wave-code 1.3.0 → 1.5.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.
Files changed (63) hide show
  1. package/dist/bundle/wave.mjs +1382 -1131
  2. package/package.json +9 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/commands/update.ts +62 -22
  5. package/src/components/AgentsManager.tsx +3 -3
  6. package/src/components/App.tsx +5 -0
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/BtwDisplay.tsx +71 -15
  9. package/src/components/CompactDisplay.tsx +1 -6
  10. package/src/components/ConfirmationDetails.tsx +34 -13
  11. package/src/components/ConfirmationSelector.tsx +31 -36
  12. package/src/components/DiffDisplay.tsx +1 -1
  13. package/src/components/HooksManager.tsx +3 -3
  14. package/src/components/InputBox.tsx +37 -11
  15. package/src/components/LoginCommand.tsx +16 -35
  16. package/src/components/Markdown.tsx +7 -3
  17. package/src/components/MarketplaceAddForm.tsx +15 -19
  18. package/src/components/MessageBlockItem.tsx +0 -6
  19. package/src/components/RemoteControlView.tsx +175 -0
  20. package/src/components/ScrolledContent.tsx +15 -13
  21. package/src/components/SkillsManager.tsx +3 -3
  22. package/src/components/TaskList.tsx +3 -3
  23. package/src/components/ToolDisplay.tsx +3 -18
  24. package/src/components/WorkflowManager.tsx +150 -136
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +6 -0
  27. package/src/contexts/useChat.tsx +308 -154
  28. package/src/daemon/commands.ts +31 -22
  29. package/src/daemon-cli.ts +6 -1
  30. package/src/hooks/useInputManager.ts +25 -39
  31. package/src/hooks/useLineScroll.ts +51 -5
  32. package/src/hooks/useRemoteControl.ts +346 -0
  33. package/src/index.ts +35 -0
  34. package/src/managers/inputHandlers.ts +5 -0
  35. package/src/managers/inputReducer.ts +39 -0
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/workflowManagerReducer.ts +21 -2
  38. package/src/remote/frameBudget.ts +45 -0
  39. package/src/remote/hostIdentity.ts +128 -0
  40. package/src/remote/messageWindow.ts +46 -0
  41. package/src/remote/notificationCoalescer.ts +192 -0
  42. package/src/remote/relayClient.ts +690 -0
  43. package/src/remote/remoteControlCliHost.ts +420 -0
  44. package/src/remote/remoteControlCommands.ts +197 -0
  45. package/src/remote/remoteControlConfig.ts +353 -0
  46. package/src/remote/remoteControlService.ts +822 -0
  47. package/src/remote/remoteControlSink.ts +78 -0
  48. package/src/remote/remoteNotifications.ts +200 -0
  49. package/src/stdio/agentBridge.ts +874 -106
  50. package/src/stdio/daemonServer.ts +30 -0
  51. package/src/stdio/hostSlashCommands.ts +209 -0
  52. package/src/stdio/jsonRpcConnection.ts +11 -2
  53. package/src/stdio/protocol.ts +14 -115
  54. package/src/stdio/stdioServer.ts +33 -2
  55. package/src/stdio/uiPayload.ts +65 -0
  56. package/src/stdio-cli.ts +11 -2
  57. package/src/types.ts +7 -0
  58. package/src/utils/gitCredentialHelper.ts +106 -0
  59. package/src/utils/logger.ts +5 -2
  60. package/src/utils/rewindCheckpoints.ts +2 -4
  61. package/src/utils/workspaceDiff.ts +620 -0
  62. package/src/utils/worktree.ts +97 -0
  63. package/src/utils/bracketedPaste.ts +0 -170
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useMemo, useReducer } from "react";
2
- import { Box, Text, useInput, useStdout } from "ink";
2
+ import { Box, Text, useInput, useWindowSize } from "ink";
3
3
  import type { SkillMetadata } from "wave-agent-sdk";
4
4
  import {
5
5
  skillsManagerReducer,
@@ -55,7 +55,7 @@ export const SkillsManager: React.FC<SkillsManagerProps> = ({
55
55
  skills,
56
56
  }) => {
57
57
  const [state, dispatch] = useReducer(skillsManagerReducer, initialState);
58
- const { stdout } = useStdout();
58
+ const { rows } = useWindowSize();
59
59
 
60
60
  // Handle pending effects
61
61
  useEffect(() => {
@@ -114,7 +114,7 @@ export const SkillsManager: React.FC<SkillsManagerProps> = ({
114
114
  // Window slice: center the selected item within the visible area, clamping
115
115
  // to the terminal's available rows (reusable pattern from
116
116
  // AgentsManager/BackgroundTaskManager).
117
- const availableRows = stdout?.rows ?? 24;
117
+ const availableRows = rows || 24;
118
118
  const maxVisible = Math.max(3, Math.min(15, availableRows - 12));
119
119
  const selectedFlatIndex = entries.findIndex(
120
120
  (e) => e.selectableIndex === state.selectedIndex,
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { useChat } from "../contexts/useChat.js";
3
- import { Box, Text, useStdout } from "ink";
3
+ import { Box, Text, useWindowSize } from "ink";
4
4
  import { useTasks } from "../hooks/useTasks.js";
5
5
  import type { Task, TaskStatus } from "wave-agent-sdk";
6
6
 
@@ -92,7 +92,7 @@ function getDisplayLimit(rows: number | undefined): number {
92
92
  export const TaskList: React.FC = () => {
93
93
  const tasks = useTasks();
94
94
  const { isTaskListVisible, isBtwActive } = useChat();
95
- const { stdout } = useStdout();
95
+ const { rows } = useWindowSize();
96
96
 
97
97
  const completionTimestampsRef = React.useRef<Map<string, number>>(new Map());
98
98
  const previousCompletedIdsRef = React.useRef<Set<string> | null>(null);
@@ -195,7 +195,7 @@ export const TaskList: React.FC = () => {
195
195
  return null;
196
196
  }
197
197
 
198
- const displayLimit = getDisplayLimit(stdout?.rows);
198
+ const displayLimit = getDisplayLimit(rows);
199
199
  const needsTruncation = tasks.length > displayLimit;
200
200
  const sorted = sortTasksByPriority(
201
201
  tasks,
@@ -91,12 +91,7 @@ export const ToolDisplay: React.FC<ToolDisplayProps> = ({
91
91
 
92
92
  {/* Display shortResult in collapsed state */}
93
93
  {!isExpanded && shortResult && !error && (
94
- <Box
95
- paddingLeft={2}
96
- borderLeft
97
- borderColor="gray"
98
- flexDirection="column"
99
- >
94
+ <Box paddingLeft={2} flexDirection="column">
100
95
  {shortResult.split("\n").map((line, index) => (
101
96
  <Text key={index} color="gray" wrap="truncate-end">
102
97
  {line}
@@ -107,12 +102,7 @@ export const ToolDisplay: React.FC<ToolDisplayProps> = ({
107
102
 
108
103
  {/* Display complete parameters in expanded state */}
109
104
  {isExpanded && parameters && (
110
- <Box
111
- paddingLeft={2}
112
- borderLeft
113
- borderColor="gray"
114
- flexDirection="column"
115
- >
105
+ <Box paddingLeft={2} flexDirection="column">
116
106
  <Text color="cyan" bold>
117
107
  Parameters:
118
108
  </Text>
@@ -123,12 +113,7 @@ export const ToolDisplay: React.FC<ToolDisplayProps> = ({
123
113
  {/* Display complete result in expanded state */}
124
114
  {isExpanded && result && (
125
115
  <Box flexDirection="column">
126
- <Box
127
- paddingLeft={2}
128
- borderLeft
129
- borderColor="green"
130
- flexDirection="column"
131
- >
116
+ <Box paddingLeft={2} flexDirection="column">
132
117
  <Text color="cyan" bold>
133
118
  Result:
134
119
  </Text>
@@ -1,53 +1,34 @@
1
- import React, { useEffect, useReducer } from "react";
1
+ import React, { useEffect, useReducer, useState } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { useChat } from "../contexts/useChat.js";
4
- import type { WorkflowRun } from "wave-agent-sdk";
5
4
  import {
6
5
  workflowManagerReducer,
7
6
  type WorkflowManagerState,
8
7
  } from "../reducers/workflowManagerReducer.js";
8
+ import WorkflowRunDetail, {
9
+ formatDuration,
10
+ formatTokens,
11
+ workflowStatusColor,
12
+ } from "./WorkflowRunDetail.js";
9
13
 
10
14
  export interface WorkflowManagerProps {
11
15
  onCancel: () => void;
12
16
  }
13
17
 
14
- const statusColor = (
15
- status: WorkflowRun["status"],
16
- ): "green" | "blue" | "red" | "yellow" => {
17
- switch (status) {
18
- case "running":
19
- return "green";
20
- case "completed":
21
- return "blue";
22
- case "failed":
23
- case "aborted":
24
- return "red";
25
- case "paused":
26
- return "yellow";
27
- }
28
- };
29
-
30
- const formatDuration = (ms: number): string => {
31
- if (ms < 1000) return `${ms}ms`;
32
- if (ms < 60000) return `${Math.round(ms / 1000)}s`;
33
- const minutes = Math.floor(ms / 60000);
34
- const seconds = Math.round((ms % 60000) / 1000);
35
- return `${minutes}m ${seconds}s`;
36
- };
37
-
38
- const formatTime = (timestamp: number): string => {
39
- return new Date(timestamp).toLocaleTimeString();
40
- };
41
-
42
- const formatTokens = (tokens: number): string => {
43
- if (tokens < 1000) return `${tokens}`;
44
- return `${(tokens / 1000).toFixed(1)}k`;
45
- };
18
+ /** Inline save dialog state, opened from a run's detail view (spec workflow「把
19
+ * 脚本存进库并按名字复用」场景 2). */
20
+ interface SaveDialogState {
21
+ name: string;
22
+ scope: "project" | "user";
23
+ /** Set on a conflict so the next Enter overwrites instead of failing again. */
24
+ overwrite: boolean;
25
+ error?: string;
26
+ }
46
27
 
47
28
  export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
48
29
  onCancel,
49
30
  }) => {
50
- const { workflowRuns, stopWorkflowRun } = useChat();
31
+ const { workflowRuns, stopWorkflowRun, saveWorkflowScript } = useChat();
51
32
  const MAX_VISIBLE_ITEMS = 3;
52
33
 
53
34
  const [state, dispatch] = useReducer(workflowManagerReducer, {
@@ -57,6 +38,8 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
57
38
  detailRunId: null,
58
39
  pendingEffect: null,
59
40
  } as WorkflowManagerState);
41
+ const [saveDialog, setSaveDialog] = useState<SaveDialogState | null>(null);
42
+ const [saveNotice, setSaveNotice] = useState<string | null>(null);
60
43
 
61
44
  // Handle pending effects
62
45
  useEffect(() => {
@@ -82,7 +65,93 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
82
65
  dispatch({ type: "SET_RUNS", runs: workflowRuns });
83
66
  }, [workflowRuns]);
84
67
 
68
+ const submitSave = async () => {
69
+ if (!saveDialog || !state.detailRunId) return;
70
+ try {
71
+ const saved = await saveWorkflowScript(state.detailRunId, {
72
+ name: saveDialog.name,
73
+ scope: saveDialog.scope,
74
+ overwrite: saveDialog.overwrite,
75
+ });
76
+ setSaveDialog(null);
77
+ setSaveNotice(`Saved "${saved.name}" to ${saved.path}`);
78
+ } catch (error) {
79
+ const message = error instanceof Error ? error.message : String(error);
80
+ setSaveDialog((current) =>
81
+ current
82
+ ? {
83
+ ...current,
84
+ error: message,
85
+ // A name conflict is the one failure the user can resolve right
86
+ // here: the next Enter overwrites (spec 场景 4).
87
+ overwrite: message.includes("already exists")
88
+ ? true
89
+ : current.overwrite,
90
+ }
91
+ : current,
92
+ );
93
+ }
94
+ };
95
+
85
96
  useInput((input, key) => {
97
+ // The save dialog owns the keyboard while it is open, so typing a name
98
+ // never triggers the list/detail shortcuts underneath.
99
+ if (saveDialog) {
100
+ if (key.escape) {
101
+ setSaveDialog(null);
102
+ return;
103
+ }
104
+ if (key.tab) {
105
+ setSaveDialog((current) =>
106
+ current
107
+ ? {
108
+ ...current,
109
+ scope: current.scope === "project" ? "user" : "project",
110
+ error: undefined,
111
+ }
112
+ : current,
113
+ );
114
+ return;
115
+ }
116
+ if (key.return) {
117
+ void submitSave();
118
+ return;
119
+ }
120
+ if (key.backspace || key.delete) {
121
+ setSaveDialog((current) =>
122
+ current
123
+ ? {
124
+ ...current,
125
+ name: current.name.slice(0, -1),
126
+ error: undefined,
127
+ }
128
+ : current,
129
+ );
130
+ return;
131
+ }
132
+ if (input && !key.ctrl && !key.meta) {
133
+ setSaveDialog((current) =>
134
+ current
135
+ ? { ...current, name: current.name + input, error: undefined }
136
+ : current,
137
+ );
138
+ }
139
+ return;
140
+ }
141
+
142
+ if (state.viewMode === "detail" && state.detailRunId && input === "s") {
143
+ const run = state.runs.find((r) => r.runId === state.detailRunId);
144
+ if (run) {
145
+ setSaveNotice(null);
146
+ setSaveDialog({
147
+ name: run.meta.name,
148
+ scope: "project",
149
+ overwrite: false,
150
+ });
151
+ }
152
+ return;
153
+ }
154
+
86
155
  dispatch({ type: "HANDLE_KEY", input, key });
87
156
  });
88
157
 
@@ -94,10 +163,6 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
94
163
  return null;
95
164
  }
96
165
 
97
- const elapsed = run.endTime
98
- ? run.endTime - run.startTime
99
- : Date.now() - run.startTime;
100
-
101
166
  return (
102
167
  <Box
103
168
  flexDirection="column"
@@ -115,79 +180,27 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
115
180
  </Text>
116
181
  </Box>
117
182
 
118
- <Box flexDirection="column" gap={0}>
119
- <Box>
120
- <Text>
121
- <Text color="blue">Run ID:</Text> {run.runId}
122
- </Text>
123
- </Box>
124
- {run.meta.description && (
125
- <Box>
126
- <Text>
127
- <Text color="blue">Description:</Text> {run.meta.description}
128
- </Text>
129
- </Box>
130
- )}
131
- <Box>
132
- <Text>
133
- <Text color="blue">Status:</Text>{" "}
134
- <Text color={statusColor(run.status)}>{run.status}</Text>
135
- </Text>
136
- </Box>
137
- <Box>
138
- <Text>
139
- <Text color="blue">Started:</Text> {formatTime(run.startTime)}
140
- {run.endTime && (
141
- <Text>
142
- {" "}
143
- | <Text color="blue">Ended:</Text> {formatTime(run.endTime)}
144
- </Text>
145
- )}
146
- {" | "}
147
- <Text color="blue">Duration:</Text> {formatDuration(elapsed)}
148
- </Text>
149
- </Box>
150
- <Box>
151
- <Text>
152
- <Text color="blue">Agents:</Text> {run.totalAgents} {" | "}
153
- <Text color="blue">Tokens:</Text> {formatTokens(run.totalTokens)}
154
- </Text>
155
- </Box>
156
- <Box>
157
- <Text>
158
- <Text color="blue">Script:</Text> {run.scriptPath}
159
- </Text>
160
- </Box>
161
- {run.error && (
162
- <Box>
163
- <Text color="red">
164
- <Text color="blue">Error:</Text> {run.error}
165
- </Text>
166
- </Box>
167
- )}
168
- </Box>
183
+ <WorkflowRunDetail run={run} />
169
184
 
170
- {run.phases.length > 0 && (
171
- <Box flexDirection="column" marginTop={1}>
172
- <Text color="cyan" bold>
173
- Phases:
185
+ {saveDialog && (
186
+ <Box flexDirection="column">
187
+ <Text color="cyan">Save dynamic workflow</Text>
188
+ <Text>
189
+ Name: {saveDialog.name}
190
+ <Text color="cyan">▏</Text>
174
191
  </Text>
175
- {run.phases.map((phase, i) => (
176
- <Box key={i} marginLeft={2}>
177
- <Text>
178
- <Text color="blue">{phase.title}</Text>
179
- {" — "}
180
- {phase.agentCount} agents | {formatTokens(phase.tokens)}{" "}
181
- tokens | {formatDuration(phase.elapsed)}
182
- </Text>
183
- </Box>
184
- ))}
192
+ <Text dimColor>Scope: {saveDialog.scope} · Tab to switch</Text>
193
+ {saveDialog.error && <Text color="red">{saveDialog.error}</Text>}
185
194
  </Box>
186
195
  )}
187
196
 
197
+ {saveNotice && <Text color="green">{saveNotice}</Text>}
198
+
188
199
  <Box marginTop={1}>
189
200
  <Text dimColor>
190
- {run.status === "running" ? "k to stop · " : ""}Esc back
201
+ {saveDialog
202
+ ? "Enter save · Tab scope · Esc cancel"
203
+ : `${run.status === "running" ? "k to stop · " : ""}s save script · Esc back`}
191
204
  </Text>
192
205
  </Box>
193
206
  </Box>
@@ -225,6 +238,17 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
225
238
  );
226
239
  const visibleRuns = runs.slice(startIndex, startIndex + MAX_VISIBLE_ITEMS);
227
240
 
241
+ // Header counts of the states a user acts on (a failed run is plain to see in
242
+ // its own row, so it needs no tally).
243
+ const runningCount = runs.filter((r) => r.status === "running").length;
244
+ const completedCount = runs.filter((r) => r.status === "completed").length;
245
+ const subtitle = [
246
+ runningCount > 0 ? `${runningCount} running` : "",
247
+ completedCount > 0 ? `${completedCount} completed` : "",
248
+ ]
249
+ .filter(Boolean)
250
+ .join(" · ");
251
+
228
252
  return (
229
253
  <Box
230
254
  flexDirection="column"
@@ -240,6 +264,7 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
240
264
  <Text color="cyan" bold>
241
265
  Workflows
242
266
  </Text>
267
+ {subtitle && <Text dimColor> {subtitle}</Text>}
243
268
  </Box>
244
269
  <Text dimColor>Select a run to view details</Text>
245
270
 
@@ -250,7 +275,15 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
250
275
  const elapsed = run.endTime
251
276
  ? run.endTime - run.startTime
252
277
  : Date.now() - run.startTime;
253
- const phaseText = run.phases.map((p) => p.title).join(" → ");
278
+ // Duration always reads; the two counters are dropped at zero so an
279
+ // unstarted run does not claim "0 agents · 0 tokens".
280
+ const meta = [
281
+ formatDuration(elapsed),
282
+ run.totalAgents > 0 ? `${run.totalAgents} agents` : "",
283
+ run.totalTokens > 0
284
+ ? `${formatTokens(run.totalTokens)} tokens`
285
+ : "",
286
+ ].filter(Boolean);
254
287
 
255
288
  return (
256
289
  <Box key={run.runId} flexDirection="column">
@@ -259,43 +292,24 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
259
292
  backgroundColor={isSelected ? "cyan" : undefined}
260
293
  >
261
294
  {isSelected ? "▶ " : " "}
262
- {actualIndex + 1}. {run.runId.slice(0, 8)} {run.meta.name}
263
- <Text color={statusColor(run.status)}> ({run.status})</Text>
295
+ {actualIndex + 1}. {run.meta.name || run.meta.description}
296
+ <Text color={workflowStatusColor(run.status)}>
297
+ {" "}
298
+ ({run.status})
299
+ </Text>
300
+ {/* Oversized marker: the row is where a run is picked, so the
301
+ "too big to leave running" signal belongs on it (spec
302
+ 「预算与规模硬上限」场景 7). The counts are on the meta line
303
+ right below, and the stop key is in the footer. */}
304
+ {run.sizeWarning && <Text color="yellow"> ⚠ Large</Text>}
264
305
  </Text>
265
306
  <Text
266
307
  color={isSelected ? "black" : "gray"}
267
308
  backgroundColor={isSelected ? "cyan" : undefined}
268
309
  >
269
310
  {" "}
270
- {run.totalAgents} agents · {formatTokens(run.totalTokens)}{" "}
271
- tokens · {formatDuration(elapsed)}
311
+ {meta.join(" · ")}
272
312
  </Text>
273
- {phaseText && (
274
- <Text
275
- color={isSelected ? "black" : "gray"}
276
- backgroundColor={isSelected ? "cyan" : undefined}
277
- >
278
- {" "}
279
- {run.phases.map((p, pi) => (
280
- <React.Fragment key={pi}>
281
- {pi > 0 && " → "}
282
- <Text
283
- color={
284
- isSelected
285
- ? "black"
286
- : pi === run.phases.length - 1 &&
287
- run.status === "running"
288
- ? "green"
289
- : undefined
290
- }
291
- backgroundColor={isSelected ? "cyan" : undefined}
292
- >
293
- {p.title}
294
- </Text>
295
- </React.Fragment>
296
- ))}
297
- </Text>
298
- )}
299
313
  {isSelected && (
300
314
  <Box marginLeft={4} flexDirection="column">
301
315
  <Text color="gray" dimColor>
@@ -0,0 +1,193 @@
1
+ /**
2
+ * WorkflowRunDetail - one workflow run's own fields, rendered for the terminal.
3
+ *
4
+ * Shared by the two CLI panels that inspect a run: the `/workflows` detail view,
5
+ * and the `/tasks` detail view when the selected task is a workflow (a workflow
6
+ * background task's id IS its run id, so the two panels join on it). Both render
7
+ * this component, so they cannot disagree about what a run's progress looks like.
8
+ *
9
+ * The information set matches what the webview renders through
10
+ * `WorkflowRunProgress` — same fields, same order, same zero-value rules — but
11
+ * the carrier differs: Ink text lines instead of a DOM block, and the log lines
12
+ * are capped because an Ink panel does not scroll. That split is the spec's
13
+ * cross-renderer rule (docs/specs/multi-agent/workflow.md 边界情况).
14
+ */
15
+
16
+ import React from "react";
17
+ import { Box, Text } from "ink";
18
+ import { getLastLines } from "wave-agent-sdk";
19
+ import type { WorkflowRun } from "wave-agent-sdk";
20
+ import {
21
+ WORKFLOW_SIZE_WARNING_AGENTS,
22
+ WORKFLOW_SIZE_WARNING_TOKENS,
23
+ } from "wave-agent-sdk/constants";
24
+
25
+ export const formatDuration = (ms: number): string => {
26
+ if (ms < 1000) return `${ms}ms`;
27
+ if (ms < 60000) return `${Math.round(ms / 1000)}s`;
28
+ const minutes = Math.floor(ms / 60000);
29
+ const seconds = Math.round((ms % 60000) / 1000);
30
+ return `${minutes}m ${seconds}s`;
31
+ };
32
+
33
+ export const formatTime = (timestamp: number): string => {
34
+ return new Date(timestamp).toLocaleTimeString();
35
+ };
36
+
37
+ export const formatTokens = (tokens: number): string => {
38
+ if (tokens < 1000) return `${tokens}`;
39
+ // Millions matter here: a run crosses the 1.5M size threshold already showing
40
+ // "1500.0k", and the marker line names that threshold.
41
+ if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(1)}k`;
42
+ return `${(tokens / 1_000_000).toFixed(1)}M`;
43
+ };
44
+
45
+ export const workflowStatusColor = (
46
+ status: WorkflowRun["status"],
47
+ ): "green" | "blue" | "red" | "yellow" => {
48
+ switch (status) {
49
+ case "running":
50
+ return "green";
51
+ case "completed":
52
+ return "blue";
53
+ case "failed":
54
+ case "aborted":
55
+ return "red";
56
+ case "paused":
57
+ return "yellow";
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Log lines rendered. Same convention as this panel's OUTPUT block ("last 10
63
+ * lines"): a TUI panel cannot scroll, and a workflow that logs inside a loop can
64
+ * hold hundreds of retained lines.
65
+ */
66
+ export const MAX_RUN_LOG_LINES = 10;
67
+
68
+ const WorkflowRunDetail: React.FC<{ run: WorkflowRun }> = ({ run }) => {
69
+ const elapsed = run.endTime
70
+ ? run.endTime - run.startTime
71
+ : Date.now() - run.startTime;
72
+ const logs = run.logs ?? [];
73
+ const failures = run.failures ?? [];
74
+
75
+ return (
76
+ <>
77
+ <Box flexDirection="column" gap={0}>
78
+ <Box>
79
+ <Text>
80
+ <Text color="blue">Run ID:</Text> {run.runId}
81
+ </Text>
82
+ </Box>
83
+ {run.meta.description && (
84
+ <Box>
85
+ <Text>
86
+ <Text color="blue">Description:</Text> {run.meta.description}
87
+ </Text>
88
+ </Box>
89
+ )}
90
+ <Box>
91
+ <Text>
92
+ <Text color="blue">Status:</Text>{" "}
93
+ <Text color={workflowStatusColor(run.status)}>{run.status}</Text>
94
+ </Text>
95
+ </Box>
96
+ <Box>
97
+ <Text>
98
+ <Text color="blue">Started:</Text> {formatTime(run.startTime)}
99
+ {run.endTime && (
100
+ <Text>
101
+ {" "}
102
+ | <Text color="blue">Ended:</Text> {formatTime(run.endTime)}
103
+ </Text>
104
+ )}
105
+ {" | "}
106
+ <Text color="blue">Duration:</Text> {formatDuration(elapsed)}
107
+ </Text>
108
+ </Box>
109
+ <Box>
110
+ <Text>
111
+ <Text color="blue">Agents:</Text> {run.totalAgents} {" | "}
112
+ <Text color="blue">Tokens:</Text> {formatTokens(run.totalTokens)}
113
+ </Text>
114
+ </Box>
115
+ {/* Oversized marker (spec「预算与规模硬上限」场景 7): the run is bigger
116
+ than the thresholds, which is the human's cue to stop it — the model
117
+ is never told, so this line has to carry the scale and the
118
+ thresholds itself. Stopping stays with this panel's own affordance
119
+ (the footer's `k`), so the line only names it when the run is still
120
+ running and there is something to stop. */}
121
+ {run.sizeWarning && (
122
+ <Box>
123
+ <Text color="yellow">
124
+ ⚠ Large run: {run.totalAgents} agents /{" "}
125
+ {formatTokens(run.totalTokens)} tokens — over the{" "}
126
+ {WORKFLOW_SIZE_WARNING_AGENTS} agents /{" "}
127
+ {formatTokens(WORKFLOW_SIZE_WARNING_TOKENS)} tokens threshold.
128
+ {run.status === "running" && " Press k to stop it."}
129
+ </Text>
130
+ </Box>
131
+ )}
132
+ <Box>
133
+ <Text>
134
+ <Text color="blue">Script:</Text> {run.scriptPath}
135
+ </Text>
136
+ </Box>
137
+ {run.error && (
138
+ <Box>
139
+ <Text color="red">
140
+ <Text color="blue">Error:</Text> {run.error}
141
+ </Text>
142
+ </Box>
143
+ )}
144
+ </Box>
145
+
146
+ {failures.length > 0 && (
147
+ <Box flexDirection="column" marginTop={1}>
148
+ <Text color="red" bold>
149
+ Failures:
150
+ </Text>
151
+ {failures.map((failure, i) => (
152
+ <Box key={i} marginLeft={2}>
153
+ <Text color="red">{failure}</Text>
154
+ </Box>
155
+ ))}
156
+ </Box>
157
+ )}
158
+
159
+ {/* Only render a section that has content: an empty "Phases:" / "Logs:"
160
+ block reads as missing data rather than "nothing happened yet". */}
161
+ {run.phases.length > 0 && (
162
+ <Box flexDirection="column" marginTop={1}>
163
+ <Text color="cyan" bold>
164
+ Phases:
165
+ </Text>
166
+ {run.phases.map((phase, i) => (
167
+ <Box key={i} marginLeft={2}>
168
+ <Text>
169
+ <Text color="blue">{phase.title}</Text>
170
+ {" — "}
171
+ {phase.agentCount} agents | {formatTokens(phase.tokens)} tokens
172
+ | {formatDuration(phase.elapsed)}
173
+ </Text>
174
+ </Box>
175
+ ))}
176
+ </Box>
177
+ )}
178
+
179
+ {logs.length > 0 && (
180
+ <Box flexDirection="column" marginTop={1}>
181
+ <Text color="cyan" bold>
182
+ Logs (last {MAX_RUN_LOG_LINES} lines):
183
+ </Text>
184
+ <Box marginLeft={2}>
185
+ <Text>{getLastLines(logs.join("\n"), MAX_RUN_LOG_LINES)}</Text>
186
+ </Box>
187
+ </Box>
188
+ )}
189
+ </>
190
+ );
191
+ };
192
+
193
+ export default WorkflowRunDetail;
@@ -110,6 +110,12 @@ export const AVAILABLE_COMMANDS: SlashCommand[] = [
110
110
  description: "Set a custom title for the current conversation",
111
111
  handler: () => {}, // Handler here won't be used, actual processing is in the hook
112
112
  },
113
+ {
114
+ id: "remote-control",
115
+ name: "remote-control",
116
+ description: "Serve this terminal's session to a phone",
117
+ handler: () => {}, // Handler here won't be used, actual processing is in the hook
118
+ },
113
119
  {
114
120
  id: "clear",
115
121
  name: "clear",