@multiclawcli/dashboard 0.4.0 → 0.5.1

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.js CHANGED
@@ -3,8 +3,8 @@ import { render } from "ink";
3
3
  import { createElement } from "react";
4
4
 
5
5
  // src/tui/App.tsx
6
- import * as path from "path";
7
- import { isAbortError, Orchestrator } from "@multiclawcli/core";
6
+ import * as path2 from "path";
7
+ import { describeResumePlan, isAbortError, Orchestrator } from "@multiclawcli/core";
8
8
  import { Box as Box5, Text as Text5, useApp, useInput, useWindowSize } from "ink";
9
9
  import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
10
10
 
@@ -15,7 +15,8 @@ var COMMANDS = [
15
15
  name: "focus",
16
16
  args: "<id>",
17
17
  description: "Focus an agent by id",
18
- availability: "always"
18
+ availability: "always",
19
+ completes: "agent"
19
20
  },
20
21
  { name: "clear", description: "Clear the focused agent's log", availability: "always" },
21
22
  {
@@ -23,6 +24,46 @@ var COMMANDS = [
23
24
  description: "Browse finished turns",
24
25
  availability: "always"
25
26
  },
27
+ {
28
+ name: "verbose",
29
+ description: "Toggle tool detail in the stream",
30
+ availability: "always"
31
+ },
32
+ {
33
+ name: "retry",
34
+ args: "[id]",
35
+ description: "Re-run a failed agent (or all of them)",
36
+ availability: "idle"
37
+ },
38
+ {
39
+ name: "replay",
40
+ args: "[turn]",
41
+ description: "Run an earlier turn again",
42
+ availability: "idle"
43
+ },
44
+ {
45
+ name: "fork",
46
+ args: "[turn]",
47
+ description: "Edit an earlier turn's requirement",
48
+ availability: "always"
49
+ },
50
+ {
51
+ name: "export",
52
+ args: "[turn] [path]",
53
+ description: "Export a finished turn as Markdown",
54
+ availability: "always"
55
+ },
56
+ {
57
+ name: "search",
58
+ args: "<text>",
59
+ description: "Search the focused stream",
60
+ availability: "always"
61
+ },
62
+ {
63
+ name: "latest",
64
+ description: "Jump the stream to latest output",
65
+ availability: "always"
66
+ },
26
67
  {
27
68
  name: "cancel",
28
69
  description: "Cancel the running turn and its queue",
@@ -35,6 +76,16 @@ function availableCommands(running) {
35
76
  const phase = running ? "running" : "idle";
36
77
  return COMMANDS.filter((c) => c.availability === "always" || c.availability === phase);
37
78
  }
79
+ function canRun(name, running) {
80
+ const command = COMMANDS.find((c) => c.name === name);
81
+ if (!command) return true;
82
+ return availableCommands(running).includes(command);
83
+ }
84
+ function unavailableReason(name, running) {
85
+ const command = COMMANDS.find((c) => c.name === name);
86
+ if (!command || availableCommands(running).includes(command)) return void 0;
87
+ return running ? `/${name} waits for the current turn to finish` : `/${name} only applies while a turn is running`;
88
+ }
38
89
  function matchCommands(query, running) {
39
90
  const q = query.trim().toLowerCase();
40
91
  const pool = availableCommands(running);
@@ -60,16 +111,117 @@ function parseCommand(text) {
60
111
  const [name = "", ...args] = text.slice(1).trim().split(/\s+/);
61
112
  return name ? { name, args } : void 0;
62
113
  }
114
+ function paletteRows(commands) {
115
+ return commands.map((command) => ({
116
+ key: command.name,
117
+ label: command.args ? `/${command.name} ${command.args}` : `/${command.name}`,
118
+ description: command.description
119
+ }));
120
+ }
121
+ function argQuery(text) {
122
+ if (!isCommandLine(text)) return void 0;
123
+ const match = /^(\S+)\s+(\S*)$/.exec(text);
124
+ if (!match) return void 0;
125
+ const command = COMMANDS.find((c) => c.name === match[1].slice(1));
126
+ if (!command?.completes) return void 0;
127
+ return { command, partial: match[2] };
128
+ }
129
+ function completeAgents(partial, ids, limit = 8) {
130
+ const query = partial.toLowerCase();
131
+ const matches = query ? ids.filter((id) => id.toLowerCase().startsWith(query)) : [...ids];
132
+ return matches.slice(0, limit);
133
+ }
134
+ function resolveAgentId(input, ids) {
135
+ if (ids.includes(input)) return { id: input };
136
+ const query = input.toLowerCase();
137
+ const hits = ids.filter((id) => id.toLowerCase().startsWith(query));
138
+ if (hits.length === 1) return { id: hits[0] };
139
+ if (hits.length === 0) return { error: `unknown agent: ${input}` };
140
+ return { error: `ambiguous: ${hits.join(", ")}` };
141
+ }
63
142
 
64
143
  // src/tui/components/chrome.tsx
65
144
  import { Box, Text } from "ink";
66
145
 
67
146
  // src/tui/format.ts
147
+ import stringWidth from "string-width";
68
148
  function truncate(text, max) {
69
149
  if (max <= 0) return "";
70
150
  if (text.length <= max) return text;
71
151
  return `${text.slice(0, Math.max(0, max - 1))}\u2026`;
72
152
  }
153
+ function displayWidth(text) {
154
+ return stringWidth(text);
155
+ }
156
+ function wrapLine(line, width) {
157
+ const limit = Math.max(1, width);
158
+ if (line === "") return [""];
159
+ const rows = [];
160
+ let row = "";
161
+ let empty = true;
162
+ const flush = () => {
163
+ if (!empty) rows.push(row);
164
+ row = "";
165
+ empty = true;
166
+ };
167
+ const overflow = (word) => {
168
+ const chunks = splitByWidth(word, limit);
169
+ for (const chunk of chunks.slice(0, -1)) rows.push(chunk);
170
+ row = chunks[chunks.length - 1];
171
+ empty = row === "";
172
+ };
173
+ for (const word of line.split(" ")) {
174
+ const candidate = empty ? word : `${row} ${word}`;
175
+ if (displayWidth(candidate) <= limit) {
176
+ row = candidate;
177
+ empty = false;
178
+ continue;
179
+ }
180
+ const head = breakableHead(empty ? "" : row, word, limit);
181
+ if (head === 0) {
182
+ flush();
183
+ overflow(word);
184
+ continue;
185
+ }
186
+ rows.push(empty ? word.slice(0, head) : `${row} ${word.slice(0, head)}`);
187
+ overflow(word.slice(head));
188
+ }
189
+ flush();
190
+ return rows;
191
+ }
192
+ var CLOSERS = "\u3002\uFF0C\u3001\uFF1B\uFF1A\uFF1F\uFF01\uFF09\uFF3D\uFF5D\u300D\u300F\u3011\u300B\u3009\u201D\u2019\u2026\u2014\xB7";
193
+ function breakableHead(row, word, limit) {
194
+ const chars = [...word];
195
+ let used = row === "" ? 0 : displayWidth(row) + 1;
196
+ let head = 0;
197
+ for (let i = 0; i < chars.length; i++) {
198
+ const charWidth = displayWidth(chars[i]);
199
+ if (used + charWidth > limit) break;
200
+ used += charWidth;
201
+ const next = chars[i + 1];
202
+ if (next !== void 0 && CLOSERS.includes(next)) continue;
203
+ if (charWidth >= 2 || next !== void 0 && displayWidth(next) >= 2) head = i + 1;
204
+ }
205
+ return head;
206
+ }
207
+ function splitByWidth(text, width) {
208
+ const chunks = [];
209
+ let chunk = "";
210
+ let used = 0;
211
+ for (const char of text) {
212
+ const charWidth = displayWidth(char);
213
+ if (chunk !== "" && used + charWidth > width) {
214
+ chunks.push(chunk);
215
+ chunk = char;
216
+ used = charWidth;
217
+ } else {
218
+ chunk += char;
219
+ used += charWidth;
220
+ }
221
+ }
222
+ chunks.push(chunk);
223
+ return chunks;
224
+ }
73
225
  function formatDuration(ms) {
74
226
  if (!Number.isFinite(ms) || ms < 0) return "\u2014";
75
227
  if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
@@ -86,6 +238,22 @@ function circled(n) {
86
238
  function padRight(text, width) {
87
239
  return text.length >= width ? text : text + " ".repeat(width - text.length);
88
240
  }
241
+ function oneDecimal(value) {
242
+ const fixed = value.toFixed(1);
243
+ return fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed;
244
+ }
245
+ function formatTokens(n) {
246
+ if (!Number.isFinite(n) || n < 0) return "\u2014";
247
+ if (n < 1e3) return String(Math.round(n));
248
+ if (n < 1e6) return `${oneDecimal(n / 1e3)}k`;
249
+ return `${oneDecimal(n / 1e6)}M`;
250
+ }
251
+ function formatCost(usd) {
252
+ if (!Number.isFinite(usd) || usd < 0) return "\u2014";
253
+ if (usd === 0) return "$0.00";
254
+ if (usd < 0.01) return "<$0.01";
255
+ return `$${usd.toFixed(2)}`;
256
+ }
89
257
 
90
258
  // src/tui/layout.ts
91
259
  var MIN_COLUMNS = 40;
@@ -129,18 +297,23 @@ function computeLayout(input) {
129
297
  queueRows + menuRows + 1 + // prompt
130
298
  1;
131
299
  const bodyHeight = Math.max(MIN_BODY_ROWS, rows - reserved);
300
+ const showTeam = !compact;
301
+ const teamWidth = Math.min(30, Math.max(18, Math.floor(columns * 0.24)));
302
+ const streamChrome = (showTeam ? teamWidth + 1 : 0) + 1;
303
+ const streamWidth = Math.max(1, columns - streamChrome - 1);
132
304
  return {
133
305
  columns,
134
306
  rows,
135
307
  compact,
136
- showTeam: !compact,
308
+ showTeam,
137
309
  showRail,
138
310
  railRows,
139
311
  welcome,
140
312
  bodyHeight,
141
313
  // The stream spends one of the body's rows on its own header.
142
314
  logViewport: Math.max(1, bodyHeight - 1),
143
- teamWidth: Math.min(30, Math.max(18, Math.floor(columns * 0.24))),
315
+ teamWidth,
316
+ streamWidth,
144
317
  menuRows,
145
318
  queueRows
146
319
  };
@@ -160,6 +333,9 @@ var STATUS = {
160
333
  retrying: { glyph: "\u21BB", color: COLOR.warn, label: "retrying" },
161
334
  success: { glyph: "\u2713", color: COLOR.ok, label: "done" },
162
335
  failed: { glyph: "\u2717", color: COLOR.error, label: "failed" },
336
+ // Stopped, not broken: a filled square reads as "halted" and shares the muted
337
+ // treatment of a skip, so a Ctrl-C does not paint the roster red.
338
+ cancelled: { glyph: "\u25A0", color: COLOR.muted, dim: true, label: "cancelled" },
163
339
  skipped: { glyph: "\u2298", color: COLOR.muted, dim: true, label: "skipped" }
164
340
  };
165
341
  var SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
@@ -187,7 +363,16 @@ var GLYPH = {
187
363
  /** The leader is asking for a decision rather than reporting one. */
188
364
  ask: "?",
189
365
  /** A mid-turn warning that rides alongside the outcome rather than hiding it. */
190
- warn: "\u26A0"
366
+ warn: "\u26A0",
367
+ /**
368
+ * A pasted line break, drawn in the one-line prompt.
369
+ *
370
+ * MUST be exactly one column wide. `Prompt` maps `\n` to this glyph 1:1 — one
371
+ * character in, one character out — so its caret index stays valid; a glyph
372
+ * that rendered as two columns would push the caret off by one for every
373
+ * newline before it. If a terminal disagrees, this single glyph is the fix.
374
+ */
375
+ newline: "\u21B5"
191
376
  };
192
377
 
193
378
  // src/tui/components/chrome.tsx
@@ -304,7 +489,8 @@ var initialState = {
304
489
  leaderIds: [],
305
490
  autoFollow: true,
306
491
  scroll: 0,
307
- history: []
492
+ history: [],
493
+ verbose: false
308
494
  };
309
495
  function stripAnsi(text) {
310
496
  return text.replace(ANSI_PATTERN, "");
@@ -346,19 +532,20 @@ function seedAgents(config) {
346
532
  }
347
533
  return agents;
348
534
  }
349
- function createInitialState(config) {
535
+ function createInitialState(config, history = []) {
350
536
  const agents = seedAgents(config);
351
537
  return {
352
538
  phase: "idle",
353
539
  name: config.name,
354
- turn: 0,
540
+ turn: history[history.length - 1]?.turn ?? 0,
355
541
  stages: [],
356
542
  leaderIds: leaderAgentIds(config),
357
543
  agents,
358
544
  focusId: Object.keys(agents)[0],
359
545
  autoFollow: true,
360
546
  scroll: 0,
361
- history: []
547
+ history,
548
+ verbose: false
362
549
  };
363
550
  }
364
551
  function mergeStageAgents(agents, stages) {
@@ -393,6 +580,7 @@ function reduceEvent(state, event) {
393
580
  phase: "running",
394
581
  name: event.payload.name,
395
582
  requirement: event.payload.requirement ?? state.requirement,
583
+ reason: event.payload.selectionReason,
396
584
  stages: event.payload.stages,
397
585
  agents: mergeStageAgents(state.agents, event.payload.stages)
398
586
  };
@@ -441,7 +629,8 @@ function reduceEvent(state, event) {
441
629
  };
442
630
  }
443
631
  case "agent:output": {
444
- const { agentId, chunk } = event.payload;
632
+ const { agentId, chunk, kind } = event.payload;
633
+ if (kind === "tool-detail" && !state.verbose) return state;
445
634
  const existing = state.agents[agentId];
446
635
  if (!existing) return state;
447
636
  return {
@@ -455,20 +644,19 @@ function reduceEvent(state, event) {
455
644
  case "agent:complete":
456
645
  case "agent:failed": {
457
646
  const result = event.payload;
458
- const status = event.type === "agent:complete" ? "success" : "failed";
459
- const existing = state.agents[result.agentId];
460
- const next = existing ? {
461
- ...existing,
462
- status,
463
- duration: result.duration,
464
- attempts: result.attempts,
465
- error: result.error
466
- } : blankAgent(result.agentId, result.agentName, {
467
- status,
647
+ const patch = {
648
+ status: result.status,
468
649
  duration: result.duration,
469
650
  attempts: result.attempts,
470
- error: result.error
471
- });
651
+ error: result.error,
652
+ usage: result.usage,
653
+ costUsd: result.costUsd,
654
+ // Present only on the success path; a failed agent has no session to
655
+ // continue, so `/retry` re-runs it cold.
656
+ sessionId: result.sessionId
657
+ };
658
+ const existing = state.agents[result.agentId];
659
+ const next = existing ? { ...existing, ...patch } : blankAgent(result.agentId, result.agentName, patch);
472
660
  return { ...state, agents: { ...state.agents, [result.agentId]: next } };
473
661
  }
474
662
  case "agent:skipped": {
@@ -495,6 +683,7 @@ function reduceEvent(state, event) {
495
683
  phase: "complete",
496
684
  success: event.payload.success,
497
685
  totalDuration: event.payload.totalDuration,
686
+ totalCostUsd: event.payload.totalCostUsd,
498
687
  endedAt: epoch(event.timestamp) ?? Date.now(),
499
688
  reply: event.payload.reply
500
689
  };
@@ -502,9 +691,12 @@ function reduceEvent(state, event) {
502
691
  return state;
503
692
  }
504
693
  }
505
- function focusedLineCount(turn) {
694
+ function wrapLog(logs, width) {
695
+ return logs.split("\n").flatMap((line) => wrapLine(line, width));
696
+ }
697
+ function focusedRowCount(turn, width) {
506
698
  const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
507
- return agent?.logs ? agent.logs.split("\n").length : 0;
699
+ return agent?.logs ? wrapLog(agent.logs, width).length : 0;
508
700
  }
509
701
  function snapshot(state) {
510
702
  return {
@@ -521,6 +713,8 @@ function snapshot(state) {
521
713
  scroll: state.scroll,
522
714
  success: state.success,
523
715
  totalDuration: state.totalDuration,
716
+ totalCostUsd: state.totalCostUsd,
717
+ reason: state.reason,
524
718
  warning: state.warning,
525
719
  reply: state.reply
526
720
  };
@@ -533,9 +727,12 @@ function mapShown(state, fn) {
533
727
  const next = fn(state);
534
728
  return next === state ? state : { ...state, ...next };
535
729
  }
730
+ function recordedTurns(state) {
731
+ return [...state.history, ...state.phase === "complete" ? [snapshot(state)] : []];
732
+ }
536
733
  function conversationTurns(state) {
537
734
  const turns = [];
538
- for (const turn of [...state.history, snapshot(state)]) {
735
+ for (const turn of recordedTurns(state)) {
539
736
  if (turn.phase !== "complete" || !turn.requirement) continue;
540
737
  turns.push(
541
738
  turn.reply ? { requirement: turn.requirement, reply: turn.reply } : { requirement: turn.requirement }
@@ -549,7 +746,7 @@ function tuiReducer(state, action) {
549
746
  return reduceEvent(state, action.event);
550
747
  case "turn:start": {
551
748
  const ids = Object.keys(action.agents);
552
- const history = state.turn > 0 ? [...state.history, snapshot(state)].slice(-MAX_HISTORY) : state.history;
749
+ const history = state.requirement !== void 0 ? [...state.history, snapshot(state)].slice(-MAX_HISTORY) : state.history;
553
750
  return {
554
751
  phase: "running",
555
752
  name: state.name,
@@ -563,11 +760,15 @@ function tuiReducer(state, action) {
563
760
  autoFollow: true,
564
761
  scroll: 0,
565
762
  history,
566
- // A new requirement replaces the previous answer; leaving it up would
567
- // make the old reply look like the response to the new request. Reading
568
- // a past turn ends for the same reason — the panes belong to the run.
763
+ // A new requirement also replaces the previous leader metadata; leaving
764
+ // it up would make the old answer or routing reason look like the
765
+ // response to the new request. Reading a past turn ends for the same
766
+ // reason — the panes belong to the run.
767
+ reason: void 0,
569
768
  reply: void 0,
570
- view: void 0
769
+ view: void 0,
770
+ // A console setting, carried across turns rather than reset with them.
771
+ verbose: state.verbose
571
772
  };
572
773
  }
573
774
  case "focus:cycle":
@@ -587,11 +788,35 @@ function tuiReducer(state, action) {
587
788
  });
588
789
  case "stream:scroll":
589
790
  return mapShown(state, (turn) => {
590
- const lines = focusedLineCount(turn);
591
- const maxScroll = Math.max(0, lines - Math.max(1, action.viewport));
791
+ const rows = focusedRowCount(turn, action.width);
792
+ const maxScroll = Math.max(0, rows - Math.max(1, action.viewport));
592
793
  const next = Math.min(Math.max(0, turn.scroll + action.delta), maxScroll);
593
794
  return next === turn.scroll ? turn : { ...turn, scroll: next };
594
795
  });
796
+ case "stream:search":
797
+ return mapShown(state, (turn) => {
798
+ const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
799
+ if (!agent?.logs || action.query === "") return turn;
800
+ const rows = wrapLog(agent.logs, action.width);
801
+ const needle = action.query.toLowerCase();
802
+ let index = -1;
803
+ for (let i = rows.length - 1; i >= 0; i--) {
804
+ if (rows[i].toLowerCase().includes(needle)) {
805
+ index = i;
806
+ break;
807
+ }
808
+ }
809
+ if (index === -1) return turn;
810
+ const height = Math.max(1, action.viewport);
811
+ const maxScroll = Math.max(0, rows.length - height);
812
+ const scroll = Math.min(Math.max(0, rows.length - 1 - index), maxScroll);
813
+ return scroll === turn.scroll && !turn.autoFollow ? turn : { ...turn, scroll, autoFollow: false };
814
+ });
815
+ case "stream:latest":
816
+ return mapShown(
817
+ state,
818
+ (turn) => turn.scroll === 0 && turn.autoFollow ? turn : { ...turn, scroll: 0, autoFollow: true }
819
+ );
595
820
  case "stream:clear":
596
821
  return mapShown(state, (turn) => {
597
822
  const id = turn.focusId;
@@ -605,6 +830,8 @@ function tuiReducer(state, action) {
605
830
  }
606
831
  case "view:close":
607
832
  return state.view ? { ...state, view: void 0 } : state;
833
+ case "verbose:toggle":
834
+ return { ...state, verbose: !state.verbose };
608
835
  }
609
836
  }
610
837
  function stageStatus(stage, agents) {
@@ -613,9 +840,15 @@ function stageStatus(stage, agents) {
613
840
  if (statuses.some((s) => s === "running" || s === "retrying")) return "running";
614
841
  if (statuses.length > 0 && statuses.every((s) => s === "success")) return "success";
615
842
  if (statuses.length > 0 && statuses.every((s) => s === "skipped")) return "skipped";
843
+ if (statuses.length > 0 && statuses.every((s) => s === "cancelled")) return "cancelled";
616
844
  return "pending";
617
845
  }
618
- var SETTLED = /* @__PURE__ */ new Set(["success", "failed", "skipped"]);
846
+ var SETTLED = /* @__PURE__ */ new Set([
847
+ "success",
848
+ "failed",
849
+ "cancelled",
850
+ "skipped"
851
+ ]);
619
852
  function progress(turn) {
620
853
  const agents = Object.values(turn.agents);
621
854
  return { done: agents.filter((a) => SETTLED.has(a.status)).length, total: agents.length };
@@ -632,15 +865,15 @@ function turnElapsed(turn, now) {
632
865
  const end = turn.phase === "running" ? now : turn.endedAt ?? now;
633
866
  return Math.max(0, end - turn.startedAt);
634
867
  }
635
- function visibleLog(turn, viewport) {
868
+ function visibleLog(turn, viewport, width) {
636
869
  const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
637
- const all = agent?.logs ? agent.logs.split("\n") : [];
870
+ const all = agent?.logs ? wrapLog(agent.logs, width) : [];
638
871
  const height = Math.max(1, viewport);
639
872
  const maxScroll = Math.max(0, all.length - height);
640
873
  const scrolledBack = Math.min(Math.max(0, turn.scroll), maxScroll);
641
874
  const end = all.length - scrolledBack;
642
875
  return {
643
- lines: all.slice(Math.max(0, end - height), end),
876
+ rows: all.slice(Math.max(0, end - height), end),
644
877
  scrolledBack,
645
878
  following: scrolledBack === 0
646
879
  };
@@ -672,9 +905,9 @@ var HELP_GROUPS = [
672
905
  title: "commands",
673
906
  rows: [
674
907
  ["/", "Command menu"],
675
- ["/history", "Browse finished turns"],
676
908
  ["?", "This help"],
677
- ["ctrl+c", "Cancel the turn and its queue \xB7 again to quit"]
909
+ ["ctrl+c", "Cancel the turn and its queue \xB7 again to quit"],
910
+ ["/<command>", `All ${COMMANDS.length} commands \u2014 type / to browse`]
678
911
  ]
679
912
  }
680
913
  ];
@@ -684,7 +917,7 @@ var HISTORY_CHROME_ROWS = 4;
684
917
  function historyCapacity(maxRows) {
685
918
  return Math.max(1, Math.floor((maxRows - HISTORY_CHROME_ROWS) / TURN_ROWS));
686
919
  }
687
- function historyWindowStart(selected, total, capacity) {
920
+ function listWindowStart(selected, total, capacity) {
688
921
  const maxStart = Math.max(0, total - capacity);
689
922
  return Math.min(Math.max(0, Math.min(selected, selected - capacity + 1)), maxStart);
690
923
  }
@@ -722,7 +955,7 @@ function HistoryOverlay({
722
955
  maxRows
723
956
  }) {
724
957
  const capacity = historyCapacity(maxRows);
725
- const start = historyWindowStart(selected, turns.length, capacity);
958
+ const start = listWindowStart(selected, turns.length, capacity);
726
959
  const visible = turns.slice(start, start + capacity);
727
960
  const range = turns.length > capacity ? ` \xB7 showing ${start + 1}\u2013${start + visible.length} of ${turns.length}` : "";
728
961
  return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, paddingTop: 1, width, children: [
@@ -803,7 +1036,11 @@ function TeamRow({
803
1036
  glyph,
804
1037
  " ",
805
1038
  agent.icon ? `${agent.icon} ` : "",
806
- agent.name
1039
+ agent.name,
1040
+ agent.taskTitle ? /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1041
+ " ",
1042
+ agent.taskTitle
1043
+ ] }) : null
807
1044
  ]
808
1045
  }
809
1046
  ) }),
@@ -856,18 +1093,19 @@ function Divider() {
856
1093
  function StreamPanel({
857
1094
  turn,
858
1095
  height,
1096
+ width,
859
1097
  now,
860
1098
  spinner
861
1099
  }) {
862
1100
  const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
863
1101
  const viewport = Math.max(1, height - 1);
864
- const window = visibleLog(turn, viewport);
1102
+ const window = visibleLog(turn, viewport, width);
865
1103
  const style = agent ? STATUS[agent.status] : void 0;
866
1104
  const elapsed = agent ? agentElapsed(agent, now) : void 0;
867
1105
  return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", flexGrow: 1, height, paddingLeft: 1, children: [
868
1106
  /* @__PURE__ */ jsxs3(Box3, { justifyContent: "space-between", children: [
869
- /* @__PURE__ */ jsxs3(Box3, { flexShrink: 1, children: [
870
- /* @__PURE__ */ jsx3(Text3, { bold: true, wrap: "truncate-end", children: agent?.name ?? "\u2014" }),
1107
+ /* @__PURE__ */ jsx3(Box3, { flexShrink: 1, children: /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate-end", children: [
1108
+ /* @__PURE__ */ jsx3(Text3, { bold: true, children: agent?.name ?? "\u2014" }),
871
1109
  agent?.model ? /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
872
1110
  " \xB7 ",
873
1111
  agent.model
@@ -875,8 +1113,23 @@ function StreamPanel({
875
1113
  agent && style ? /* @__PURE__ */ jsxs3(Fragment, { children: [
876
1114
  /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: " \xB7 " }),
877
1115
  /* @__PURE__ */ jsx3(Text3, { color: style.color, dimColor: style.dim, children: agent.status === "running" ? `${spinner} ${style.label}` : style.label })
1116
+ ] }) : null,
1117
+ agent?.taskTitle ? /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1118
+ " \xB7 ",
1119
+ agent.taskTitle
1120
+ ] }) : null,
1121
+ agent?.usage?.contextTokens !== void 0 && agent.usage.contextSize !== void 0 ? /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1122
+ " ",
1123
+ "\xB7 ctx ",
1124
+ formatTokens(agent.usage.contextTokens),
1125
+ "/",
1126
+ formatTokens(agent.usage.contextSize)
1127
+ ] }) : null,
1128
+ agent?.costUsd !== void 0 ? /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
1129
+ " \xB7 ",
1130
+ formatCost(agent.costUsd)
878
1131
  ] }) : null
879
- ] }),
1132
+ ] }) }),
880
1133
  /* @__PURE__ */ jsxs3(Box3, { flexShrink: 0, children: [
881
1134
  elapsed === void 0 ? null : /* @__PURE__ */ jsxs3(Text3, { dimColor: true, children: [
882
1135
  formatDuration(elapsed),
@@ -885,11 +1138,28 @@ function StreamPanel({
885
1138
  /* @__PURE__ */ jsx3(Text3, { color: window.following ? COLOR.accent : COLOR.warn, children: window.following ? GLYPH.live : `${GLYPH.paused} ${window.scrolledBack}\u2191` })
886
1139
  ] })
887
1140
  ] }),
888
- window.lines.length === 0 ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: turn.phase === "running" ? "waiting for output\u2026" : "no output yet" }) : window.lines.map((line, index) => (
889
- // Log lines are an append-only tail; position is the identity.
890
- // biome-ignore lint/suspicious/noArrayIndexKey: positional log tail
891
- /* @__PURE__ */ jsx3(Text3, { wrap: "truncate-end", children: line }, index)
892
- ))
1141
+ window.rows.length === 0 ? /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: turn.phase === "running" ? "waiting for output\u2026" : "no output yet" }) : (
1142
+ // The tail, already wrapped to `width` by `visibleLog` — the pane draws
1143
+ // rows, and one log line is several of them. `visibleLog` also picks
1144
+ // which rows, so the content here is exactly the pane's height and the
1145
+ // oldest rows are the ones already gone: nothing is left to clip, which
1146
+ // is why this box is not bottom-pinned. Pinning it would only leave a
1147
+ // gap under the header whenever an agent has not said much yet.
1148
+ //
1149
+ // Rows are rendered with `truncate-end` rather than letting Ink wrap
1150
+ // them again: they are supposed to fit, and if `width` was ever
1151
+ // optimistic the failure should be a shaved row, not a re-wrap that
1152
+ // overflows the pane and pushes the newest output out of sight.
1153
+ //
1154
+ // `flexShrink={0}` stops Yoga compressing the rows into each other,
1155
+ // and `overflow="hidden"` is the backstop if any of the above is ever
1156
+ // wrong.
1157
+ /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", flexShrink: 0, height: viewport, overflow: "hidden", children: window.rows.map((row, index) => (
1158
+ // Rows are an append-only tail; position is the identity.
1159
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional log tail
1160
+ /* @__PURE__ */ jsx3(Box3, { flexShrink: 0, children: /* @__PURE__ */ jsx3(Text3, { wrap: "truncate-end", children: row }) }, index)
1161
+ )) })
1162
+ )
893
1163
  ] });
894
1164
  }
895
1165
  function ReplyPanel({ reply, width }) {
@@ -909,6 +1179,19 @@ function ReplyPanel({ reply, width }) {
909
1179
  ] })
910
1180
  );
911
1181
  }
1182
+ function ErrorPanel({ message, width }) {
1183
+ return (
1184
+ // `flexShrink={0}` — see ReplyPanel. A compressed column box folds its own
1185
+ // text rows into each other before the body gets a chance to clip them.
1186
+ /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, paddingTop: 1, width, children: [
1187
+ /* @__PURE__ */ jsxs3(Text3, { color: COLOR.error, bold: true, children: [
1188
+ GLYPH.warn,
1189
+ " the run stopped"
1190
+ ] }),
1191
+ /* @__PURE__ */ jsx3(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx3(Text3, { color: COLOR.error, children: message }) })
1192
+ ] })
1193
+ );
1194
+ }
912
1195
  function WelcomePanel({ turn, width }) {
913
1196
  const agents = Object.values(turn.agents);
914
1197
  const leaderId = turn.leaderIds[0];
@@ -951,10 +1234,11 @@ function Prompt({
951
1234
  caretVisible,
952
1235
  placeholder
953
1236
  }) {
1237
+ const shown = value.replace(/\r/g, "").replace(/\n/g, GLYPH.newline);
954
1238
  const ghost = value === "";
955
- const before = value.slice(0, cursor);
956
- const cell = ghost ? placeholder.slice(0, 1) : value.slice(cursor, cursor + 1);
957
- const tail = ghost ? placeholder.slice(1) : value.slice(cursor + 1);
1239
+ const before = shown.slice(0, cursor);
1240
+ const cell = ghost ? placeholder.slice(0, 1) : shown.slice(cursor, cursor + 1);
1241
+ const tail = ghost ? placeholder.slice(1) : shown.slice(cursor + 1);
958
1242
  return /* @__PURE__ */ jsxs4(Box4, { flexShrink: 0, children: [
959
1243
  /* @__PURE__ */ jsxs4(Text4, { color: COLOR.accent, children: [
960
1244
  GLYPH.caret,
@@ -965,14 +1249,10 @@ function Prompt({
965
1249
  /* @__PURE__ */ jsx4(Text4, { dimColor: ghost, children: tail })
966
1250
  ] });
967
1251
  }
968
- function CommandMenu({ commands, selected }) {
969
- const nameWidth = Math.max(
970
- 1,
971
- ...commands.map((c) => c.name.length + (c.args ? c.args.length + 1 : 0))
972
- );
973
- return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", paddingLeft: 2, children: commands.map((command, index) => {
1252
+ function CommandMenu({ rows, selected }) {
1253
+ const labelWidth = Math.max(1, ...rows.map((row) => row.label.length));
1254
+ return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", paddingLeft: 2, children: rows.map((row, index) => {
974
1255
  const active = index === selected;
975
- const label = command.args ? `${command.name} ${command.args}` : command.name;
976
1256
  return /* @__PURE__ */ jsxs4(
977
1257
  Text4,
978
1258
  {
@@ -981,17 +1261,82 @@ function CommandMenu({ commands, selected }) {
981
1261
  wrap: "truncate-end",
982
1262
  children: [
983
1263
  active ? GLYPH.caret : " ",
984
- " /",
985
- padRight(label, nameWidth),
986
1264
  " ",
987
- command.description
1265
+ padRight(row.label, labelWidth),
1266
+ " ",
1267
+ row.description
988
1268
  ]
989
1269
  },
990
- command.name
1270
+ row.key
991
1271
  );
992
1272
  }) });
993
1273
  }
994
1274
 
1275
+ // src/tui/history.ts
1276
+ import * as fs from "fs";
1277
+ import * as path from "path";
1278
+ var CONSOLE_DIR = "console";
1279
+ var HISTORY_FILE = "history.json";
1280
+ var EXPORT_DIR = "exports";
1281
+ function historyPath(workDir) {
1282
+ return path.join(workDir, ".multiclaw", CONSOLE_DIR, HISTORY_FILE);
1283
+ }
1284
+ function isObject(value) {
1285
+ return typeof value === "object" && value !== null;
1286
+ }
1287
+ function isTurnView(value) {
1288
+ if (!isObject(value)) return false;
1289
+ const turn = value;
1290
+ return typeof turn.turn === "number" && typeof turn.phase === "string" && isObject(turn.agents) && Object.values(turn.agents).every(isObject) && Array.isArray(turn.stages) && turn.stages.every(isObject) && Array.isArray(turn.leaderIds);
1291
+ }
1292
+ function loadHistory(workDir) {
1293
+ try {
1294
+ const raw = fs.readFileSync(historyPath(workDir), "utf-8");
1295
+ const parsed = JSON.parse(raw);
1296
+ if (!Array.isArray(parsed)) return [];
1297
+ return parsed.filter(isTurnView).slice(-MAX_HISTORY);
1298
+ } catch {
1299
+ return [];
1300
+ }
1301
+ }
1302
+ async function saveHistory(workDir, turns) {
1303
+ const target = historyPath(workDir);
1304
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
1305
+ const tmp = `${target}.tmp`;
1306
+ await fs.promises.writeFile(tmp, JSON.stringify(turns), "utf-8");
1307
+ await fs.promises.rename(tmp, target);
1308
+ }
1309
+ function exportPath(workDir, turn) {
1310
+ return path.join(workDir, ".multiclaw", CONSOLE_DIR, EXPORT_DIR, `turn-${turn}.md`);
1311
+ }
1312
+ async function exportTurn(turn, filePath) {
1313
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
1314
+ await fs.promises.writeFile(filePath, formatTurnMarkdown(turn), "utf-8");
1315
+ }
1316
+ function formatTurnMarkdown(turn) {
1317
+ const lines = [`# Turn ${turn.turn}`, ""];
1318
+ if (turn.requirement) lines.push("## Requirement", "", turn.requirement, "");
1319
+ if (turn.reply) {
1320
+ lines.push("## Reply", "", `_${turn.reply.mode}_`, "", turn.reply.message, "");
1321
+ } else {
1322
+ lines.push("## Result", "", `Status: ${turn.success ? "success" : "failed"}`, "");
1323
+ }
1324
+ const agents = Object.values(turn.agents);
1325
+ if (agents.length > 0) {
1326
+ lines.push("## Agents", "");
1327
+ for (const agent of agents) {
1328
+ lines.push(`- ${agent.id}: ${agent.name} \u2014 ${agent.status}`);
1329
+ }
1330
+ lines.push("");
1331
+ lines.push("## Logs", "");
1332
+ for (const agent of agents) {
1333
+ lines.push(`### ${agent.id}: ${agent.name}`, "", "```", agent.logs ?? "", "```", "");
1334
+ }
1335
+ }
1336
+ return `${lines.join("\n").trimEnd()}
1337
+ `;
1338
+ }
1339
+
995
1340
  // src/tui/App.tsx
996
1341
  import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
997
1342
  var FLUSH_INTERVAL_MS = 80;
@@ -1000,6 +1345,19 @@ var BLINK_INTERVAL_MS = 530;
1000
1345
  var INPUT_PLACEHOLDER = "describe what the team should do\u2026";
1001
1346
  var RUNNING_PLACEHOLDER = "type to queue a follow-up\u2026";
1002
1347
  var EMPTY_LINE = { text: "", cursor: 0 };
1348
+ function pickRecordedTurn(state, raw, whenEmpty) {
1349
+ const turns = recordedTurns(state);
1350
+ if (turns.length === 0) return { error: whenEmpty };
1351
+ const target = raw === void 0 ? turns[turns.length - 1] : turns.find((t) => String(t.turn) === raw);
1352
+ if (!target) return { error: `no turn ${raw}` };
1353
+ return { record: target };
1354
+ }
1355
+ function pickTurn(state, raw, whenEmpty) {
1356
+ const target = pickRecordedTurn(state, raw, whenEmpty);
1357
+ if ("error" in target) return target;
1358
+ if (!target.record.requirement) return { error: `turn ${target.record.turn} has no requirement` };
1359
+ return { record: target.record, requirement: target.record.requirement };
1360
+ }
1003
1361
  function StatusLine({
1004
1362
  state,
1005
1363
  running,
@@ -1037,8 +1395,10 @@ function StatusLine({
1037
1395
  if (state.phase === "complete") {
1038
1396
  const time = state.totalDuration === void 0 ? "" : ` ${formatDuration(state.totalDuration)}`;
1039
1397
  const outcome2 = state.success ? `${STATUS.success.glyph} done${time}` : `${STATUS.failed.glyph} finished with failures${time}`;
1398
+ const cost = state.totalCostUsd === void 0 ? "" : ` \xB7 ${formatCost(state.totalCostUsd)}`;
1040
1399
  return /* @__PURE__ */ jsxs5(Text5, { color: state.success ? COLOR.ok : COLOR.error, wrap: "truncate-end", children: [
1041
1400
  outcome2,
1401
+ cost,
1042
1402
  state.warning ? ` \xB7 ${GLYPH.warn} ${state.warning}` : ""
1043
1403
  ] });
1044
1404
  }
@@ -1053,7 +1413,11 @@ function StatusLine({
1053
1413
  function App({ config, onTurnStart }) {
1054
1414
  const { exit } = useApp();
1055
1415
  const { columns, rows } = useWindowSize();
1056
- const [state, dispatch] = useReducer(tuiReducer, config, createInitialState);
1416
+ const [state, dispatch] = useReducer(
1417
+ tuiReducer,
1418
+ config,
1419
+ (c) => createInitialState(c, loadHistory(c.workDir))
1420
+ );
1057
1421
  const stateRef = useRef(state);
1058
1422
  stateRef.current = state;
1059
1423
  const [line, setLine] = useState(EMPTY_LINE);
@@ -1092,8 +1456,19 @@ function App({ config, onTurnStart }) {
1092
1456
  const timer = setInterval(() => setCaretOn((on) => !on), BLINK_INTERVAL_MS);
1093
1457
  return () => clearInterval(timer);
1094
1458
  }, [running, line.text]);
1459
+ const lastSaved = useRef(void 0);
1460
+ useEffect(() => {
1461
+ const turns = recordedTurns(stateRef.current).slice(-MAX_HISTORY);
1462
+ if (turns.length === 0) return;
1463
+ const json = JSON.stringify(turns);
1464
+ if (json === lastSaved.current) return;
1465
+ lastSaved.current = json;
1466
+ void saveHistory(config.workDir, turns).catch(
1467
+ (err) => setNotice(`history not saved: ${err.message}`)
1468
+ );
1469
+ }, [state.history, state.phase]);
1095
1470
  const startTurn = useCallback(
1096
- async (requirement) => {
1471
+ async (requirement, opts) => {
1097
1472
  setError(void 0);
1098
1473
  setCancelled(false);
1099
1474
  setNotice(void 0);
@@ -1106,14 +1481,16 @@ function App({ config, onTurnStart }) {
1106
1481
  startedAt: Date.now()
1107
1482
  });
1108
1483
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
1109
- const logDir = path.join(config.workDir, ".multiclaw", "runs", `run-${stamp}`, "logs");
1484
+ const logDir = path2.join(config.workDir, ".multiclaw", "runs", `run-${stamp}`, "logs");
1110
1485
  const runConfig = {
1111
1486
  ...config,
1112
1487
  context: { ...config.context, requirement },
1113
1488
  // An answer only reads as a requirement next to the question it answers,
1114
1489
  // so the leader is handed the turns behind this one as well.
1115
1490
  history: earlierTurns,
1116
- logDir
1491
+ logDir,
1492
+ // Only a retry carries this; a normal turn lets the leader route.
1493
+ ...opts?.resume ? { resume: opts.resume } : {}
1117
1494
  };
1118
1495
  const controller = new AbortController();
1119
1496
  abortRef.current = controller;
@@ -1145,9 +1522,49 @@ function App({ config, onTurnStart }) {
1145
1522
  setQueue(rest);
1146
1523
  void startTurn(next);
1147
1524
  }, [running, queue, startTurn]);
1525
+ const query = menuQuery(line.text);
1526
+ const matches = useMemo(
1527
+ () => query === void 0 ? [] : matchCommands(query, running),
1528
+ [query, running]
1529
+ );
1530
+ const historyTurns = useMemo(() => [...state.history].reverse(), [state.history]);
1531
+ const historyIndex = historyTurns.length === 0 ? 0 : Math.min(historyCursor, historyTurns.length - 1);
1532
+ const shown = state.view ?? state;
1533
+ const arg = argQuery(line.text);
1534
+ const argRows = arg ? completeAgents(arg.partial, Object.keys(shown.agents)).map((id) => ({
1535
+ key: id,
1536
+ label: id,
1537
+ // The static role, not the per-turn task title: while completing, the
1538
+ // user is asking "which agent is this", not "what is it doing now".
1539
+ description: shown.agents[id]?.description ?? shown.agents[id]?.name ?? id
1540
+ })) : [];
1541
+ const allRows = arg ? argRows : paletteRows(matches);
1542
+ const paletteKey = arg ? `${arg.command.name} ${arg.partial}` : query;
1543
+ const paletteTotal = allRows.length;
1544
+ const menuIndex = menuCursor.query === paletteKey ? menuCursor.index : 0;
1545
+ const selected = paletteTotal === 0 ? 0 : Math.min(menuIndex, paletteTotal - 1);
1546
+ const menuOpen = !helpOpen && !historyOpen && query !== void 0 && matches.length > 0;
1547
+ const argOpen = !helpOpen && !historyOpen && arg !== void 0 && argRows.length > 0;
1548
+ const paletteOpen = menuOpen || argOpen;
1549
+ const layout = computeLayout({
1550
+ columns,
1551
+ rows,
1552
+ turn: state.turn,
1553
+ overlay: helpOpen || historyOpen,
1554
+ menuRows: paletteOpen ? paletteTotal : 0,
1555
+ queueRows: queue.length,
1556
+ notice: notice !== void 0,
1557
+ viewing: state.view !== void 0,
1558
+ hasRail: shown.leaderIds.length > 0 || shown.stages.length > 0,
1559
+ railCells: railCellWidths(shown)
1560
+ });
1148
1561
  const runCommand = useCallback(
1149
1562
  (name, args) => {
1150
1563
  setLine(EMPTY_LINE);
1564
+ if (!canRun(name, running)) {
1565
+ setNotice(unavailableReason(name, running));
1566
+ return;
1567
+ }
1151
1568
  const shown2 = state.view ?? state;
1152
1569
  switch (name) {
1153
1570
  case "agents":
@@ -1156,17 +1573,18 @@ function App({ config, onTurnStart }) {
1156
1573
  );
1157
1574
  return;
1158
1575
  case "focus": {
1159
- const id = args[0];
1160
- if (!id) {
1576
+ const input = args[0];
1577
+ if (!input) {
1161
1578
  setNotice("usage: /focus <agent id>");
1162
1579
  return;
1163
1580
  }
1164
- if (!shown2.agents[id]) {
1165
- setNotice(`unknown agent: ${id}`);
1581
+ const resolved = resolveAgentId(input, Object.keys(shown2.agents));
1582
+ if ("error" in resolved) {
1583
+ setNotice(resolved.error);
1166
1584
  return;
1167
1585
  }
1168
- dispatch({ type: "focus:set", agentId: id });
1169
- setNotice(`focused ${id}`);
1586
+ dispatch({ type: "focus:set", agentId: resolved.id });
1587
+ setNotice(`focused ${resolved.id}`);
1170
1588
  return;
1171
1589
  }
1172
1590
  case "clear":
@@ -1186,6 +1604,97 @@ function App({ config, onTurnStart }) {
1186
1604
  setHistoryCursor(0);
1187
1605
  setHistoryOpen(true);
1188
1606
  return;
1607
+ case "verbose":
1608
+ dispatch({ type: "verbose:toggle" });
1609
+ setNotice(state.verbose ? "verbose off" : "verbose on \u2014 showing tool detail");
1610
+ return;
1611
+ case "retry": {
1612
+ if (state.phase !== "complete" || !state.requirement) {
1613
+ setNotice("nothing to retry yet");
1614
+ return;
1615
+ }
1616
+ let ids;
1617
+ if (args[0]) {
1618
+ const resolved = resolveAgentId(args[0], Object.keys(state.agents));
1619
+ if ("error" in resolved) {
1620
+ setNotice(resolved.error);
1621
+ return;
1622
+ }
1623
+ ids = [resolved.id];
1624
+ } else {
1625
+ ids = Object.values(state.agents).filter((agent) => agent.status === "failed").map((agent) => agent.id);
1626
+ }
1627
+ if (ids.length === 0) {
1628
+ setNotice("nothing failed");
1629
+ return;
1630
+ }
1631
+ const sessions = Object.fromEntries(
1632
+ ids.filter((id) => state.agents[id]?.sessionId).map((id) => [id, state.agents[id].sessionId])
1633
+ );
1634
+ void startTurn(state.requirement, {
1635
+ resume: {
1636
+ agents: ids,
1637
+ ...Object.keys(sessions).length > 0 ? { sessions } : {}
1638
+ }
1639
+ });
1640
+ setNotice(
1641
+ `retrying ${ids.join(", ")} \u2014 ${describeResumePlan({
1642
+ agents: ids,
1643
+ sessions,
1644
+ team: config.agents
1645
+ })}`
1646
+ );
1647
+ return;
1648
+ }
1649
+ case "replay": {
1650
+ const target = pickTurn(state, args[0], "nothing to replay yet");
1651
+ if ("error" in target) {
1652
+ setNotice(target.error);
1653
+ return;
1654
+ }
1655
+ void startTurn(target.requirement);
1656
+ setNotice(`replaying turn ${target.record.turn}`);
1657
+ return;
1658
+ }
1659
+ case "fork": {
1660
+ const target = pickTurn(state, args[0], "nothing to fork yet");
1661
+ if ("error" in target) {
1662
+ setNotice(target.error);
1663
+ return;
1664
+ }
1665
+ setLine({ text: target.requirement, cursor: target.requirement.length });
1666
+ setNotice(`editing turn ${target.record.turn} \u2014 enter to run`);
1667
+ return;
1668
+ }
1669
+ case "export": {
1670
+ const target = pickRecordedTurn(state, args[0], "nothing to export yet");
1671
+ if ("error" in target) {
1672
+ setNotice(target.error);
1673
+ return;
1674
+ }
1675
+ const file = args[1] ? path2.resolve(config.workDir, args[1]) : exportPath(config.workDir, target.record.turn);
1676
+ void exportTurn(target.record, file).then(() => setNotice(`exported turn ${target.record.turn} to ${file}`)).catch((err) => setNotice(`export failed: ${err.message}`));
1677
+ return;
1678
+ }
1679
+ case "search": {
1680
+ const text = args.join(" ");
1681
+ if (!text) {
1682
+ setNotice("usage: /search <text>");
1683
+ return;
1684
+ }
1685
+ dispatch({
1686
+ type: "stream:search",
1687
+ query: text,
1688
+ viewport: layout.logViewport,
1689
+ width: layout.streamWidth
1690
+ });
1691
+ setNotice(`searched for ${text}`);
1692
+ return;
1693
+ }
1694
+ case "latest":
1695
+ dispatch({ type: "stream:latest" });
1696
+ setNotice("showing latest output");
1697
+ return;
1189
1698
  case "cancel": {
1190
1699
  if (!abortRef.current) {
1191
1700
  setNotice("nothing to cancel");
@@ -1209,7 +1718,7 @@ function App({ config, onTurnStart }) {
1209
1718
  setNotice(`unknown command: /${name}`);
1210
1719
  }
1211
1720
  },
1212
- [config, exit, queue.length, state]
1721
+ [config, exit, layout.logViewport, layout.streamWidth, queue.length, running, state, startTurn]
1213
1722
  );
1214
1723
  const submit = useCallback(() => {
1215
1724
  const text = line.text.trim();
@@ -1249,31 +1758,9 @@ function App({ config, onTurnStart }) {
1249
1758
  const moveTo = useCallback((cursor) => {
1250
1759
  setLine((l) => ({ ...l, cursor }));
1251
1760
  }, []);
1252
- const query = menuQuery(line.text);
1253
- const matches = useMemo(
1254
- () => query === void 0 ? [] : matchCommands(query, running),
1255
- [query, running]
1256
- );
1257
- const menuIndex = menuCursor.query === query ? menuCursor.index : 0;
1258
- const selected = matches.length === 0 ? 0 : Math.min(menuIndex, matches.length - 1);
1259
- const menuOpen = !helpOpen && !historyOpen && query !== void 0 && matches.length > 0;
1260
- const historyTurns = useMemo(() => [...state.history].reverse(), [state.history]);
1261
- const historyIndex = historyTurns.length === 0 ? 0 : Math.min(historyCursor, historyTurns.length - 1);
1262
- const shown = state.view ?? state;
1263
- const layout = computeLayout({
1264
- columns,
1265
- rows,
1266
- turn: state.turn,
1267
- overlay: helpOpen || historyOpen,
1268
- menuRows: menuOpen ? matches.length : 0,
1269
- queueRows: queue.length,
1270
- notice: notice !== void 0,
1271
- viewing: state.view !== void 0,
1272
- hasRail: shown.leaderIds.length > 0 || shown.stages.length > 0,
1273
- railCells: railCellWidths(shown)
1274
- });
1275
- const menuWindow = matches.slice(0, layout.menuRows);
1276
- const menuSelected = Math.min(selected, Math.max(0, menuWindow.length - 1));
1761
+ const windowStart = listWindowStart(selected, paletteTotal, layout.menuRows);
1762
+ const paletteWindow = allRows.slice(windowStart, windowStart + layout.menuRows);
1763
+ const paletteSelected = selected - windowStart;
1277
1764
  const now = Date.now();
1278
1765
  const spinner = SPINNER[tick % SPINNER.length];
1279
1766
  const handleKey = (char, key) => {
@@ -1340,13 +1827,13 @@ function App({ config, onTurnStart }) {
1340
1827
  dispatch({ type: "focus:cycle", delta: key.shift ? -1 : 1 });
1341
1828
  return;
1342
1829
  }
1343
- if (menuOpen) {
1830
+ if (paletteOpen) {
1344
1831
  if (key.upArrow) {
1345
- setMenuCursor({ query, index: (menuIndex - 1 + matches.length) % matches.length });
1832
+ setMenuCursor({ query: paletteKey, index: (menuIndex - 1 + paletteTotal) % paletteTotal });
1346
1833
  return;
1347
1834
  }
1348
1835
  if (key.downArrow) {
1349
- setMenuCursor({ query, index: (menuIndex + 1) % matches.length });
1836
+ setMenuCursor({ query: paletteKey, index: (menuIndex + 1) % paletteTotal });
1350
1837
  return;
1351
1838
  }
1352
1839
  if (key.escape) {
@@ -1354,24 +1841,45 @@ function App({ config, onTurnStart }) {
1354
1841
  return;
1355
1842
  }
1356
1843
  if (key.return) {
1357
- runCommand(matches[selected].name, []);
1844
+ if (arg) runCommand(arg.command.name, [allRows[selected].key]);
1845
+ else runCommand(matches[selected].name, []);
1358
1846
  return;
1359
1847
  }
1360
1848
  }
1361
1849
  if (key.upArrow) {
1362
- dispatch({ type: "stream:scroll", delta: 1, viewport: layout.logViewport });
1850
+ dispatch({
1851
+ type: "stream:scroll",
1852
+ delta: 1,
1853
+ viewport: layout.logViewport,
1854
+ width: layout.streamWidth
1855
+ });
1363
1856
  return;
1364
1857
  }
1365
1858
  if (key.downArrow) {
1366
- dispatch({ type: "stream:scroll", delta: -1, viewport: layout.logViewport });
1859
+ dispatch({
1860
+ type: "stream:scroll",
1861
+ delta: -1,
1862
+ viewport: layout.logViewport,
1863
+ width: layout.streamWidth
1864
+ });
1367
1865
  return;
1368
1866
  }
1369
1867
  if (key.pageUp) {
1370
- dispatch({ type: "stream:scroll", delta: layout.logViewport, viewport: layout.logViewport });
1868
+ dispatch({
1869
+ type: "stream:scroll",
1870
+ delta: layout.logViewport,
1871
+ viewport: layout.logViewport,
1872
+ width: layout.streamWidth
1873
+ });
1371
1874
  return;
1372
1875
  }
1373
1876
  if (key.pageDown) {
1374
- dispatch({ type: "stream:scroll", delta: -layout.logViewport, viewport: layout.logViewport });
1877
+ dispatch({
1878
+ type: "stream:scroll",
1879
+ delta: -layout.logViewport,
1880
+ viewport: layout.logViewport,
1881
+ width: layout.streamWidth
1882
+ });
1375
1883
  return;
1376
1884
  }
1377
1885
  if (char === "?" && line.text === "" && !running) {
@@ -1399,6 +1907,10 @@ function App({ config, onTurnStart }) {
1399
1907
  return;
1400
1908
  }
1401
1909
  if (key.escape) {
1910
+ if (error) {
1911
+ setError(void 0);
1912
+ return;
1913
+ }
1402
1914
  if (state.view) {
1403
1915
  dispatch({ type: "view:close" });
1404
1916
  return;
@@ -1415,12 +1927,12 @@ function App({ config, onTurnStart }) {
1415
1927
  deleteForward();
1416
1928
  return;
1417
1929
  }
1418
- if (char && !key.ctrl && !key.meta) insert(char.replace(/[\r\n]+/g, " "));
1930
+ if (char && !key.ctrl && !key.meta) insert(char.replace(/\r\n?/g, "\n"));
1419
1931
  };
1420
1932
  const handlerRef = useRef(handleKey);
1421
1933
  handlerRef.current = handleKey;
1422
1934
  useInput((char, key) => handlerRef.current(char, key));
1423
- const keys = helpOpen ? "esc close" : historyOpen ? "\u2191\u2193 select \xB7 enter open \xB7 esc close" : menuOpen ? "\u2191\u2193 select \xB7 enter run \xB7 esc close" : running ? "enter queues \xB7 ctrl+c cancel" : state.view ? "esc live \xB7 tab agent \xB7 ? help" : state.reply ? "enter sends \xB7 / commands \xB7 ? help" : "tab switch \xB7 / commands \xB7 ? help";
1935
+ const keys = helpOpen ? "esc close" : historyOpen ? "\u2191\u2193 select \xB7 enter open \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 enter run \xB7 esc close" : running ? "enter queues \xB7 ctrl+c cancel" : state.view ? "esc live \xB7 tab agent \xB7 ? help" : state.reply ? "enter sends \xB7 / commands \xB7 ? help" : "tab switch \xB7 / commands \xB7 ? help";
1424
1936
  return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", height: layout.rows, children: [
1425
1937
  /* @__PURE__ */ jsx5(
1426
1938
  Header,
@@ -1437,6 +1949,7 @@ function App({ config, onTurnStart }) {
1437
1949
  // the chrome below it off the screen.
1438
1950
  /* @__PURE__ */ jsx5(Box5, { marginTop: 1, height: layout.railRows, overflow: "hidden", flexShrink: 0, children: /* @__PURE__ */ jsx5(PipelineRail, { turn: shown, spinner }) })
1439
1951
  ) : null,
1952
+ shown.reason ? /* @__PURE__ */ jsx5(Text5, { dimColor: true, wrap: "truncate-end", children: ` reason: ${shown.reason}` }) : null,
1440
1953
  /* @__PURE__ */ jsx5(
1441
1954
  Box5,
1442
1955
  {
@@ -1453,12 +1966,26 @@ function App({ config, onTurnStart }) {
1453
1966
  width: layout.columns,
1454
1967
  maxRows: layout.bodyHeight
1455
1968
  }
1456
- ) : layout.welcome ? /* @__PURE__ */ jsx5(WelcomePanel, { turn: state, width: layout.columns }) : shown.reply ? /* @__PURE__ */ jsx5(ReplyPanel, { reply: shown.reply, width: layout.columns }) : /* @__PURE__ */ jsxs5(Box5, { height: layout.bodyHeight, flexShrink: 0, flexDirection: "row", children: [
1969
+ ) : layout.welcome ? /* @__PURE__ */ jsx5(WelcomePanel, { turn: state, width: layout.columns }) : error ? (
1970
+ // The error wins over a reply: a run that stopped with an error is the
1971
+ // most urgent thing on screen, and a stale answer underneath it would
1972
+ // read as the outcome. The status line keeps a one-line version too.
1973
+ /* @__PURE__ */ jsx5(ErrorPanel, { message: error, width: layout.columns })
1974
+ ) : shown.reply ? /* @__PURE__ */ jsx5(ReplyPanel, { reply: shown.reply, width: layout.columns }) : /* @__PURE__ */ jsxs5(Box5, { height: layout.bodyHeight, flexShrink: 0, flexDirection: "row", children: [
1457
1975
  layout.showTeam ? /* @__PURE__ */ jsxs5(Fragment2, { children: [
1458
1976
  /* @__PURE__ */ jsx5(TeamPanel, { turn: shown, width: layout.teamWidth, now, spinner }),
1459
1977
  /* @__PURE__ */ jsx5(Divider, {})
1460
1978
  ] }) : null,
1461
- /* @__PURE__ */ jsx5(StreamPanel, { turn: shown, height: layout.bodyHeight, now, spinner })
1979
+ /* @__PURE__ */ jsx5(
1980
+ StreamPanel,
1981
+ {
1982
+ turn: shown,
1983
+ height: layout.bodyHeight,
1984
+ width: layout.streamWidth,
1985
+ now,
1986
+ spinner
1987
+ }
1988
+ )
1462
1989
  ] })
1463
1990
  }
1464
1991
  ),
@@ -1468,7 +1995,7 @@ function App({ config, onTurnStart }) {
1468
1995
  notice
1469
1996
  ] }) : null,
1470
1997
  /* @__PURE__ */ jsx5(QueueStrip, { items: queue, max: layout.queueRows }),
1471
- menuOpen ? /* @__PURE__ */ jsx5(CommandMenu, { commands: menuWindow, selected: menuSelected }) : null,
1998
+ paletteOpen ? /* @__PURE__ */ jsx5(CommandMenu, { rows: paletteWindow, selected: paletteSelected }) : null,
1472
1999
  /* @__PURE__ */ jsx5(
1473
2000
  Prompt,
1474
2001
  {
@@ -1537,6 +2064,7 @@ export {
1537
2064
  measureRailRows,
1538
2065
  plannedAgents,
1539
2066
  progress,
2067
+ recordedTurns,
1540
2068
  reduceEvent,
1541
2069
  seedAgents,
1542
2070
  stageStatus,