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.
- package/dist/bundle/wave.mjs +713 -842
- package/package.json +7 -7
- package/src/cli.tsx +14 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +22 -15
- package/src/components/ConfirmationSelector.tsx +31 -36
- package/src/components/DiffDisplay.tsx +1 -1
- package/src/components/HooksManager.tsx +3 -3
- package/src/components/InputBox.tsx +37 -13
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/RemoteControlView.tsx +175 -0
- package/src/components/ScrolledContent.tsx +15 -13
- package/src/components/SkillsManager.tsx +3 -3
- package/src/components/TaskList.tsx +3 -3
- package/src/components/ToolDisplay.tsx +3 -18
- package/src/components/WorkflowManager.tsx +117 -3
- package/src/contexts/useChat.tsx +87 -165
- package/src/daemon/commands.ts +18 -18
- package/src/hooks/useInputManager.ts +24 -42
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +23 -1
- package/src/managers/inputHandlers.ts +4 -1
- package/src/managers/inputReducer.ts +39 -1
- package/src/remote/messageWindow.ts +18 -69
- package/src/remote/notificationCoalescer.ts +21 -3
- package/src/remote/relayClient.ts +48 -26
- package/src/remote/remoteControlCliHost.ts +72 -43
- package/src/remote/remoteControlConfig.ts +25 -6
- package/src/remote/remoteControlService.ts +93 -70
- package/src/remote/remoteControlSink.ts +4 -3
- package/src/remote/remoteNotifications.ts +8 -2
- package/src/stdio/agentBridge.ts +325 -91
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/uiPayload.ts +65 -0
- package/src/types.ts +7 -0
- 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
|
|
82
|
-
//
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
19
|
-
*
|
|
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
|
|
26
|
-
* text: by the time
|
|
27
|
-
* terminal width, so the
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
if (!yogaNode) {
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!hasMeasured) {
|
|
41
41
|
return;
|
|
42
42
|
}
|
|
43
|
-
const rows = Math.round(
|
|
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,
|
|
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 {
|
|
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 =
|
|
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,
|
|
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 {
|
|
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(
|
|
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
|
-
{
|
|
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>
|