@multiclawcli/dashboard 0.12.0 → 0.13.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.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  describeResumePlan,
11
11
  isAbortError,
12
12
  Orchestrator,
13
+ resolvePermissionMode,
13
14
  validateRuntimeQuestionContent
14
15
  } from "@multiclawcli/core";
15
16
  import { Box as Box6, Text as Text6, useApp, useInput, useWindowSize } from "ink";
@@ -57,149 +58,95 @@ function appendLines(target, lines) {
57
58
 
58
59
  // src/tui/commands.ts
59
60
  var COMMANDS = [
60
- { name: "agents", description: "List the configured team", availability: "always" },
61
+ { name: "new", description: "Start an empty session", availability: "idle" },
62
+ { name: "sessions", description: "Switch or delete saved sessions", availability: "idle" },
61
63
  {
62
64
  name: "focus",
63
- args: "<id>",
64
- description: "Focus an agent by id",
65
+ args: "[id]",
66
+ description: "Choose the focused agent",
65
67
  availability: "always",
66
68
  completes: "agent"
67
69
  },
68
- { name: "clear", description: "Clear the focused agent's log", availability: "always" },
69
70
  {
70
- name: "sessions",
71
- description: "Switch between, or delete, saved sessions",
71
+ name: "mode",
72
+ args: "[auto | ask | deny]",
73
+ description: "Choose the permission mode",
72
74
  availability: "always"
73
75
  },
76
+ { name: "verbose", description: "Toggle tool detail in the stream", availability: "always" },
74
77
  {
75
- name: "verbose",
76
- description: "Toggle tool detail in the stream",
78
+ name: "search",
79
+ args: "[text]",
80
+ description: "Search the focused stream",
77
81
  availability: "always"
78
82
  },
83
+ { name: "latest", description: "Jump to latest output", availability: "always" },
79
84
  {
80
85
  name: "retry",
81
86
  args: "[id]",
82
- description: "Re-run a failed agent (or all of them)",
87
+ description: "Choose failed agents to retry",
83
88
  availability: "idle"
84
89
  },
85
90
  {
86
91
  name: "replay",
87
92
  args: "[turn]",
88
- description: "Run an earlier turn again",
93
+ description: "Choose a turn to run again",
89
94
  availability: "idle"
90
95
  },
91
96
  {
92
- name: "fork",
97
+ name: "edit",
93
98
  args: "[turn]",
94
- description: "Edit an earlier turn's requirement",
99
+ description: "Edit a previous requirement",
95
100
  availability: "always"
96
101
  },
97
102
  {
98
103
  name: "export",
99
104
  args: "[turn] [path]",
100
- description: "Export a finished turn as Markdown",
101
- availability: "always"
102
- },
103
- {
104
- name: "search",
105
- args: "<text>",
106
- description: "Search the focused stream",
105
+ description: "Export a turn as Markdown",
107
106
  availability: "always"
108
107
  },
109
- {
110
- name: "latest",
111
- description: "Jump the stream to latest output",
112
- availability: "always"
113
- },
114
- {
115
- name: "cancel",
116
- description: "Cancel the running turn and its queue",
117
- availability: "running"
118
- },
119
- {
120
- name: "new",
121
- args: "<requirement>",
122
- description: "Start a new session with fresh agent sessions",
123
- availability: "idle"
124
- },
125
108
  {
126
109
  name: "steer",
127
- args: "<text>",
128
- description: "Send a message into the agents running right now",
110
+ args: "[text]",
111
+ description: "Send a message to running agents",
129
112
  availability: "running"
130
113
  },
131
- {
132
- name: "answer",
133
- args: "<text>",
134
- description: "Answer the leader's pending question",
135
- availability: "idle"
136
- },
137
- {
138
- name: "pause",
139
- description: "Pause queued turns, not the active agent",
140
- availability: "always"
141
- },
114
+ { name: "pause", description: "Pause queued turns", availability: "always" },
142
115
  {
143
116
  name: "continue",
144
- description: "Resume queued turns or pass a checkpoint",
145
- availability: "always"
146
- },
147
- {
148
- name: "modify",
149
- args: "<instruction>",
150
- description: "Provide feedback at a checkpoint and continue",
151
- availability: "running"
152
- },
153
- {
154
- name: "respond",
155
- args: "<question id> <JSON object>",
156
- description: "Answer a question in one line of JSON",
157
- availability: "always"
158
- },
159
- {
160
- name: "skip",
161
- args: "<question id>",
162
- description: "Decline a structured tool question",
163
- availability: "always"
164
- },
165
- {
166
- name: "dismiss",
167
- args: "<question id>",
168
- description: "Cancel a structured tool question",
169
- availability: "always"
170
- },
171
- {
172
- name: "approve",
173
- args: "[id] <number>",
174
- description: "Approve the request on screen, or a named one",
175
- availability: "always"
176
- },
177
- {
178
- name: "deny",
179
- args: "[id]",
180
- description: "Deny the request on screen, or a named one",
117
+ description: "Resume the queue or continue a checkpoint",
181
118
  availability: "always"
182
- },
183
- { name: "help", description: "Show the key map", availability: "always" },
184
- { name: "quit", description: "Exit the console", availability: "always" }
119
+ }
185
120
  ];
186
- function availableCommands(running) {
121
+ function relevant(name, context) {
122
+ if (!context) return true;
123
+ if (context.checkpoint) return name === "continue";
124
+ if (["replay", "edit", "export"].includes(name)) return context.history;
125
+ if (name === "retry") return context.retry;
126
+ if (name === "pause") return context.queue > 0 && !context.paused;
127
+ if (name === "continue") return context.queue > 0 && context.paused && !context.awaitingAnswer;
128
+ return true;
129
+ }
130
+ function availableCommands(running, context) {
187
131
  const phase = running ? "running" : "idle";
188
- return COMMANDS.filter((c) => c.availability === "always" || c.availability === phase);
132
+ return COMMANDS.filter(
133
+ (c) => (c.availability === "always" || c.availability === phase) && relevant(c.name, context)
134
+ );
189
135
  }
190
- function canRun(name, running) {
136
+ function canRun(name, running, context) {
191
137
  const command = COMMANDS.find((c) => c.name === name);
192
138
  if (!command) return true;
193
- return availableCommands(running).includes(command);
139
+ return availableCommands(running, context).includes(command);
194
140
  }
195
- function unavailableReason(name, running) {
141
+ function unavailableReason(name, running, context) {
196
142
  const command = COMMANDS.find((c) => c.name === name);
197
- if (!command || availableCommands(running).includes(command)) return void 0;
143
+ if (!command || availableCommands(running, context).includes(command)) return void 0;
144
+ if (!relevant(name, context)) return `/${name} has nothing to act on right now`;
198
145
  return running ? `/${name} waits for the current turn to finish` : `/${name} only applies while a turn is running`;
199
146
  }
200
- function matchCommands(query, running) {
147
+ function matchCommands(query, running, context) {
201
148
  const q = query.trim().toLowerCase();
202
- const pool = availableCommands(running);
149
+ const pool = availableCommands(running, context);
203
150
  if (!q) return pool;
204
151
  const byName = [];
205
152
  const byDescription = [];
@@ -459,16 +406,24 @@ function computeLayout(input) {
459
406
  const compact = columns < COMPACT_COLUMNS;
460
407
  const welcome = !input.overlay && input.turn === 0;
461
408
  const showRail = !welcome && !input.overlay && rows >= RAIL_MIN_ROWS && input.hasRail;
462
- const menuRows = Math.min(Math.max(0, input.menuRows), MAX_MENU_ROWS);
463
- const queueRows = Math.min(Math.max(0, input.queueRows ?? 0), MAX_QUEUE_ROWS);
464
409
  const promptRows = Math.max(1, input.promptRows ?? 1);
465
410
  const railRows = showRail ? Math.min(MAX_RAIL_ROWS, Math.max(1, measureRailRows(input.railCells, columns))) : 0;
411
+ const fixedRows = 4 + MIN_BODY_ROWS + promptRows + (input.notice ? 1 : 0) + (railRows > 0 ? railRows + 1 : 0);
412
+ const queueRows = Math.min(
413
+ Math.max(0, input.queueRows ?? 0),
414
+ MAX_QUEUE_ROWS,
415
+ Math.max(0, rows - fixedRows - (input.menuRows > 0 ? 1 : 0))
416
+ );
417
+ const menuRows = Math.min(
418
+ Math.max(0, input.menuRows),
419
+ MAX_MENU_ROWS,
420
+ Math.max(1, rows - fixedRows - queueRows)
421
+ );
466
422
  const chrome = 1 + // header
467
423
  1 + // rule under the header
468
424
  (railRows > 0 ? railRows + 1 : 0) + // pipeline rail plus the gap above it
469
425
  1 + // gap before the body
470
- (input.notice ? 1 : 0) + (input.viewing ? 1 : 0) + // "viewing turn N" banner
471
- queueRows + menuRows + promptRows + 1;
426
+ (input.notice ? 1 : 0) + queueRows + menuRows + promptRows + 1;
472
427
  const barRoom = Math.max(0, rows - chrome - MIN_BODY_ROWS - 1);
473
428
  const interactionRows2 = barRoom < MIN_INTERACTION_ROWS ? 0 : Math.min(Math.max(0, input.interactionRows ?? 0), MAX_INTERACTION_ROWS, barRoom);
474
429
  const reserved = chrome + (interactionRows2 > 0 ? interactionRows2 + 1 : 0);
@@ -877,8 +832,9 @@ function reduceEvent(state, event) {
877
832
  if (event.type === "agent:failed") {
878
833
  if (next.logs.length === 0 && result.output)
879
834
  next.logs = appendLog([], result.output, "text");
880
- if (result.error && !next.logs.some((s) => s.text.includes(stripAnsi(result.error)))) {
881
- next.logs = appendLog(next.logs, result.error, "system");
835
+ const error = result.error;
836
+ if (error && !next.logs.some((s) => s.text.includes(stripAnsi(error)))) {
837
+ next.logs = appendLog(next.logs, error, "system");
882
838
  }
883
839
  }
884
840
  return { ...state, agents: { ...state.agents, [result.agentId]: next } };
@@ -920,14 +876,120 @@ function reduceEvent(state, event) {
920
876
  return state;
921
877
  }
922
878
  }
879
+ var wrappedCache = /* @__PURE__ */ new WeakMap();
880
+ var MAX_CACHED_WIDTHS = 3;
923
881
  function wrapLog(logs, width) {
924
882
  if (logs.length === 0) return [];
883
+ let byWidth = wrappedCache.get(logs);
884
+ if (byWidth === void 0) {
885
+ byWidth = /* @__PURE__ */ new Map();
886
+ wrappedCache.set(logs, byWidth);
887
+ }
888
+ const cached = byWidth.get(width);
889
+ if (cached !== void 0) return cached;
925
890
  const rendered = renderSegments(logs, width);
926
- return rendered.trimEnd().split("\n").flatMap((line) => wrapLine(line, width));
891
+ const rows = rendered.trimEnd().split("\n").flatMap((line) => wrapLine(line, width));
892
+ if (byWidth.size >= MAX_CACHED_WIDTHS) byWidth.clear();
893
+ byWidth.set(width, rows);
894
+ return rows;
895
+ }
896
+ function totalRows(parts) {
897
+ let total = 0;
898
+ for (const part of parts) total += part.length;
899
+ return total;
900
+ }
901
+ function windowParts(parts, viewport, scroll) {
902
+ const height = Math.max(1, viewport);
903
+ const total = totalRows(parts);
904
+ const maxScroll = Math.max(0, total - height);
905
+ const scrolledBack = Math.min(Math.max(0, scroll), maxScroll);
906
+ const end = total - scrolledBack;
907
+ const start = Math.max(0, end - height);
908
+ const rows = [];
909
+ let offset = 0;
910
+ for (const part of parts) {
911
+ const partEnd = offset + part.length;
912
+ if (partEnd <= start) {
913
+ offset = partEnd;
914
+ continue;
915
+ }
916
+ if (offset >= end) break;
917
+ const from = Math.max(0, start - offset);
918
+ const to = Math.min(part.length, end - offset);
919
+ for (let i = from; i < to; i++) rows.push(part[i]);
920
+ offset = partEnd;
921
+ }
922
+ return { rows, scrolledBack, following: scrolledBack === 0 };
923
+ }
924
+ function clipToColumns(text, max) {
925
+ if (max <= 0) return "";
926
+ if (displayWidth(text) <= max) return text;
927
+ let head = "";
928
+ let used = 0;
929
+ for (const char of text) {
930
+ const width = displayWidth(char);
931
+ if (used + width > max - 1) break;
932
+ head += char;
933
+ used += width;
934
+ }
935
+ return `${head}\u2026`;
936
+ }
937
+ function turnDivider(turn, width) {
938
+ const MAX_REQ = 45;
939
+ const raw = turn.requirement?.replace(/\s+/g, " ").trim();
940
+ const req = raw ? clipToColumns(raw, MAX_REQ) : void 0;
941
+ const label = req ? ` turn ${turn.turn} \xB7 ${req} ` : ` turn ${turn.turn} `;
942
+ const fill = Math.max(1, Math.floor((width - displayWidth(label)) / 2));
943
+ const rule = `${"\u2500".repeat(fill)}${label}${"\u2500".repeat(fill)}`;
944
+ return `\x1B[2m${clipToColumns(rule, width)}\x1B[0m`;
945
+ }
946
+ function pushRows(target, rows) {
947
+ for (const row of rows) target.push(row);
948
+ }
949
+ var historyCache = /* @__PURE__ */ new WeakMap();
950
+ function historyRows(history, agentId, width) {
951
+ if (history.length === 0) return [];
952
+ let byAgent = historyCache.get(history);
953
+ if (byAgent === void 0) {
954
+ byAgent = /* @__PURE__ */ new Map();
955
+ historyCache.set(history, byAgent);
956
+ }
957
+ let byWidth = byAgent.get(agentId);
958
+ if (byWidth === void 0) {
959
+ byWidth = /* @__PURE__ */ new Map();
960
+ byAgent.set(agentId, byWidth);
961
+ }
962
+ const cached = byWidth.get(width);
963
+ if (cached !== void 0) return cached;
964
+ const rows = [];
965
+ for (const turn of history) {
966
+ rows.push(turnDivider(turn, width));
967
+ const agent = turn.agents[agentId];
968
+ if (agent && agent.logs.length > 0) pushRows(rows, wrapLog(agent.logs, width));
969
+ }
970
+ if (byWidth.size >= MAX_CACHED_WIDTHS) byWidth.clear();
971
+ byWidth.set(width, rows);
972
+ return rows;
927
973
  }
928
- function focusedRowCount(turn, width) {
929
- const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
930
- return agent?.logs ? wrapLog(agent.logs, width).length : 0;
974
+ function liveRows(state, agentId, width) {
975
+ const agent = state.agents[agentId];
976
+ if (!agent || agent.logs.length === 0) return [];
977
+ const rows = wrapLog(agent.logs, width);
978
+ if (state.history.length === 0) return rows;
979
+ const out = [turnDivider(state, width)];
980
+ pushRows(out, rows);
981
+ return out;
982
+ }
983
+ function timelineParts(state, agentId, width) {
984
+ const history = historyRows(state.history, agentId, width);
985
+ const live = liveRows(state, agentId, width);
986
+ if (live.length === 0) return history.length === 0 ? [] : [history];
987
+ return history.length === 0 ? [live] : [history, live];
988
+ }
989
+ function streamMatches(state, query, width) {
990
+ if (!state.focusId || !query.trim()) return [];
991
+ const needle = query.toLowerCase();
992
+ return timelineParts(state, state.focusId, width).flat().flatMap((row, index) => row.toLowerCase().includes(needle) ? [index] : []);
931
993
  }
932
994
  function snapshot(state) {
933
995
  return {
@@ -954,14 +1016,6 @@ function snapshot(state) {
954
1016
  reply: state.reply
955
1017
  };
956
1018
  }
957
- function mapShown(state, fn) {
958
- if (state.view) {
959
- const next2 = fn(state.view);
960
- return next2 === state.view ? state : { ...state, view: next2 };
961
- }
962
- const next = fn(state);
963
- return next === state ? state : { ...state, ...next };
964
- }
965
1019
  function recordedTurns(state) {
966
1020
  return [...state.history, ...state.phase === "complete" ? [snapshot(state)] : []];
967
1021
  }
@@ -1035,68 +1089,55 @@ function tuiReducer(state, action) {
1035
1089
  history,
1036
1090
  // A new requirement also replaces the previous leader metadata; leaving
1037
1091
  // it up would make the old answer or routing reason look like the
1038
- // response to the new request. Reading a past turn ends for the same
1039
- // reason — the panes belong to the run.
1092
+ // response to the new request.
1040
1093
  reason: void 0,
1041
1094
  reply: void 0,
1042
- view: void 0,
1043
1095
  // A console setting, carried across turns rather than reset with them.
1044
1096
  verbose: state.verbose
1045
1097
  };
1046
1098
  }
1047
- case "focus:cycle":
1048
- return mapShown(state, (turn) => {
1049
- const ids = Object.keys(turn.agents);
1050
- if (ids.length === 0) return turn;
1051
- const current = turn.focusId ? ids.indexOf(turn.focusId) : -1;
1052
- const nextIndex = (current + action.delta + ids.length) % ids.length;
1053
- if (nextIndex === current) return turn;
1054
- return { ...turn, focusId: ids[nextIndex], autoFollow: false, scroll: 0 };
1055
- });
1056
- case "focus:set":
1057
- return mapShown(state, (turn) => {
1058
- if (!turn.agents[action.agentId]) return turn;
1059
- if (turn.focusId === action.agentId && !turn.autoFollow && turn.scroll === 0) return turn;
1060
- return { ...turn, focusId: action.agentId, autoFollow: false, scroll: 0 };
1061
- });
1062
- case "stream:scroll":
1063
- return mapShown(state, (turn) => {
1064
- const rows = focusedRowCount(turn, action.width);
1065
- const maxScroll = Math.max(0, rows - Math.max(1, action.viewport));
1066
- const next = Math.min(Math.max(0, turn.scroll + action.delta), maxScroll);
1067
- return next === turn.scroll ? turn : { ...turn, scroll: next };
1068
- });
1069
- case "stream:search":
1070
- return mapShown(state, (turn) => {
1071
- const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
1072
- if (!agent?.logs || action.query === "") return turn;
1073
- const rows = wrapLog(agent.logs, action.width);
1074
- const needle = action.query.toLowerCase();
1075
- let index = -1;
1076
- for (let i = rows.length - 1; i >= 0; i--) {
1077
- if (rows[i].toLowerCase().includes(needle)) {
1078
- index = i;
1079
- break;
1080
- }
1081
- }
1082
- if (index === -1) return turn;
1083
- const height = Math.max(1, action.viewport);
1084
- const maxScroll = Math.max(0, rows.length - height);
1085
- const scroll = Math.min(Math.max(0, rows.length - 1 - index), maxScroll);
1086
- return scroll === turn.scroll && !turn.autoFollow ? turn : { ...turn, scroll, autoFollow: false };
1087
- });
1099
+ case "focus:cycle": {
1100
+ const ids = Object.keys(state.agents);
1101
+ if (ids.length === 0) return state;
1102
+ const current = state.focusId ? ids.indexOf(state.focusId) : -1;
1103
+ const nextIndex = (current + action.delta + ids.length) % ids.length;
1104
+ if (nextIndex === current) return state;
1105
+ return { ...state, focusId: ids[nextIndex], autoFollow: false, scroll: 0 };
1106
+ }
1107
+ case "focus:set": {
1108
+ if (!state.agents[action.agentId]) return state;
1109
+ if (state.focusId === action.agentId && !state.autoFollow && state.scroll === 0) return state;
1110
+ return { ...state, focusId: action.agentId, autoFollow: false, scroll: 0 };
1111
+ }
1112
+ case "stream:scroll": {
1113
+ const focusId = state.focusId;
1114
+ if (!focusId) return state;
1115
+ const rows = totalRows(timelineParts(state, focusId, action.width));
1116
+ const maxScroll = Math.max(0, rows - Math.max(1, action.viewport));
1117
+ const next = Math.min(Math.max(0, state.scroll + action.delta), maxScroll);
1118
+ return next === state.scroll ? state : { ...state, scroll: next };
1119
+ }
1120
+ case "stream:search": {
1121
+ const focusId = state.focusId;
1122
+ if (!focusId || action.query === "") return state;
1123
+ const parts = timelineParts(state, focusId, action.width);
1124
+ const matches = streamMatches(state, action.query, action.width);
1125
+ const index = matches[action.match ?? matches.length - 1];
1126
+ if (index === void 0) return state;
1127
+ const total = totalRows(parts);
1128
+ const height = Math.max(1, action.viewport);
1129
+ const maxScroll = Math.max(0, total - height);
1130
+ const scroll = Math.min(Math.max(0, total - 1 - index), maxScroll);
1131
+ return scroll === state.scroll && !state.autoFollow ? state : { ...state, scroll, autoFollow: false };
1132
+ }
1088
1133
  case "stream:latest":
1089
- return mapShown(
1090
- state,
1091
- (turn) => turn.scroll === 0 && turn.autoFollow ? turn : { ...turn, scroll: 0, autoFollow: true }
1092
- );
1093
- case "stream:clear":
1094
- return mapShown(state, (turn) => {
1095
- const id = turn.focusId;
1096
- const agent = id ? turn.agents[id] : void 0;
1097
- if (!id || !agent) return turn;
1098
- return { ...turn, agents: { ...turn.agents, [id]: { ...agent, logs: [] } }, scroll: 0 };
1099
- });
1134
+ return state.scroll === 0 && state.autoFollow ? state : { ...state, scroll: 0, autoFollow: true };
1135
+ case "stream:clear": {
1136
+ const id = state.focusId;
1137
+ const agent = id ? state.agents[id] : void 0;
1138
+ if (!id || !agent) return state;
1139
+ return { ...state, agents: { ...state.agents, [id]: { ...agent, logs: [] } }, scroll: 0 };
1140
+ }
1100
1141
  case "stream:note": {
1101
1142
  const agent = state.agents[action.agentId];
1102
1143
  if (!agent) return state;
@@ -1108,12 +1149,6 @@ function tuiReducer(state, action) {
1108
1149
  }
1109
1150
  };
1110
1151
  }
1111
- case "view:open": {
1112
- const record = state.history.find((turn) => turn.turn === action.turn);
1113
- return record ? { ...state, view: record } : state;
1114
- }
1115
- case "view:close":
1116
- return state.view ? { ...state, view: void 0 } : state;
1117
1152
  case "verbose:toggle":
1118
1153
  return { ...state, verbose: !state.verbose };
1119
1154
  case "session:reset":
@@ -1148,7 +1183,6 @@ function seededState(state, agents, leaderIds, history) {
1148
1183
  autoFollow: true,
1149
1184
  scroll: 0,
1150
1185
  history,
1151
- view: void 0,
1152
1186
  verbose: state.verbose
1153
1187
  };
1154
1188
  }
@@ -1184,18 +1218,8 @@ function turnElapsed(turn, now) {
1184
1218
  const end = turn.phase === "running" ? now : turn.endedAt ?? now;
1185
1219
  return Math.max(0, end - turn.startedAt);
1186
1220
  }
1187
- function visibleLog(turn, viewport, width) {
1188
- const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
1189
- const all = agent?.logs ? wrapLog(agent.logs, width) : [];
1190
- const height = Math.max(1, viewport);
1191
- const maxScroll = Math.max(0, all.length - height);
1192
- const scrolledBack = Math.min(Math.max(0, turn.scroll), maxScroll);
1193
- const end = all.length - scrolledBack;
1194
- return {
1195
- rows: all.slice(Math.max(0, end - height), end),
1196
- scrolledBack,
1197
- following: scrolledBack === 0
1198
- };
1221
+ function visibleTimeline(state, agentId, viewport, width, scroll) {
1222
+ return windowParts(timelineParts(state, agentId, width), viewport, scroll);
1199
1223
  }
1200
1224
 
1201
1225
  // src/tui/interaction.ts
@@ -1452,7 +1476,7 @@ function formComplete(fields, state) {
1452
1476
  }
1453
1477
  var MAX_FORM_FIELDS = 6;
1454
1478
  function questionBarRows(fields) {
1455
- return 2 + Math.min(fields.length, MAX_FORM_FIELDS) + 1 + 1;
1479
+ return 2 + Math.min(fields.length, MAX_FORM_FIELDS) + 1 + 2;
1456
1480
  }
1457
1481
  function interactionRows(interactions, width) {
1458
1482
  const front = interactions[0];
@@ -1480,11 +1504,11 @@ var HELP_GROUPS = [
1480
1504
  {
1481
1505
  title: "panes",
1482
1506
  rows: [
1483
- ["tab \u21E7tab", "Next / previous agent"],
1507
+ ["tab", "Next agent"],
1508
+ ["\u21E7tab", "Toggle permission mode"],
1484
1509
  ["\u2191 \u2193", "Scroll the stream one line"],
1485
1510
  ["pgup pgdn", "Scroll the stream a page"],
1486
- ["ctrl+l", "Clear the focused log"],
1487
- ["esc", "Stop reading a past turn"]
1511
+ ["ctrl+l", "Clear the current turn's output"]
1488
1512
  ]
1489
1513
  },
1490
1514
  {
@@ -1583,18 +1607,22 @@ function PermissionBar({
1583
1607
  others,
1584
1608
  cursor,
1585
1609
  released,
1586
- width
1610
+ width,
1611
+ maxRows = Number.POSITIVE_INFINITY
1587
1612
  }) {
1588
1613
  const choices = permissionChoices(request);
1589
- const impact = describeImpact(request, Math.max(8, width - 6));
1590
- const also = waitingLine(others);
1614
+ const capacity = Math.max(1, Math.min(choices.length, maxRows - 3));
1615
+ const start = listWindowStart(cursor ?? 0, choices.length, capacity);
1616
+ const extra = Math.max(0, maxRows - 3 - capacity);
1617
+ const impact = describeImpact(request, Math.max(8, width - 6)).slice(0, extra);
1618
+ const also = extra > impact.length ? waitingLine(others) : void 0;
1591
1619
  const tool = clean(request.toolName ?? request.kind ?? "tool");
1592
1620
  return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, width, children: [
1593
1621
  /* @__PURE__ */ jsx3(Text3, { bold: true, color: COLOR.warn, wrap: "truncate-end", children: `permission \xB7 ${clean(request.agentId)} \xB7 ${tool}${others.length > 0 ? ` \xB7 ${others.length + 1} waiting` : ""}` }),
1594
1622
  /* @__PURE__ */ jsx3(Text3, { wrap: "truncate-end", children: ` ${truncate(clean(request.title), Math.max(8, width - 4))}` }),
1595
1623
  impact.map((line) => /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: ` ${line}` }, line)),
1596
- choices.map((choice, index) => {
1597
- const active = index === cursor;
1624
+ choices.slice(start, start + capacity).map((choice, index) => {
1625
+ const active = start + index === cursor;
1598
1626
  return /* @__PURE__ */ jsx3(
1599
1627
  Text3,
1600
1628
  {
@@ -1607,7 +1635,7 @@ function PermissionBar({
1607
1635
  );
1608
1636
  }),
1609
1637
  also === void 0 ? null : /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: ` ${also}` }),
1610
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: released ? " typing \xB7 /approve 1 or /deny still reach it" : " \u2191\u2193 or 1-9 choose \xB7 enter confirm \xB7 esc to type instead" })
1638
+ maxRows < 4 ? null : /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: released ? " typing \xB7 esc to return to permission options" : " \u2191\u2193 or 1-9 choose \xB7 enter confirm \xB7 esc to type instead" })
1611
1639
  ] });
1612
1640
  }
1613
1641
  function QuestionForm({
@@ -1616,18 +1644,26 @@ function QuestionForm({
1616
1644
  fields,
1617
1645
  form,
1618
1646
  draft,
1619
- width
1647
+ width,
1648
+ maxRows = Number.POSITIVE_INFINITY
1620
1649
  }) {
1621
1650
  const focused = fields[form.field];
1622
1651
  const titleWidth = Math.min(
1623
1652
  24,
1624
1653
  Math.max(4, ...fields.map((field) => field.title.length + (field.required ? 2 : 0)))
1625
1654
  );
1626
- const capacity = Math.min(fields.length, MAX_FORM_FIELDS);
1627
- const start = listWindowStart(form.field, fields.length, Math.max(1, capacity));
1655
+ const actions = ["Submit answers", "Skip question", "Cancel request"];
1656
+ const actionIndex = form.field - fields.length;
1657
+ const compact = maxRows < 7;
1658
+ const capacity = compact ? focused ? 1 : 0 : Math.min(fields.length, MAX_FORM_FIELDS, maxRows - 5);
1659
+ const start = listWindowStart(
1660
+ Math.min(form.field, fields.length - 1),
1661
+ fields.length,
1662
+ Math.max(1, capacity)
1663
+ );
1628
1664
  const visible = fields.slice(start, start + capacity);
1629
- const range = fields.length > capacity ? ` \xB7 ${start + 1}\u2013${start + visible.length}/${fields.length}` : "";
1630
- const detail = focused === void 0 ? [] : detailLines(focused, width);
1665
+ const range = fields.length > capacity && focused ? ` \xB7 field ${form.field + 1}/${fields.length}` : "";
1666
+ const detail = focused && (!compact || maxRows >= 5) ? detailLines(focused, width).slice(0, 1) : [];
1631
1667
  return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, width, children: [
1632
1668
  /* @__PURE__ */ jsx3(Text3, { bold: true, color: COLOR.warn, wrap: "truncate-end", children: `question \xB7 ${clean(request.agentId)}${others.length > 0 ? ` \xB7 ${others.length + 1} waiting` : ""}` }),
1633
1669
  /* @__PURE__ */ jsx3(Text3, { wrap: "truncate-end", children: clean(request.message) }),
@@ -1648,7 +1684,8 @@ function QuestionForm({
1648
1684
  );
1649
1685
  }),
1650
1686
  detail.map((line) => /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: ` ${line}` }, line)),
1651
- /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: ` tab next \xB7 \u2191\u2193 pick \xB7 space toggle \xB7 enter next${range} \xB7 esc clear` })
1687
+ compact && focused ? null : /* @__PURE__ */ jsx3(Text3, { color: actionIndex >= 0 ? COLOR.accent : void 0, wrap: "truncate-end", children: compact || width < 72 ? actionIndex >= 0 ? `\u203A ${actions[actionIndex]}` : "tab to submit / skip / cancel" : actions.map((label, index) => (actionIndex === index ? "\u203A " : " ") + label).join(" \xB7 ") }),
1688
+ maxRows < 4 ? null : /* @__PURE__ */ jsx3(Text3, { dimColor: true, wrap: "truncate-end", children: ` tab next \xB7 \u2191\u2193 pick \xB7 space toggle \xB7 enter next${range} \xB7 esc clear` })
1652
1689
  ] });
1653
1690
  }
1654
1691
  function fieldValue(field, form, draft, focused) {
@@ -1766,17 +1803,19 @@ function Divider() {
1766
1803
  );
1767
1804
  }
1768
1805
  function StreamPanel({
1769
- turn,
1806
+ state,
1770
1807
  height,
1771
1808
  width,
1772
1809
  now,
1773
1810
  spinner
1774
1811
  }) {
1775
- const agent = turn.focusId ? turn.agents[turn.focusId] : void 0;
1812
+ const agent = state.focusId ? state.agents[state.focusId] : void 0;
1776
1813
  const viewport = Math.max(1, height - 1);
1777
- const window = visibleLog(turn, viewport, width);
1814
+ const window = visibleTimeline(state, state.focusId ?? "", viewport, width, state.scroll);
1778
1815
  const style = agent ? STATUS[agent.status] : void 0;
1779
1816
  const elapsed = agent ? agentElapsed(agent, now) : void 0;
1817
+ const focusId = state.focusId;
1818
+ const turnCount = focusId ? recordedTurns(state).filter((turn) => (turn.agents[focusId]?.logs.length ?? 0) > 0).length : 0;
1780
1819
  return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", flexGrow: 1, height, paddingLeft: 1, children: [
1781
1820
  /* @__PURE__ */ jsxs4(Box4, { justifyContent: "space-between", children: [
1782
1821
  /* @__PURE__ */ jsx4(Box4, { flexShrink: 1, children: /* @__PURE__ */ jsxs4(Text4, { wrap: "truncate-end", children: [
@@ -1803,6 +1842,11 @@ function StreamPanel({
1803
1842
  agent?.costUsd !== void 0 ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1804
1843
  " \xB7 ",
1805
1844
  formatCost(agent.costUsd)
1845
+ ] }) : null,
1846
+ turnCount > 1 ? /* @__PURE__ */ jsxs4(Text4, { dimColor: true, children: [
1847
+ " \xB7 ",
1848
+ turnCount,
1849
+ " turns"
1806
1850
  ] }) : null
1807
1851
  ] }) }),
1808
1852
  /* @__PURE__ */ jsxs4(Box4, { flexShrink: 0, children: [
@@ -1813,13 +1857,14 @@ function StreamPanel({
1813
1857
  /* @__PURE__ */ jsx4(Text4, { color: window.following ? COLOR.accent : COLOR.warn, children: window.following ? GLYPH.live : `${GLYPH.paused} ${window.scrolledBack}\u2191` })
1814
1858
  ] })
1815
1859
  ] }),
1816
- window.rows.length === 0 ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: turn.phase === "running" ? "waiting for output\u2026" : "no output yet" }) : (
1817
- // The tail, already wrapped to `width` by `visibleLog` — the pane draws
1818
- // rows, and one log line is several of them. `visibleLog` also picks
1819
- // which rows, so the content here is exactly the pane's height and the
1820
- // oldest rows are the ones already gone: nothing is left to clip, which
1821
- // is why this box is not bottom-pinned. Pinning it would only leave a
1822
- // gap under the header whenever an agent has not said much yet.
1860
+ window.rows.length === 0 ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: state.phase === "running" ? "waiting for output\u2026" : "no output yet" }) : (
1861
+ // The tail, already wrapped to `width` by `visibleTimeline` — the pane
1862
+ // draws rows, and one log line is several of them. `visibleTimeline`
1863
+ // also picks which rows, so the content here is exactly the pane's
1864
+ // height and the oldest rows are the ones already gone: nothing is left
1865
+ // to clip, which is why this box is not bottom-pinned. Pinning it would
1866
+ // only leave a gap under the header whenever an agent has not said much
1867
+ // yet.
1823
1868
  //
1824
1869
  // Rows are rendered with `truncate-end` rather than letting Ink wrap
1825
1870
  // them again: they are supposed to fit, and if `width` was ever
@@ -2339,6 +2384,16 @@ var SESSION_SAVE_INTERVAL_MS = 2e3;
2339
2384
  var INPUT_PLACEHOLDER = "describe what the team should do\u2026";
2340
2385
  var RUNNING_PLACEHOLDER = "type to queue a follow-up\u2026";
2341
2386
  var EMPTY_LINE = { text: "", cursor: 0 };
2387
+ function retryCandidates(state) {
2388
+ const turn = recordedTurns(state).slice(-1)[0];
2389
+ if (!turn?.requirement) return [];
2390
+ if (turn.acceptance && turn.acceptance.status !== "approved") {
2391
+ return [
2392
+ ...new Set(turn.stages.flatMap((stage) => stage.agents.map((agent) => agent.id)))
2393
+ ].filter((id) => id !== turn.acceptance?.reviewer);
2394
+ }
2395
+ return Object.values(turn.agents).filter((agent) => agent.status === "failed").map((agent) => agent.id);
2396
+ }
2342
2397
  function pickRecordedTurn(state, raw, whenEmpty) {
2343
2398
  const turns = recordedTurns(state);
2344
2399
  if (turns.length === 0) return { error: whenEmpty };
@@ -2470,10 +2525,19 @@ function App({
2470
2525
  queueRef.current = queue;
2471
2526
  const [cancelled, setCancelled] = useState(false);
2472
2527
  const [error, setError] = useState();
2528
+ const [permissionMode, setPermissionMode] = useState(
2529
+ // Same precedence the runner uses, so the header can never announce a mode
2530
+ // the agents are not actually running under. `ask` when nobody configured
2531
+ // one, because a human is sitting here to answer.
2532
+ resolvePermissionMode(config) ?? "ask"
2533
+ );
2473
2534
  const [notice, setNotice] = useState(
2474
2535
  launchNoticeRef.current ?? restored.notice
2475
2536
  );
2476
2537
  const [helpOpen, setHelpOpen] = useState(false);
2538
+ const [picker, setPicker] = useState(null);
2539
+ const [operationInput, setOperationInput] = useState(null);
2540
+ const [searchResult, setSearchResult] = useState(null);
2477
2541
  const [sessionsOpen, setSessionsOpen] = useState(false);
2478
2542
  const [sessionCursor, setSessionCursor] = useState(0);
2479
2543
  const [sessionList, setSessionList] = useState([]);
@@ -2488,8 +2552,6 @@ function App({
2488
2552
  useEffect(() => {
2489
2553
  if (initialSession?.kind !== "resume" || !("id" in initialSession)) return;
2490
2554
  const history = restoredRef.current?.history ?? [];
2491
- const last = history[history.length - 1];
2492
- if (last) dispatch({ type: "view:open", turn: last.turn });
2493
2555
  const resumable = [...history].reverse().find((turn) => Object.keys(turnSessions(turn)).length > 0);
2494
2556
  continuationRef.current = resumable ? { turn: resumable.turn, sessions: turnSessions(resumable) } : null;
2495
2557
  }, [initialSession]);
@@ -2563,16 +2625,21 @@ function App({
2563
2625
  const handleCheckpoint = useCallback((info, signal) => {
2564
2626
  if (closingRef.current || signal.aborted) return Promise.resolve({ action: "cancel" });
2565
2627
  return new Promise((resolve2) => {
2566
- checkpointResolveRef.current = resolve2;
2628
+ const settle = (response) => {
2629
+ signal.removeEventListener("abort", onAbort);
2630
+ if (checkpointResolveRef.current === settle) {
2631
+ checkpointResolveRef.current = null;
2632
+ checkpointLabelRef.current = "";
2633
+ }
2634
+ resolve2(response);
2635
+ };
2636
+ checkpointResolveRef.current = settle;
2567
2637
  checkpointLabelRef.current = info.label;
2568
2638
  const names = info.completedAgents.map((a) => a.name).join(", ");
2569
2639
  setNotice(
2570
- `checkpoint: ${info.label} \u2014 ${names} done \xB7 /continue /modify <instruction> /cancel`
2640
+ `checkpoint: ${info.label} \u2014 ${names} done \xB7 /continue \xB7 type feedback \xB7 ctrl+c cancel`
2571
2641
  );
2572
- const onAbort = () => {
2573
- checkpointResolveRef.current = null;
2574
- resolve2({ action: "cancel" });
2575
- };
2642
+ const onAbort = () => settle({ action: "cancel" });
2576
2643
  signal.addEventListener("abort", onAbort, { once: true });
2577
2644
  });
2578
2645
  }, []);
@@ -2689,6 +2756,13 @@ function App({
2689
2756
  setError(void 0);
2690
2757
  setCancelled(false);
2691
2758
  setNotice(void 0);
2759
+ if (opts?.newSession && !requirement) {
2760
+ busyRef.current = false;
2761
+ setRunning(false);
2762
+ setSearchResult(null);
2763
+ setNotice("new session \u2014 type a requirement to begin");
2764
+ return;
2765
+ }
2692
2766
  const earlierTurns = opts?.newSession ? [] : conversationTurns(stateRef.current);
2693
2767
  dispatch({
2694
2768
  type: "turn:start",
@@ -2706,7 +2780,16 @@ function App({
2706
2780
  // so the leader is handed the turns behind this one as well.
2707
2781
  history: earlierTurns,
2708
2782
  logDir,
2709
- permissions: !config.permissions || config.permissions.mode === "ask" ? { mode: "ask", onRequest: requestPermission } : config.permissions,
2783
+ permissions: permissionMode === "ask" ? { mode: "ask", onRequest: requestPermission } : { mode: permissionMode },
2784
+ // The console's mode is a live decision, so it outranks any per-agent
2785
+ // `permissions` written in the file: otherwise shift+tab would relabel
2786
+ // the header while an agent that declares its own mode kept the old
2787
+ // behaviour. Per-agent modes stay a headless-run concern.
2788
+ agents: config.agents.map((agent) => ({
2789
+ ...agent,
2790
+ permissions: { mode: permissionMode }
2791
+ })),
2792
+ ...config.leader ? { leader: { ...config.leader, permissions: { mode: permissionMode } } } : {},
2710
2793
  questions: config.questions?.onRequest ? config.questions : { onRequest: requestQuestion },
2711
2794
  onCheckpoint: config.onCheckpoint ?? handleCheckpoint,
2712
2795
  // A retry names the agents to redo; a continued conversation carries only
@@ -2771,7 +2854,8 @@ function App({
2771
2854
  requestPermission,
2772
2855
  requestQuestion,
2773
2856
  handleCheckpoint,
2774
- clearInteractions
2857
+ clearInteractions,
2858
+ permissionMode
2775
2859
  ]
2776
2860
  );
2777
2861
  useEffect(() => {
@@ -2791,6 +2875,7 @@ function App({
2791
2875
  return;
2792
2876
  }
2793
2877
  if (closingRef.current) return;
2878
+ setSearchResult(null);
2794
2879
  const restored2 = restoreConsole(config.workDir, id);
2795
2880
  sessionIdRef.current = id;
2796
2881
  persistedTurnRef.current = restored2.persistedTurn;
@@ -2802,10 +2887,6 @@ function App({
2802
2887
  leaderIds: leaderAgentIds(config),
2803
2888
  history: restored2.history
2804
2889
  });
2805
- const lastTurn = restored2.history[restored2.history.length - 1];
2806
- if (lastTurn) {
2807
- dispatch({ type: "view:open", turn: lastTurn.turn });
2808
- }
2809
2890
  setQueue(restored2.queue);
2810
2891
  queuePausedRef.current = restored2.queue.length > 0;
2811
2892
  setQueuePaused(restored2.queue.length > 0);
@@ -2885,28 +2966,32 @@ function App({
2885
2966
  })();
2886
2967
  return shutdownRef.current;
2887
2968
  }, [config.workDir, exit, cleanup]);
2888
- const query = menuQuery(line.text);
2889
- const matches = useMemo(
2890
- () => query === void 0 ? [] : matchCommands(query, running),
2891
- [query, running]
2892
- );
2893
- const shown = state.view ?? state;
2894
- const arg = argQuery(line.text);
2895
- const argRows = arg ? completeAgents(arg.partial, Object.keys(shown.agents)).map((id) => ({
2969
+ const commandContext = {
2970
+ history: recordedTurns(state).length > 0,
2971
+ retry: retryCandidates(state).length > 0,
2972
+ queue: queue.length,
2973
+ paused: queuePaused,
2974
+ awaitingAnswer,
2975
+ checkpoint: checkpointResolveRef.current !== null
2976
+ };
2977
+ const query = interactions[0]?.kind === "question" ? void 0 : menuQuery(line.text);
2978
+ const matches = query === void 0 ? [] : matchCommands(query, running, commandContext);
2979
+ const arg = interactions[0]?.kind === "question" ? void 0 : argQuery(line.text);
2980
+ const argRows = arg ? completeAgents(arg.partial, Object.keys(state.agents)).map((id) => ({
2896
2981
  key: id,
2897
2982
  label: id,
2898
2983
  // The static role, not the per-turn task title: while completing, the
2899
2984
  // user is asking "which agent is this", not "what is it doing now".
2900
- description: shown.agents[id]?.description ?? shown.agents[id]?.name ?? id
2985
+ description: state.agents[id]?.description ?? state.agents[id]?.name ?? id
2901
2986
  })) : [];
2902
- const allRows = arg ? argRows : paletteRows(matches);
2987
+ const allRows = picker ? picker.rows : arg ? argRows : paletteRows(matches);
2903
2988
  const paletteKey = arg ? `${arg.command.name} ${arg.partial}` : query;
2904
2989
  const paletteTotal = allRows.length;
2905
2990
  const menuIndex = menuCursor.query === paletteKey ? menuCursor.index : 0;
2906
- const selected = paletteTotal === 0 ? 0 : Math.min(menuIndex, paletteTotal - 1);
2907
- const menuOpen = !helpOpen && !sessionsOpen && query !== void 0 && matches.length > 0;
2908
- const argOpen = !helpOpen && !sessionsOpen && arg !== void 0 && argRows.length > 0;
2909
- const paletteOpen = menuOpen || argOpen;
2991
+ const selected = paletteTotal === 0 ? 0 : Math.min(picker?.cursor ?? menuIndex, paletteTotal - 1);
2992
+ const menuOpen = !picker && !operationInput && !helpOpen && !sessionsOpen && query !== void 0 && matches.length > 0;
2993
+ const argOpen = !picker && !operationInput && !helpOpen && !sessionsOpen && arg !== void 0 && argRows.length > 0;
2994
+ const paletteOpen = picker !== null || menuOpen || argOpen;
2910
2995
  const front = interactions[0];
2911
2996
  const menu = front?.kind === "permission" && permissionMenu.id === front.shortId ? permissionMenu : { id: front?.shortId ?? "", cursor: 0, released: false };
2912
2997
  const frontQuestion = front?.kind === "question" ? front : void 0;
@@ -2932,9 +3017,8 @@ function App({
2932
3017
  interactionRows: interactionRows(interactions, Math.max(MIN_COLUMNS, columns)),
2933
3018
  promptRows,
2934
3019
  notice: notice !== void 0 || queuePaused && queue.length > 0,
2935
- viewing: state.view !== void 0,
2936
- hasRail: shown.leaderIds.length > 0 || shown.stages.length > 0,
2937
- railCells: railCellWidths(shown)
3020
+ hasRail: state.leaderIds.length > 0 || state.stages.length > 0,
3021
+ railCells: railCellWidths(state)
2938
3022
  });
2939
3023
  const steerRunningAgents = useCallback(async (text) => {
2940
3024
  const manager = sessionsRef.current;
@@ -2959,11 +3043,11 @@ function App({
2959
3043
  setNotice(describeSteering(reports));
2960
3044
  }, []);
2961
3045
  const runCommand = useCallback(
2962
- (name, args, rawText) => {
3046
+ (name, args) => {
2963
3047
  setLine(EMPTY_LINE);
2964
- if (checkpointResolveRef.current && name !== "continue" && name !== "modify" && name !== "cancel") {
3048
+ if (checkpointResolveRef.current && name !== "continue") {
2965
3049
  setNotice(
2966
- `checkpoint: ${checkpointLabelRef.current} \xB7 /continue /modify <instruction> /cancel`
3050
+ `checkpoint: ${checkpointLabelRef.current} \xB7 /continue \xB7 type feedback \xB7 ctrl+c cancel`
2967
3051
  );
2968
3052
  return;
2969
3053
  }
@@ -2971,111 +3055,86 @@ function App({
2971
3055
  setNotice(unavailableReason(name, running));
2972
3056
  return;
2973
3057
  }
2974
- const shown2 = state.view ?? state;
2975
3058
  if (closingRef.current) return;
2976
- switch (name) {
2977
- case "respond":
2978
- case "skip":
2979
- case "dismiss": {
2980
- const [id, ...jsonParts] = args;
2981
- if (!id || (name === "respond" ? jsonParts.length === 0 : args.length !== 1)) {
2982
- setNotice(
2983
- name === "respond" ? "usage: /respond <question id> <JSON object>" : `usage: /${name} <question id>`
2984
- );
2985
- return;
2986
- }
2987
- const matches2 = interactionsRef.current.filter(
2988
- (entry2) => entry2.request.requestId === id || entry2.shortId === id
3059
+ const choose = (rows2) => {
3060
+ setPicker({ command: name, rows: rows2, cursor: 0 });
3061
+ setNotice(`choose ${name} \xB7 enter confirm \xB7 esc cancel`);
3062
+ };
3063
+ if (args.length === 0) {
3064
+ if (name === "mode") {
3065
+ choose(
3066
+ ["ask", "auto", "deny"].map((mode) => ({
3067
+ key: mode,
3068
+ label: mode,
3069
+ description: mode === "ask" ? "Ask before tools run" : mode === "auto" ? "Allow tools automatically" : "Refuse tool requests"
3070
+ }))
2989
3071
  );
2990
- const entry = matches2.length === 1 ? matches2[0] : void 0;
2991
- if (entry?.kind !== "question") {
2992
- setNotice(`unknown or stale question request: ${id}`);
2993
- return;
2994
- }
2995
- if (name !== "respond") {
2996
- entry.settle({ action: name === "skip" ? "decline" : "cancel" });
2997
- setNotice(`${name === "skip" ? "skipped" : "dismissed"} ${entry.shortId}`);
2998
- return;
2999
- }
3000
- let content;
3001
- try {
3002
- const rawJson = /^\/respond\s+\S+\s+([\s\S]+)$/.exec(rawText ?? "")?.[1] ?? "";
3003
- content = JSON.parse(rawJson);
3004
- } catch {
3005
- setNotice("response must be a valid JSON object");
3072
+ return;
3073
+ }
3074
+ if (name === "focus") {
3075
+ choose(
3076
+ Object.values(state.agents).map((agent) => ({
3077
+ key: agent.id,
3078
+ label: agent.name,
3079
+ description: agent.description ?? agent.id
3080
+ }))
3081
+ );
3082
+ return;
3083
+ }
3084
+ if (name === "retry") {
3085
+ const ids = retryCandidates(state);
3086
+ if (ids.length === 0) {
3087
+ setNotice("nothing failed");
3006
3088
  return;
3007
3089
  }
3008
- if (!validateRuntimeQuestionContent(entry.request.requestedSchema, content)) {
3009
- setNotice(
3010
- "response does not match the question schema; check required fields and choices"
3011
- );
3090
+ choose([
3091
+ { key: "all", label: "All failed / repair agents", description: ids.join(", ") },
3092
+ ...ids.map((id) => ({
3093
+ key: id,
3094
+ label: state.agents[id]?.name ?? id,
3095
+ description: state.agents[id]?.error ?? "Retry this agent"
3096
+ }))
3097
+ ]);
3098
+ return;
3099
+ }
3100
+ if (["replay", "edit", "export"].includes(name)) {
3101
+ const turns = recordedTurns(state).slice().reverse();
3102
+ if (turns.length === 0) {
3103
+ setNotice(`nothing to ${name} yet`);
3012
3104
  return;
3013
3105
  }
3014
- entry.settle({
3015
- action: "accept",
3016
- content
3017
- });
3018
- setNotice(`responded to ${entry.shortId}`);
3106
+ choose(
3107
+ turns.map((turn) => ({
3108
+ key: String(turn.turn),
3109
+ label: `Turn ${turn.turn}`,
3110
+ description: turn.requirement?.replace(/\s+/g, " ") ?? "(no requirement)"
3111
+ }))
3112
+ );
3113
+ return;
3114
+ }
3115
+ if (name === "search" || name === "steer") {
3116
+ setOperationInput(name);
3117
+ setNotice(
3118
+ name === "search" ? "search the focused stream \xB7 enter find \xB7 esc cancel" : "message running agents \xB7 enter send \xB7 esc cancel"
3119
+ );
3019
3120
  return;
3020
3121
  }
3021
- case "approve":
3022
- case "deny": {
3023
- const onScreen = interactionsRef.current[0];
3024
- const byId = (id) => {
3025
- const found = interactionsRef.current.filter(
3026
- (item) => item.request.requestId === id || item.shortId === id
3027
- );
3028
- if (found.length !== 1) setNotice(`unknown or stale permission request: ${id}`);
3029
- return found.length === 1 ? found[0] : void 0;
3030
- };
3031
- let entry;
3032
- let number;
3033
- if (name === "approve" && args.length === 1 && /^\d+$/.test(args[0])) {
3034
- number = args[0];
3035
- entry = onScreen;
3036
- } else if (name === "approve" && args.length === 2) {
3037
- number = args[1];
3038
- entry = byId(args[0]);
3039
- } else if (name === "deny" && args.length === 0) {
3040
- entry = onScreen;
3041
- } else if (name === "deny" && args.length === 1) {
3042
- entry = byId(args[0]);
3122
+ }
3123
+ switch (name) {
3124
+ case "mode": {
3125
+ const m = args[0];
3126
+ if (m === "auto" || m === "ask" || m === "deny") {
3127
+ setPermissionMode(m);
3128
+ setNotice(`permission mode: ${m}`);
3043
3129
  } else {
3044
- setNotice(
3045
- name === "approve" ? "usage: /approve [id] <option number>" : "usage: /deny [id]"
3046
- );
3047
- return;
3130
+ setNotice("usage: /mode auto | ask | deny");
3048
3131
  }
3049
- if (entry === void 0) {
3050
- if (args.length === 0 || /^\d+$/.test(args[0] ?? "")) {
3051
- setNotice("no permission request on screen");
3052
- }
3053
- return;
3054
- }
3055
- if (entry.kind !== "permission") {
3056
- setNotice("the request on screen is a question, not a permission");
3057
- return;
3058
- }
3059
- if (name === "deny") {
3060
- entry.settle();
3061
- setNotice(`denied ${entry.shortId}`);
3062
- return;
3063
- }
3064
- const option = /^\d+$/.test(number ?? "") ? entry.request.options[Number(number) - 1] : void 0;
3065
- if (!option) {
3066
- setNotice(
3067
- `invalid option for ${entry.shortId}; choose 1\u2013${entry.request.options.length}`
3068
- );
3069
- return;
3070
- }
3071
- entry.settle(option.optionId);
3072
- setNotice(`selected ${clean(option.name)} for ${entry.shortId}`);
3073
3132
  return;
3074
3133
  }
3075
3134
  case "new": {
3076
3135
  const text = args.join(" ");
3077
- if (!text) {
3078
- setNotice("usage: /new <requirement> \u2014 starts a new session with fresh agent sessions");
3136
+ if (text) {
3137
+ setNotice("/new opens an empty session \u2014 enter the requirement afterwards");
3079
3138
  return;
3080
3139
  }
3081
3140
  setQueue([]);
@@ -3084,15 +3143,6 @@ function App({
3084
3143
  void startTurn(text, { newSession: true });
3085
3144
  return;
3086
3145
  }
3087
- case "answer": {
3088
- const text = args.join(" ");
3089
- if (!awaitingAnswerRef.current || !text) {
3090
- setNotice("usage: /answer <text> \u2014 only when the leader is waiting for an answer");
3091
- return;
3092
- }
3093
- void startTurn(text);
3094
- return;
3095
- }
3096
3146
  case "steer": {
3097
3147
  const text = args.join(" ");
3098
3148
  if (!text) {
@@ -3103,6 +3153,10 @@ function App({
3103
3153
  return;
3104
3154
  }
3105
3155
  case "pause":
3156
+ if (queueRef.current.length === 0) {
3157
+ setNotice("no queued turns to pause");
3158
+ return;
3159
+ }
3106
3160
  queuePausedRef.current = true;
3107
3161
  setQueuePaused(true);
3108
3162
  setNotice("queue paused \u2014 the active turn continues");
@@ -3120,56 +3174,31 @@ function App({
3120
3174
  setNotice("answer the leader first; queued input is not an answer");
3121
3175
  return;
3122
3176
  }
3177
+ if (queueRef.current.length === 0) {
3178
+ setNotice("no queued turns to resume");
3179
+ return;
3180
+ }
3123
3181
  queuePausedRef.current = false;
3124
3182
  setQueuePaused(false);
3125
3183
  setNotice("queue resumed");
3126
3184
  return;
3127
3185
  }
3128
- case "modify": {
3129
- const instruction = args.join(" ");
3130
- if (!instruction) {
3131
- setNotice("usage: /modify <instruction> \u2014 only at a checkpoint");
3132
- return;
3133
- }
3134
- if (!checkpointResolveRef.current) {
3135
- setNotice("no checkpoint is waiting");
3136
- return;
3137
- }
3138
- const resolve2 = checkpointResolveRef.current;
3139
- checkpointResolveRef.current = null;
3140
- checkpointLabelRef.current = "";
3141
- resolve2({ action: "modify", instruction });
3142
- setNotice("modifying and continuing\u2026");
3143
- return;
3144
- }
3145
- case "agents":
3146
- setNotice(
3147
- `team: ${plannedAgents(config).map((agent) => agent.id).join(", ")}`
3148
- );
3149
- return;
3150
3186
  case "focus": {
3151
3187
  const input = args[0];
3152
3188
  if (!input) {
3153
3189
  setNotice("usage: /focus <agent id>");
3154
3190
  return;
3155
3191
  }
3156
- const resolved = resolveAgentId(input, Object.keys(shown2.agents));
3192
+ const resolved = resolveAgentId(input, Object.keys(state.agents));
3157
3193
  if ("error" in resolved) {
3158
3194
  setNotice(resolved.error);
3159
3195
  return;
3160
3196
  }
3197
+ setSearchResult(null);
3161
3198
  dispatch({ type: "focus:set", agentId: resolved.id });
3162
3199
  setNotice(`focused ${resolved.id}`);
3163
3200
  return;
3164
3201
  }
3165
- case "clear":
3166
- if (!shown2.focusId) {
3167
- setNotice("nothing focused");
3168
- return;
3169
- }
3170
- dispatch({ type: "stream:clear" });
3171
- setNotice(`cleared ${shown2.focusId}`);
3172
- return;
3173
3202
  case "sessions": {
3174
3203
  const list = listSessions(config.workDir);
3175
3204
  setSessionList(list);
@@ -3184,38 +3213,44 @@ function App({
3184
3213
  setNotice(state.verbose ? "verbose off" : "verbose on \u2014 showing tool detail");
3185
3214
  return;
3186
3215
  case "retry": {
3187
- if (state.phase !== "complete" || !state.requirement) {
3216
+ const turn = recordedTurns(state).slice(-1)[0];
3217
+ if (!turn?.requirement) {
3188
3218
  setNotice("nothing to retry yet");
3189
3219
  return;
3190
3220
  }
3221
+ if (args[0] === "all") args = [];
3191
3222
  let ids;
3192
- if (state.acceptance && state.acceptance.status !== "approved" && (!args[0] || args[0] === state.acceptance.reviewer)) {
3223
+ if (turn.acceptance && turn.acceptance.status !== "approved" && (!args[0] || args[0] === turn.acceptance.reviewer)) {
3193
3224
  ids = [
3194
- ...new Set(state.stages.flatMap((stage) => stage.agents.map((agent) => agent.id)))
3195
- ].filter((id) => id !== state.acceptance?.reviewer);
3225
+ ...new Set(turn.stages.flatMap((stage) => stage.agents.map((agent) => agent.id)))
3226
+ ].filter((id) => id !== turn.acceptance?.reviewer);
3196
3227
  } else if (args[0]) {
3197
- const resolved = resolveAgentId(args[0], Object.keys(state.agents));
3228
+ const resolved = resolveAgentId(args[0], Object.keys(turn.agents));
3198
3229
  if ("error" in resolved) {
3199
3230
  setNotice(resolved.error);
3200
3231
  return;
3201
3232
  }
3233
+ if (!retryCandidates(state).includes(resolved.id)) {
3234
+ setNotice(`agent has not failed or does not need repair: ${resolved.id}`);
3235
+ return;
3236
+ }
3202
3237
  ids = [resolved.id];
3203
3238
  } else {
3204
- ids = Object.values(state.agents).filter((agent) => agent.status === "failed").map((agent) => agent.id);
3239
+ ids = Object.values(turn.agents).filter((agent) => agent.status === "failed").map((agent) => agent.id);
3205
3240
  }
3206
3241
  if (ids.length === 0) {
3207
3242
  setNotice("nothing failed");
3208
3243
  return;
3209
3244
  }
3210
3245
  const sessions = Object.fromEntries(
3211
- ids.filter((id) => state.agents[id]?.sessionId).map((id) => [id, state.agents[id].sessionId])
3246
+ ids.filter((id) => turn.agents[id]?.sessionId).map((id) => [id, turn.agents[id].sessionId])
3212
3247
  );
3213
- if (ids.some((id) => state.leaderIds.includes(id))) {
3214
- void startTurn(state.requirement);
3248
+ if (ids.some((id) => turn.leaderIds.includes(id))) {
3249
+ void startTurn(turn.requirement);
3215
3250
  setNotice("retrying leader routing");
3216
3251
  return;
3217
3252
  }
3218
- void startTurn(state.effectiveRequirement ?? state.requirement, {
3253
+ void startTurn(turn.effectiveRequirement ?? turn.requirement, {
3219
3254
  resume: {
3220
3255
  agents: ids,
3221
3256
  ...Object.keys(sessions).length > 0 ? { sessions } : {}
@@ -3240,8 +3275,8 @@ function App({
3240
3275
  setNotice(`replaying turn ${target.record.turn}`);
3241
3276
  return;
3242
3277
  }
3243
- case "fork": {
3244
- const target = pickTurn(state, args[0], "nothing to fork yet");
3278
+ case "edit": {
3279
+ const target = pickTurn(state, args[0], "nothing to edit yet");
3245
3280
  if ("error" in target) {
3246
3281
  setNotice(target.error);
3247
3282
  return;
@@ -3266,74 +3301,66 @@ function App({
3266
3301
  setNotice("usage: /search <text>");
3267
3302
  return;
3268
3303
  }
3304
+ const matches2 = streamMatches(state, text, layout.streamWidth);
3305
+ if (matches2.length === 0) {
3306
+ setSearchResult(null);
3307
+ setNotice(`no matches for ${text}`);
3308
+ return;
3309
+ }
3310
+ const index = matches2.length - 1;
3311
+ setSearchResult({ query: text, index });
3269
3312
  dispatch({
3270
3313
  type: "stream:search",
3271
3314
  query: text,
3315
+ match: index,
3272
3316
  viewport: layout.logViewport,
3273
3317
  width: layout.streamWidth
3274
3318
  });
3275
- setNotice(`searched for ${text}`);
3319
+ setNotice(
3320
+ "searched for " + text + " \xB7 match " + (index + 1) + "/" + matches2.length + " \xB7 ctrl+n next \xB7 ctrl+p previous"
3321
+ );
3276
3322
  return;
3277
3323
  }
3278
3324
  case "latest":
3325
+ setSearchResult(null);
3279
3326
  dispatch({ type: "stream:latest" });
3280
3327
  setNotice("showing latest output");
3281
3328
  return;
3282
- case "cancel": {
3283
- if (checkpointResolveRef.current) {
3284
- const resolve2 = checkpointResolveRef.current;
3285
- checkpointResolveRef.current = null;
3286
- checkpointLabelRef.current = "";
3287
- resolve2({ action: "cancel" });
3288
- setNotice("checkpoint cancelled");
3289
- return;
3290
- }
3291
- if (!abortRef.current) {
3292
- setNotice("nothing to cancel");
3293
- return;
3294
- }
3295
- const dropped = queue.length;
3296
- abortRef.current.abort();
3297
- clearInteractions();
3298
- setQueue([]);
3299
- setCancelled(true);
3300
- setNotice(dropped > 0 ? `cancelling\u2026 \xB7 dropped ${dropped} queued` : "cancelling\u2026");
3301
- return;
3302
- }
3303
- case "help":
3304
- setNotice(void 0);
3305
- setHelpOpen(true);
3306
- return;
3307
- case "quit":
3308
- void shutdown();
3309
- return;
3310
3329
  default:
3311
3330
  setNotice(`unknown command: /${name}`);
3312
3331
  }
3313
3332
  },
3314
- [
3315
- config,
3316
- shutdown,
3317
- clearInteractions,
3318
- layout.logViewport,
3319
- layout.streamWidth,
3320
- queue.length,
3321
- running,
3322
- state,
3323
- startTurn,
3324
- steerRunningAgents
3325
- ]
3333
+ [config, layout.logViewport, layout.streamWidth, running, state, startTurn, steerRunningAgents]
3326
3334
  );
3327
3335
  const submit = useCallback(() => {
3328
3336
  const text = line.text.trim();
3329
3337
  if (!text || closingRef.current) return;
3338
+ if (operationInput) {
3339
+ const operation = operationInput;
3340
+ setOperationInput(null);
3341
+ if (operation === "steer" && !running) {
3342
+ setLine(EMPTY_LINE);
3343
+ setQueue((current) => [...current, text]);
3344
+ queuePausedRef.current = true;
3345
+ setQueuePaused(true);
3346
+ setNotice("turn finished \u2014 message queued; /continue runs it");
3347
+ return;
3348
+ }
3349
+ runCommand(operation, [text]);
3350
+ return;
3351
+ }
3330
3352
  const command = parseCommand(text);
3331
3353
  if (command) {
3332
- runCommand(command.name, command.args, text);
3354
+ runCommand(command.name, command.args);
3333
3355
  return;
3334
3356
  }
3335
3357
  if (checkpointResolveRef.current) {
3336
- runCommand("modify", [text], text);
3358
+ const resolve2 = checkpointResolveRef.current;
3359
+ checkpointResolveRef.current = null;
3360
+ checkpointLabelRef.current = "";
3361
+ setLine(EMPTY_LINE);
3362
+ resolve2({ action: "modify", instruction: text });
3363
+ setNotice("modifying and continuing\u2026");
3337
3364
  return;
3338
3365
  }
3339
3366
  setLine(EMPTY_LINE);
@@ -3343,7 +3370,7 @@ function App({
3343
3370
  return;
3344
3371
  }
3345
3372
  void startTurn(text);
3346
- }, [line.text, runCommand, running, startTurn]);
3373
+ }, [line.text, operationInput, runCommand, running, startTurn]);
3347
3374
  const insert = useCallback((chunk) => {
3348
3375
  setLine((l) => ({
3349
3376
  text: l.text.slice(0, l.cursor) + chunk + l.text.slice(l.cursor),
@@ -3373,7 +3400,12 @@ function App({
3373
3400
  const spinner = SPINNER[tick % SPINNER.length];
3374
3401
  const handleKey = (char, key) => {
3375
3402
  if (key.ctrl && char === "c") {
3376
- if (helpOpen) {
3403
+ if (picker || operationInput) {
3404
+ setNotice(void 0);
3405
+ setPicker(null);
3406
+ setOperationInput(null);
3407
+ setLine(EMPTY_LINE);
3408
+ } else if (helpOpen) {
3377
3409
  setHelpOpen(false);
3378
3410
  } else if (sessionsOpen) {
3379
3411
  setSessionsOpen(false);
@@ -3438,13 +3470,61 @@ function App({
3438
3470
  }
3439
3471
  return;
3440
3472
  }
3473
+ if (picker) {
3474
+ if (key.escape) {
3475
+ setPicker(null);
3476
+ setLine(EMPTY_LINE);
3477
+ setNotice(void 0);
3478
+ return;
3479
+ }
3480
+ if (key.upArrow || key.downArrow || key.tab) {
3481
+ const delta = key.upArrow || key.tab && key.shift ? -1 : 1;
3482
+ setPicker({
3483
+ ...picker,
3484
+ cursor: (picker.cursor + delta + picker.rows.length) % picker.rows.length
3485
+ });
3486
+ return;
3487
+ }
3488
+ if (key.return) {
3489
+ const row = picker.rows[picker.cursor];
3490
+ setPicker(null);
3491
+ if (row) runCommand(picker.command, [row.key]);
3492
+ return;
3493
+ }
3494
+ if (!(key.ctrl && char === "d")) return;
3495
+ }
3496
+ if (operationInput && key.escape) {
3497
+ setOperationInput(null);
3498
+ setLine(EMPTY_LINE);
3499
+ setNotice(void 0);
3500
+ return;
3501
+ }
3502
+ if (searchResult && key.ctrl && (char === "n" || char === "p")) {
3503
+ const matches2 = streamMatches(state, searchResult.query, layout.streamWidth);
3504
+ if (matches2.length === 0) {
3505
+ setSearchResult(null);
3506
+ setNotice(`no matches for ${searchResult.query}`);
3507
+ return;
3508
+ }
3509
+ const index = (searchResult.index + (char === "n" ? 1 : -1) + matches2.length) % matches2.length;
3510
+ setSearchResult({ ...searchResult, index });
3511
+ dispatch({
3512
+ type: "stream:search",
3513
+ query: searchResult.query,
3514
+ match: index,
3515
+ viewport: layout.logViewport,
3516
+ width: layout.streamWidth
3517
+ });
3518
+ setNotice(`match ${index + 1}/${matches2.length} \xB7 ctrl+n next \xB7 ctrl+p previous`);
3519
+ return;
3520
+ }
3441
3521
  if (key.ctrl && char === "d") {
3442
3522
  if (!line.text) void shutdown();
3443
3523
  return;
3444
3524
  }
3445
3525
  if (key.ctrl && char === "l") {
3446
3526
  dispatch({ type: "stream:clear" });
3447
- setNotice(shown.focusId ? `cleared ${shown.focusId}` : "nothing focused");
3527
+ setNotice(state.focusId ? `cleared ${state.focusId}` : "nothing focused");
3448
3528
  return;
3449
3529
  }
3450
3530
  if (key.ctrl && char === "a") {
@@ -3455,9 +3535,16 @@ function App({
3455
3535
  setLine((l) => ({ ...l, cursor: l.text.length }));
3456
3536
  return;
3457
3537
  }
3458
- const formFront = front?.kind === "question" && !isCommandLine(line.text) ? front : void 0;
3538
+ const formFront = front?.kind === "question" && !operationInput ? front : void 0;
3459
3539
  if (key.tab && formFront === void 0 && !paletteOpen) {
3460
- dispatch({ type: "focus:cycle", delta: key.shift ? -1 : 1 });
3540
+ if (key.shift) {
3541
+ const next = permissionMode === "auto" ? "ask" : "auto";
3542
+ setPermissionMode(next);
3543
+ setNotice(`permission mode: ${next}`);
3544
+ return;
3545
+ }
3546
+ setSearchResult(null);
3547
+ dispatch({ type: "focus:cycle", delta: 1 });
3461
3548
  return;
3462
3549
  }
3463
3550
  if (paletteOpen) {
@@ -3489,7 +3576,7 @@ function App({
3489
3576
  return;
3490
3577
  }
3491
3578
  }
3492
- if (front?.kind === "permission" && line.text === "" && !menu.released) {
3579
+ if (!operationInput && front?.kind === "permission" && line.text === "" && !menu.released) {
3493
3580
  const total = permissionChoices(front.request).length;
3494
3581
  if (key.upArrow || key.downArrow) {
3495
3582
  setPermissionMenu({
@@ -3525,8 +3612,12 @@ function App({
3525
3612
  if (draft !== void 0) setLine({ text: draft, cursor: draft.length });
3526
3613
  };
3527
3614
  const moveFocus = (index, state2 = committed) => {
3528
- const next = focusField(questionFields, state2, index);
3529
- save(next.state, next.draft);
3615
+ const target = (index + total + 3) % (total + 3);
3616
+ if (target >= total) save({ ...state2, field: target }, "");
3617
+ else {
3618
+ const next = focusField(questionFields, state2, target);
3619
+ save(next.state, next.draft);
3620
+ }
3530
3621
  };
3531
3622
  const submit2 = (state2) => {
3532
3623
  if (!formComplete(questionFields, state2)) {
@@ -3548,15 +3639,29 @@ function App({
3548
3639
  setNotice(`answered ${front2.shortId}`);
3549
3640
  };
3550
3641
  const advance = (state2) => {
3551
- if (form.field >= total - 1) submit2(state2);
3552
- else moveFocus(form.field + 1, state2);
3642
+ moveFocus(form.field + 1, state2);
3553
3643
  };
3554
3644
  if (field === void 0) {
3555
- if (key.return) submit2(committed);
3645
+ const action = Math.max(0, form.field - total);
3646
+ if (key.tab || key.leftArrow || key.rightArrow || key.upArrow || key.downArrow) {
3647
+ const delta = key.leftArrow || key.upArrow || key.tab && key.shift ? -1 : 1;
3648
+ moveFocus(form.field + delta);
3649
+ return;
3650
+ }
3651
+ if (key.return) {
3652
+ if (action === 0) submit2(committed);
3653
+ else {
3654
+ front2.settle({ action: action === 1 ? "decline" : "cancel" });
3655
+ setFormEntry({ id: "", state: initialForm() });
3656
+ setLine(EMPTY_LINE);
3657
+ setNotice(action === 1 ? "question skipped" : "question cancelled");
3658
+ }
3659
+ }
3556
3660
  return;
3557
3661
  }
3558
3662
  if (key.tab) {
3559
- moveFocus(key.shift ? form.field - 1 : form.field + 1);
3663
+ const next = field.kind === "choice" ? commitField(questionFields, committed) : committed;
3664
+ moveFocus(key.shift ? form.field - 1 : form.field + 1, next);
3560
3665
  return;
3561
3666
  }
3562
3667
  if (field.choices.length > 0 && (key.upArrow || key.downArrow)) {
@@ -3660,12 +3765,8 @@ function App({
3660
3765
  setError(void 0);
3661
3766
  return;
3662
3767
  }
3663
- if (state.view) {
3664
- dispatch({ type: "view:close" });
3665
- return;
3666
- }
3667
3768
  if (front !== void 0) {
3668
- setPermissionMenu({ id: front.shortId, cursor: void 0, released: true });
3769
+ setPermissionMenu({ id: front.shortId, cursor: 0, released: !menu.released });
3669
3770
  }
3670
3771
  setLine(EMPTY_LINE);
3671
3772
  setNotice(void 0);
@@ -3684,17 +3785,42 @@ function App({
3684
3785
  const handlerRef = useRef(handleKey);
3685
3786
  handlerRef.current = handleKey;
3686
3787
  useInput((char, key) => handlerRef.current(char, key));
3687
- const keys = helpOpen ? "esc close" : sessionsOpen ? "\u2191\u2193 select \xB7 enter open \xB7 d delete \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 tab complete \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 tab complete \xB7 enter run \xB7 esc close" : running ? checkpointResolveRef.current ? `/continue \xB7 /modify <instruction> \xB7 /cancel` : interactions.length > 0 ? layout.interactionRows === 0 ? (
3688
- // Too short to dock the bar, so the commands are the only way in.
3689
- "decide with /approve 1 \xB7 /deny \xB7 /respond"
3690
- ) : interactions[0].kind === "question" ? "tab field \xB7 \u2191\u2193 pick \xB7 enter next \xB7 /dismiss" : menu.released ? "/approve 1 and /deny still reach it" : "\u2191\u2193 or 1-9 choose \xB7 enter confirm \xB7 esc to type" : "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";
3691
- const placeholder = frontQuestion !== void 0 ? formField === void 0 ? "enter to answer" : editingField !== void 0 ? `${editingField.title}${editingField.required ? " *" : ""} \u2014 enter for the next field` : `${formField.title} \u2014 \u2191\u2193 to pick \xB7 enter for the next field` : front?.kind === "permission" && !menu.released ? "choose above \xB7 esc to type here instead" : checkpointResolveRef.current ? `checkpoint: ${checkpointLabelRef.current} \u2014 type feedback or /continue` : running ? RUNNING_PLACEHOLDER : awaitingAnswer ? "answer the leader\u2026 \xB7 /task starts a new task" : INPUT_PLACEHOLDER;
3788
+ const keys = picker ? "\u2191\u2193 select \xB7 enter confirm \xB7 esc cancel" : operationInput ? "enter submit \xB7 esc cancel" : helpOpen ? "esc close" : sessionsOpen ? "\u2191\u2193 select \xB7 enter open \xB7 d delete \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 tab complete \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 tab complete \xB7 enter run \xB7 esc close" : running ? checkpointResolveRef.current ? `/continue \xB7 type feedback \xB7 ctrl+c cancel` : interactions.length > 0 ? layout.interactionRows === 0 ? (
3789
+ // On short terminals the decision is shown in the body.
3790
+ "\u2191\u2193 choose \xB7 enter confirm \xB7 esc type \xB7 ctrl+c cancel"
3791
+ ) : interactions[0].kind === "question" ? "tab field / actions \xB7 \u2191\u2193 pick \xB7 enter confirm" : menu.released ? "esc return to permission options" : "\u2191\u2193 or 1-9 choose \xB7 enter confirm \xB7 esc to type" : "enter queues \xB7 ctrl+c cancel" : state.reply ? "enter sends \xB7 / commands \xB7 ? help" : "tab switch \xB7 shift+tab mode \xB7 / commands \xB7 ? help";
3792
+ const placeholder = picker ? "choose an option above\u2026" : operationInput ? operationInput === "search" ? "search text\u2026" : "message for running agents\u2026" : frontQuestion !== void 0 ? formField === void 0 ? "enter to answer" : editingField !== void 0 ? `${editingField.title}${editingField.required ? " *" : ""} \u2014 enter for the next field` : `${formField.title} \u2014 \u2191\u2193 to pick \xB7 enter for the next field` : front?.kind === "permission" && !menu.released ? "choose above \xB7 esc to type here instead" : checkpointResolveRef.current ? `checkpoint: ${checkpointLabelRef.current} \u2014 type feedback or /continue` : running ? RUNNING_PLACEHOLDER : awaitingAnswer ? "answer the leader\u2026 \xB7 /new starts a new session" : INPUT_PLACEHOLDER;
3793
+ const renderInteraction = (maxRows) => {
3794
+ if (!front) return null;
3795
+ return front.kind === "permission" ? /* @__PURE__ */ jsx6(
3796
+ PermissionBar,
3797
+ {
3798
+ request: front.request,
3799
+ others: interactions.slice(1).flatMap((entry) => entry.kind === "permission" ? [entry.request] : []),
3800
+ cursor: menu.cursor,
3801
+ released: menu.released,
3802
+ width: layout.columns,
3803
+ maxRows
3804
+ }
3805
+ ) : /* @__PURE__ */ jsx6(
3806
+ QuestionForm,
3807
+ {
3808
+ request: front.request,
3809
+ others: interactions.slice(1).flatMap((entry) => entry.kind === "question" ? [entry.request] : []),
3810
+ fields: questionFields,
3811
+ form,
3812
+ draft: line.text,
3813
+ width: layout.columns,
3814
+ maxRows
3815
+ }
3816
+ );
3817
+ };
3692
3818
  return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", height: layout.rows, children: [
3693
3819
  /* @__PURE__ */ jsx6(
3694
3820
  Header,
3695
3821
  {
3696
3822
  name: state.name || config.name,
3697
- right: state.view ? `turn ${shown.turn} \xB7 viewing` : `turn ${shown.turn} \xB7 ${running ? formatDuration(turnElapsed(state, now) ?? 0) : state.phase === "complete" ? "complete" : "ready"}`
3823
+ right: `turn ${state.turn} \xB7 ${running ? formatDuration(turnElapsed(state, now) ?? 0) : state.phase === "complete" ? "complete" : "ready"} \xB7 ${permissionMode}`
3698
3824
  }
3699
3825
  ),
3700
3826
  /* @__PURE__ */ jsx6(Rule, { width: layout.columns }),
@@ -3703,9 +3829,9 @@ function App({
3703
3829
  // The rail wraps, so it can be taller than one row. Cap it at the height
3704
3830
  // the budget reserved and clip, rather than letting the extra rows push
3705
3831
  // the chrome below it off the screen.
3706
- /* @__PURE__ */ jsx6(Box6, { marginTop: 1, height: layout.railRows, overflow: "hidden", flexShrink: 0, children: /* @__PURE__ */ jsx6(PipelineRail, { turn: shown, spinner }) })
3832
+ /* @__PURE__ */ jsx6(Box6, { marginTop: 1, height: layout.railRows, overflow: "hidden", flexShrink: 0, children: /* @__PURE__ */ jsx6(PipelineRail, { turn: state, spinner }) })
3707
3833
  ) : null,
3708
- shown.reason ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, wrap: "truncate-end", children: ` reason: ${shown.reason}` }) : null,
3834
+ state.reason ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, wrap: "truncate-end", children: ` reason: ${state.reason}` }) : null,
3709
3835
  /* @__PURE__ */ jsx6(
3710
3836
  Box6,
3711
3837
  {
@@ -3724,20 +3850,20 @@ function App({
3724
3850
  maxRows: layout.bodyHeight,
3725
3851
  now
3726
3852
  }
3727
- ) : layout.welcome ? /* @__PURE__ */ jsx6(WelcomePanel, { turn: state, width: layout.columns }) : error ? (
3853
+ ) : front && layout.interactionRows === 0 && !paletteOpen ? renderInteraction(layout.bodyHeight) : layout.welcome ? /* @__PURE__ */ jsx6(WelcomePanel, { turn: state, width: layout.columns }) : error ? (
3728
3854
  // The error wins over a reply: a run that stopped with an error is the
3729
3855
  // most urgent thing on screen, and a stale answer underneath it would
3730
3856
  // read as the outcome. The status line keeps a one-line version too.
3731
3857
  /* @__PURE__ */ jsx6(ErrorPanel, { message: error, width: layout.columns })
3732
- ) : shown.reply ? /* @__PURE__ */ jsx6(ReplyPanel, { reply: shown.reply, width: layout.columns }) : /* @__PURE__ */ jsxs6(Box6, { height: layout.bodyHeight, flexShrink: 0, flexDirection: "row", children: [
3858
+ ) : state.reply ? /* @__PURE__ */ jsx6(ReplyPanel, { reply: state.reply, width: layout.columns }) : /* @__PURE__ */ jsxs6(Box6, { height: layout.bodyHeight, flexShrink: 0, flexDirection: "row", children: [
3733
3859
  layout.showTeam ? /* @__PURE__ */ jsxs6(Fragment3, { children: [
3734
- /* @__PURE__ */ jsx6(TeamPanel, { turn: shown, width: layout.teamWidth, now, spinner }),
3860
+ /* @__PURE__ */ jsx6(TeamPanel, { turn: state, width: layout.teamWidth, now, spinner }),
3735
3861
  /* @__PURE__ */ jsx6(Divider, {})
3736
3862
  ] }) : null,
3737
3863
  /* @__PURE__ */ jsx6(
3738
3864
  StreamPanel,
3739
3865
  {
3740
- turn: shown,
3866
+ state,
3741
3867
  height: layout.bodyHeight,
3742
3868
  width: layout.streamWidth,
3743
3869
  now,
@@ -3747,7 +3873,6 @@ function App({
3747
3873
  ] })
3748
3874
  }
3749
3875
  ),
3750
- state.view ? /* @__PURE__ */ jsx6(Text6, { color: COLOR.warn, wrap: "truncate-end", children: ` viewing turn ${shown.turn} \xB7 ${shown.requirement ?? "(no requirement)"} \xB7 esc to return` }) : null,
3751
3876
  notice || queuePaused && queue.length > 0 ? /* @__PURE__ */ jsxs6(Text6, { dimColor: true, wrap: "truncate-end", children: [
3752
3877
  " ",
3753
3878
  notice ?? (awaitingAnswer ? "queue paused \u2014 answer the leader first" : "queue paused \u2014 /continue resumes it")
@@ -3762,26 +3887,7 @@ function App({
3762
3887
  overflow: "hidden",
3763
3888
  flexShrink: 0,
3764
3889
  flexDirection: "column",
3765
- children: front.kind === "permission" ? /* @__PURE__ */ jsx6(
3766
- PermissionBar,
3767
- {
3768
- request: front.request,
3769
- others: interactions.slice(1).flatMap((entry) => entry.kind === "permission" ? [entry.request] : []),
3770
- cursor: menu.cursor,
3771
- released: menu.released,
3772
- width: layout.columns
3773
- }
3774
- ) : /* @__PURE__ */ jsx6(
3775
- QuestionForm,
3776
- {
3777
- request: front.request,
3778
- others: interactions.slice(1).flatMap((entry) => entry.kind === "question" ? [entry.request] : []),
3779
- fields: questionFields,
3780
- form,
3781
- draft: line.text,
3782
- width: layout.columns
3783
- }
3784
- )
3890
+ children: renderInteraction(layout.interactionRows)
3785
3891
  }
3786
3892
  )
3787
3893
  ] }),
@@ -3881,8 +3987,9 @@ export {
3881
3987
  seedAgents,
3882
3988
  stageStatus,
3883
3989
  startTui,
3990
+ streamMatches,
3884
3991
  stripAnsi,
3885
3992
  tuiReducer,
3886
3993
  turnElapsed,
3887
- visibleLog
3994
+ visibleTimeline
3888
3995
  };