wave-code 1.4.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 (41) hide show
  1. package/dist/bundle/wave.mjs +713 -842
  2. package/package.json +7 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/components/AgentsManager.tsx +3 -3
  5. package/src/components/App.tsx +5 -0
  6. package/src/components/BtwDisplay.tsx +71 -15
  7. package/src/components/CompactDisplay.tsx +1 -6
  8. package/src/components/ConfirmationDetails.tsx +22 -15
  9. package/src/components/ConfirmationSelector.tsx +31 -36
  10. package/src/components/DiffDisplay.tsx +1 -1
  11. package/src/components/HooksManager.tsx +3 -3
  12. package/src/components/InputBox.tsx +37 -13
  13. package/src/components/LoginCommand.tsx +16 -35
  14. package/src/components/Markdown.tsx +7 -3
  15. package/src/components/MarketplaceAddForm.tsx +15 -19
  16. package/src/components/RemoteControlView.tsx +175 -0
  17. package/src/components/ScrolledContent.tsx +15 -13
  18. package/src/components/SkillsManager.tsx +3 -3
  19. package/src/components/TaskList.tsx +3 -3
  20. package/src/components/ToolDisplay.tsx +3 -18
  21. package/src/components/WorkflowManager.tsx +117 -3
  22. package/src/contexts/useChat.tsx +87 -165
  23. package/src/daemon/commands.ts +18 -18
  24. package/src/hooks/useInputManager.ts +24 -42
  25. package/src/hooks/useLineScroll.ts +51 -5
  26. package/src/hooks/useRemoteControl.ts +23 -1
  27. package/src/managers/inputHandlers.ts +4 -1
  28. package/src/managers/inputReducer.ts +39 -1
  29. package/src/remote/messageWindow.ts +18 -69
  30. package/src/remote/notificationCoalescer.ts +21 -3
  31. package/src/remote/relayClient.ts +48 -26
  32. package/src/remote/remoteControlCliHost.ts +72 -43
  33. package/src/remote/remoteControlConfig.ts +25 -6
  34. package/src/remote/remoteControlService.ts +93 -70
  35. package/src/remote/remoteControlSink.ts +4 -3
  36. package/src/remote/remoteNotifications.ts +8 -2
  37. package/src/stdio/agentBridge.ts +325 -91
  38. package/src/stdio/hostSlashCommands.ts +209 -0
  39. package/src/stdio/uiPayload.ts +65 -0
  40. package/src/types.ts +7 -0
  41. package/src/utils/bracketedPaste.ts +0 -170
@@ -1,9 +1,8 @@
1
1
  import React, { useRef, useState } from "react";
2
- import { Box, Text, useInput } from "ink";
2
+ import { Box, Text, useInput, usePaste } from "ink";
3
3
  import { execFile } from "child_process";
4
4
  import { promisify } from "util";
5
5
  import { authService } from "wave-agent-sdk";
6
- import { createBracketedPasteDetector } from "../utils/bracketedPaste.js";
7
6
 
8
7
  const execFileAsync = promisify(execFile);
9
8
 
@@ -33,12 +32,6 @@ export const LoginCommand: React.FC<LoginCommandProps> = ({ onCancel }) => {
33
32
  const isLoadingRef = useRef(isLoading);
34
33
  isLoadingRef.current = isLoading;
35
34
 
36
- // Detects and strips bracketed paste markers (\x1b[200~ ... \x1b[201~)
37
- // that terminals wrap pasted text in. Unlike the main InputBox pipeline,
38
- // this overlay's token input goes through raw ink useInput, which only
39
- // strips ONE leading ESC, leaving the markers (e.g. "[200~") in the input.
40
- const pasteDetectorRef = useRef(createBracketedPasteDetector());
41
-
42
35
  // Resolve/reject refs for the token promise
43
36
  const tokenResolveRef = useRef<((token: string) => void) | null>(null);
44
37
  const tokenRejectRef = useRef<((err: Error) => void) | null>(null);
@@ -78,34 +71,23 @@ export const LoginCommand: React.FC<LoginCommandProps> = ({ onCancel }) => {
78
71
  setTokenInput((prev) => prev.slice(0, -1));
79
72
  return;
80
73
  }
81
- // Regular character input (single or pasted multi-char). Run through the
82
- // bracketed paste detector: a pasted token arrives wrapped in
83
- // \x1b[200~ ... \x1b[201~ markers (possibly split across chunks), which
84
- // must be stripped instead of being appended to the token.
74
+ // Regular character input (single keypress). A pasted token arrives on
75
+ // ink's paste channel instead (see usePaste below).
85
76
  if (input && !key.ctrl && !key.meta && !key.return && input.length > 0) {
86
- const result = pasteDetectorRef.current.process(input);
87
-
88
- if (result.kind === "consume") {
89
- // In-flight bracketed paste content: hold it; the final chunk
90
- // delivers the complete text.
91
- return;
92
- }
93
-
94
- if (result.kind === "paste") {
95
- // Tokens never contain carriage returns — drop \r (CRLF terminals
96
- // send \r, not \n, in pasted text).
97
- const leading = result.leadingInput?.replace(/\r/g, "");
98
- if (leading) {
99
- setTokenInput((prev) => prev + leading);
100
- }
101
- const text = result.text.replace(/\r/g, "");
102
- if (text) {
103
- setTokenInput((prev) => prev + text);
104
- }
105
- return;
106
- }
77
+ setTokenInput((prev) => prev + input);
78
+ }
79
+ });
107
80
 
108
- setTokenInput((prev) => prev + result.input);
81
+ // A pasted token arrives as one string here, so its content is appended
82
+ // verbatim instead of being read as keystrokes. Tokens never contain carriage
83
+ // returns — CRLF terminals send \r inside pasted text.
84
+ usePaste((text) => {
85
+ if (!isLoadingRef.current) {
86
+ return;
87
+ }
88
+ const token = text.replace(/\r/g, "");
89
+ if (token) {
90
+ setTokenInput((prev) => prev + token);
109
91
  }
110
92
  });
111
93
 
@@ -125,7 +107,6 @@ export const LoginCommand: React.FC<LoginCommandProps> = ({ onCancel }) => {
125
107
  setError("");
126
108
  setAuthUrl("");
127
109
  setTokenInput("");
128
- pasteDetectorRef.current.reset();
129
110
  setMessage("Starting authentication...");
130
111
 
131
112
  // Promise that resolves when user presses Enter with token input
@@ -1,5 +1,5 @@
1
1
  import React, { useMemo } from "react";
2
- import { Box, Text, useStdout } from "ink";
2
+ import { Box, Text } from "ink";
3
3
  import { Renderer, marked, type Tokens } from "marked";
4
4
  import chalk from "chalk";
5
5
  import { highlightToAnsi } from "../utils/highlightUtils.js";
@@ -155,8 +155,12 @@ export const renderMarkdownToAnsi = (
155
155
 
156
156
  // Markdown component using custom ANSI renderer
157
157
  export const Markdown = React.memo(({ children }: MarkdownProps) => {
158
- const { stdout } = useStdout();
159
- const columns = stdout?.columns ?? 80;
158
+ // Read the width without subscribing: one <Markdown> is mounted per text
159
+ // block (including frozen <Static> ones), and ink's `useWindowSize()` adds a
160
+ // `resize` listener per instance — enough of them trips Node's
161
+ // MaxListenersExceededWarning on stdout. Table widths only need to be right
162
+ // for the frame being rendered, exactly as with ink 7's `stdout.columns`.
163
+ const columns = process.stdout.columns || 80;
160
164
  const ansiContent = useMemo(
161
165
  () => renderMarkdownToAnsi(children, columns),
162
166
  [children, columns],
@@ -1,12 +1,12 @@
1
- import React, { useReducer, useEffect, useRef } from "react";
2
- import { Box, Text, useInput } from "ink";
1
+ import React, { useReducer, useEffect } from "react";
2
+ import { Box, Text, useInput, usePaste } from "ink";
3
+ import type { Key } from "ink";
3
4
  import { usePluginManagerContext } from "../contexts/PluginManagerContext.js";
4
5
  import {
5
6
  marketplaceAddFormReducer,
6
7
  SCOPES,
7
8
  type MarketplaceAddFormState,
8
9
  } from "../reducers/marketplaceAddFormReducer.js";
9
- import { createBracketedPasteDetector } from "../utils/bracketedPaste.js";
10
10
 
11
11
  export const MarketplaceAddForm: React.FC = () => {
12
12
  const { state: ctxState, actions } = usePluginManagerContext();
@@ -33,27 +33,23 @@ export const MarketplaceAddForm: React.FC = () => {
33
33
  dispatch({ type: "CLEAR_PENDING_ACTION" });
34
34
  }, [state.pendingAction, actions]);
35
35
 
36
- const pasteDetectorRef = useRef(createBracketedPasteDetector());
37
-
38
- useInput((input, key) => {
39
- const result = pasteDetectorRef.current.process(input);
40
- if (result.kind === "consume") {
41
- // Content of an in-flight bracketed paste: hold it, never submit.
42
- return;
43
- }
44
- let cleanInput: string;
45
- if (result.kind === "paste") {
46
- cleanInput = (result.leadingInput ?? "") + result.text;
47
- } else {
48
- cleanInput = result.input;
49
- }
50
-
36
+ const dispatchKey = (input: string, key: Key) => {
51
37
  dispatch({
52
38
  type: "HANDLE_KEY",
53
39
  key,
54
- input: cleanInput,
40
+ input,
55
41
  isLoading: ctxState.isLoading,
56
42
  });
43
+ };
44
+
45
+ useInput((input, key) => {
46
+ dispatchKey(input, key);
47
+ });
48
+
49
+ // A pasted source arrives here as one string with no key flags, so it fills
50
+ // the field without stepping the form or submitting it.
51
+ usePaste((text) => {
52
+ dispatchKey(text, {} as Key);
57
53
  });
58
54
 
59
55
  const { source, scopeIndex, step } = state;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * RemoteControlView — `/remote-control` 的覆盖层(把这条前台会话交给手机)。
3
+ *
4
+ * 规格:docs/specs/ui/remote-control.md「前台 CLI 会话也能被手机接管」。
5
+ *
6
+ * **这个斜杠命令不带参数**:没有 `on` / `off` / `status` 子命令,也不收机器名。
7
+ * 判据是「状态自己决定动作」——`off` / `error` 时唯一合法动作是开启,`connecting`
8
+ * / `online` 时唯一合法动作是断开,所以层里只呈现当前那一个动作,而不是把两个
9
+ * 动词写成命令参数让用户先自己算一遍状态。命名归脚本入口
10
+ * `wave remote-control on <name>`(且只在登记那一次生效)。
11
+ *
12
+ * 所有结果(四态、说明、地址、二维码、失败原因)都只回显在这一层里,**一律不进
13
+ * 消息列表**:这块内容要停在屏幕上够久以便掏手机扫码,而状态还会继续变,一条快照
14
+ * 消息既留不住也更新不了。与 GUI 设置页的「远程控制」选项卡同一口径。
15
+ *
16
+ * 状态怎么刷新:链路活在 CLI 进程里,没有推送通道,`useRemoteControl` 自己每秒读
17
+ * 一次快照(`status` 每次都是新对象),本视图读它即随渲染刷新,不另开轮询。
18
+ */
19
+
20
+ import React, { useEffect, useState } from "react";
21
+ import { Box, Text, useInput } from "ink";
22
+ import { toString as qrToString } from "qrcode";
23
+ import type { RemoteControlStatus } from "wave-agent-sdk";
24
+ import { useChat } from "../contexts/useChat.js";
25
+
26
+ export interface RemoteControlViewProps {
27
+ onCancel: () => void;
28
+ }
29
+
30
+ const STATE_LABELS: Record<RemoteControlStatus["state"], string> = {
31
+ off: "Not serving",
32
+ connecting: "Connecting…",
33
+ online: "Serving",
34
+ error: "Failed",
35
+ };
36
+
37
+ /** Colour per state — literal union, so `<Text color>` takes it as-is. */
38
+ function stateColor(state: RemoteControlStatus["state"]) {
39
+ if (state === "online") return "green";
40
+ if (state === "error") return "red";
41
+ if (state === "connecting") return "yellow";
42
+ return "gray";
43
+ }
44
+
45
+ export const RemoteControlView: React.FC<RemoteControlViewProps> = ({
46
+ onCancel,
47
+ }) => {
48
+ const { remoteControl } = useChat();
49
+ const { enabled, status, getSnapshot, connect, disconnect } = remoteControl;
50
+
51
+ // `status` is the per-second tick that makes this view re-render while it is
52
+ // open; the page address lives only on the snapshot (it is not part of
53
+ // `status`), so both are read on every render.
54
+ const { pageUrl } = getSnapshot();
55
+
56
+ const [showQr, setShowQr] = useState(false);
57
+ const [qrText, setQrText] = useState("");
58
+
59
+ // Opening this panel *is* the intent to serve: dial when nothing is running.
60
+ // A link that is already connecting or online is left alone — re-dialling
61
+ // would tear down a working connection (same rule as the GUI tab, whose
62
+ // single button flips on `state === "off" || state === "error"`).
63
+ //
64
+ // The deps are the two callbacks, not the `remoteControl` object: that object
65
+ // is rebuilt on every render, so depending on it would re-run this effect
66
+ // after every state change — which is exactly what made `d` unusable (the
67
+ // disconnect set `off`, the effect saw `off` and dialled again).
68
+ useEffect(() => {
69
+ if (!enabled) return;
70
+ const current = getSnapshot().state;
71
+ if (current === "off" || current === "error") connect();
72
+ }, [enabled, connect, getSnapshot]);
73
+
74
+ // The QR is folded away by default: it is ~35 rows tall, and the panel also
75
+ // has to stay readable when all the user wants is the state.
76
+ useEffect(() => {
77
+ if (!showQr || !pageUrl) {
78
+ setQrText("");
79
+ return;
80
+ }
81
+ let cancelled = false;
82
+ qrToString(pageUrl, { type: "utf8", errorCorrectionLevel: "L" })
83
+ .then((text) => {
84
+ if (!cancelled) setQrText(text);
85
+ })
86
+ .catch(() => {
87
+ if (!cancelled) setQrText("");
88
+ });
89
+ return () => {
90
+ cancelled = true;
91
+ };
92
+ }, [showQr, pageUrl]);
93
+
94
+ const serving = status.state === "connecting" || status.state === "online";
95
+
96
+ useInput((input, key) => {
97
+ if (key.escape || key.return) {
98
+ onCancel();
99
+ return;
100
+ }
101
+ if (input === " ") {
102
+ setShowQr((prev) => !prev);
103
+ return;
104
+ }
105
+ // One key, and its label is whichever action is legal right now — the layer
106
+ // never asks the user to work out which of two verbs applies.
107
+ if (input === "d") {
108
+ if (serving) disconnect();
109
+ else connect();
110
+ }
111
+ });
112
+
113
+ // The switch is read once per process, so a process started with it off can
114
+ // only be told what to change — it cannot pick the setting up by itself.
115
+ const note = enabled
116
+ ? status.message
117
+ : 'Remote control is unavailable in this process. Set "enableRemoteControl": true in ~/.wave/settings.json (or .wave/settings.json in the project), then restart the CLI — a process that is already running keeps the settings it started with.';
118
+
119
+ const row = (label: string, value: string, color?: string) => (
120
+ <Box>
121
+ <Box width={14}>
122
+ <Text color="yellow">{label}</Text>
123
+ </Box>
124
+ <Box flexShrink={1}>
125
+ <Text color={color} wrap="wrap">
126
+ {value}
127
+ </Text>
128
+ </Box>
129
+ </Box>
130
+ );
131
+
132
+ return (
133
+ <Box
134
+ flexDirection="column"
135
+ borderStyle="round"
136
+ borderColor="cyan"
137
+ borderLeft={false}
138
+ borderRight={false}
139
+ paddingX={1}
140
+ >
141
+ <Box marginBottom={1}>
142
+ <Text color="cyan" bold underline>
143
+ Remote Control
144
+ </Text>
145
+ </Box>
146
+
147
+ {row("Status:", STATE_LABELS[status.state], stateColor(status.state))}
148
+
149
+ {note ? row("Note:", note) : null}
150
+
151
+ {pageUrl ? row("Phone page:", pageUrl) : null}
152
+
153
+ {showQr && qrText ? (
154
+ <Box flexDirection="column" marginTop={1}>
155
+ {qrText
156
+ .split("\n")
157
+ .filter((line) => line.length > 0)
158
+ .map((line, index) => (
159
+ <Text key={index}>{line}</Text>
160
+ ))}
161
+ </Box>
162
+ ) : null}
163
+
164
+ <Box marginTop={1}>
165
+ <Text dimColor>
166
+ {!enabled
167
+ ? "Esc close"
168
+ : serving
169
+ ? "space QR code · d disconnect · Esc close"
170
+ : "space QR code · d connect · Esc close"}
171
+ </Text>
172
+ </Box>
173
+ </Box>
174
+ );
175
+ };
@@ -1,5 +1,5 @@
1
- import React, { useLayoutEffect, useRef } from "react";
2
- import { Box, type DOMElement } from "ink";
1
+ import React, { useEffect, useRef } from "react";
2
+ import { Box, useBoxMetrics, type DOMElement } from "ink";
3
3
 
4
4
  export interface ScrolledContentProps {
5
5
  /** Every content row — rows are never sliced, the viewport clips them. */
@@ -15,16 +15,16 @@ export interface ScrolledContentProps {
15
15
 
16
16
  /**
17
17
  * Hard-bounded, clipping viewport for content that can be wider than the
18
- * terminal: the content is rendered once and translated up by `scrollOffset`
19
- * rows, and rows outside the viewport are clipped away.
18
+ * terminal: the content is rendered once and shifted up by `scrollOffset` rows,
19
+ * and rows outside the viewport are clipped away.
20
20
  *
21
21
  * Height is bounded by construction — a line that wraps grows the content
22
22
  * instead of the frame — so the row budget of the surrounding layout holds no
23
23
  * matter how many rows the content really needs.
24
24
  *
25
- * The row count is read back from the layout rather than predicted from the
26
- * text: by the time this effect runs, Yoga has wrapped the content to the
27
- * terminal width, so the computed height already includes wrapped rows.
25
+ * The row count is read back from the layout (`useBoxMetrics`) rather than
26
+ * predicted from the text: by the time it reports, Yoga has wrapped the content
27
+ * to the terminal width, so the measured height already includes wrapped rows.
28
28
  */
29
29
  export const ScrolledContent: React.FC<ScrolledContentProps> = ({
30
30
  children,
@@ -33,24 +33,27 @@ export const ScrolledContent: React.FC<ScrolledContentProps> = ({
33
33
  onMeasuredRows,
34
34
  }) => {
35
35
  const contentRef = useRef<DOMElement | null>(null);
36
+ const { height, hasMeasured } = useBoxMetrics(contentRef);
36
37
  const reportedRows = useRef(0);
37
38
 
38
- useLayoutEffect(() => {
39
- const yogaNode = contentRef.current?.yogaNode;
40
- if (!yogaNode) {
39
+ useEffect(() => {
40
+ if (!hasMeasured) {
41
41
  return;
42
42
  }
43
- const rows = Math.round(yogaNode.getComputedHeight());
43
+ const rows = Math.round(height);
44
44
  if (rows > 0 && rows !== reportedRows.current) {
45
45
  reportedRows.current = rows;
46
46
  onMeasuredRows(rows);
47
47
  }
48
- });
48
+ }, [height, hasMeasured, onMeasuredRows]);
49
49
 
50
50
  return (
51
51
  <Box
52
52
  maxHeight={visibleRows}
53
53
  overflow="hidden"
54
+ // Children are shifted up by this many rows; `overflow="hidden"` clips
55
+ // whatever the shift pushes out of the viewport.
56
+ contentOffsetY={scrollOffset}
54
57
  flexShrink={0}
55
58
  flexDirection="column"
56
59
  >
@@ -60,7 +63,6 @@ export const ScrolledContent: React.FC<ScrolledContentProps> = ({
60
63
  // Without this the fixed-height parent shrinks the content to fit and
61
64
  // renders garbled rows instead of clipping them.
62
65
  flexShrink={0}
63
- marginTop={-scrollOffset}
64
66
  >
65
67
  {children}
66
68
  </Box>
@@ -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,4 +1,4 @@
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
4
  import {
@@ -15,10 +15,20 @@ export interface WorkflowManagerProps {
15
15
  onCancel: () => void;
16
16
  }
17
17
 
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
+ }
27
+
18
28
  export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
19
29
  onCancel,
20
30
  }) => {
21
- const { workflowRuns, stopWorkflowRun } = useChat();
31
+ const { workflowRuns, stopWorkflowRun, saveWorkflowScript } = useChat();
22
32
  const MAX_VISIBLE_ITEMS = 3;
23
33
 
24
34
  const [state, dispatch] = useReducer(workflowManagerReducer, {
@@ -28,6 +38,8 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
28
38
  detailRunId: null,
29
39
  pendingEffect: null,
30
40
  } as WorkflowManagerState);
41
+ const [saveDialog, setSaveDialog] = useState<SaveDialogState | null>(null);
42
+ const [saveNotice, setSaveNotice] = useState<string | null>(null);
31
43
 
32
44
  // Handle pending effects
33
45
  useEffect(() => {
@@ -53,7 +65,93 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
53
65
  dispatch({ type: "SET_RUNS", runs: workflowRuns });
54
66
  }, [workflowRuns]);
55
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
+
56
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
+
57
155
  dispatch({ type: "HANDLE_KEY", input, key });
58
156
  });
59
157
 
@@ -84,9 +182,25 @@ export const WorkflowManager: React.FC<WorkflowManagerProps> = ({
84
182
 
85
183
  <WorkflowRunDetail run={run} />
86
184
 
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>
191
+ </Text>
192
+ <Text dimColor>Scope: {saveDialog.scope} · Tab to switch</Text>
193
+ {saveDialog.error && <Text color="red">{saveDialog.error}</Text>}
194
+ </Box>
195
+ )}
196
+
197
+ {saveNotice && <Text color="green">{saveNotice}</Text>}
198
+
87
199
  <Box marginTop={1}>
88
200
  <Text dimColor>
89
- {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`}
90
204
  </Text>
91
205
  </Box>
92
206
  </Box>