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.
- package/dist/bundle/wave.mjs +1382 -1131
- package/package.json +9 -7
- package/src/cli.tsx +14 -22
- package/src/commands/update.ts +62 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +34 -13
- 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 -11
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/MessageBlockItem.tsx +0 -6
- 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 +150 -136
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +308 -154
- package/src/daemon/commands.ts +31 -22
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +25 -39
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +346 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +5 -0
- package/src/managers/inputReducer.ts +39 -0
- package/src/print-cli.ts +5 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +46 -0
- package/src/remote/notificationCoalescer.ts +192 -0
- package/src/remote/relayClient.ts +690 -0
- package/src/remote/remoteControlCliHost.ts +420 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +353 -0
- package/src/remote/remoteControlService.ts +822 -0
- package/src/remote/remoteControlSink.ts +78 -0
- package/src/remote/remoteNotifications.ts +200 -0
- package/src/stdio/agentBridge.ts +874 -106
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -115
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio/uiPayload.ts +65 -0
- package/src/stdio-cli.ts +11 -2
- package/src/types.ts +7 -0
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/utils/bracketedPaste.ts +0 -170
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import React, { useEffect, useReducer
|
|
2
|
-
import { Box, Text, useInput } from "ink";
|
|
1
|
+
import React, { useEffect, useReducer } from "react";
|
|
2
|
+
import { Box, Text, useInput, usePaste } from "ink";
|
|
3
|
+
import type { Key } from "ink";
|
|
3
4
|
import type {
|
|
4
5
|
PermissionDecision,
|
|
5
6
|
PermissionMode,
|
|
@@ -15,7 +16,6 @@ import {
|
|
|
15
16
|
} from "wave-agent-sdk";
|
|
16
17
|
import { confirmationReducer } from "../reducers/confirmationReducer.js";
|
|
17
18
|
import { questionReducer } from "../reducers/questionReducer.js";
|
|
18
|
-
import { createBracketedPasteDetector } from "../utils/bracketedPaste.js";
|
|
19
19
|
|
|
20
20
|
const getHeaderColor = (header: string) => {
|
|
21
21
|
const colors = ["red", "green", "blue", "magenta", "cyan"] as const;
|
|
@@ -124,46 +124,41 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
|
|
|
124
124
|
outsideSafeZoneDirectory ?? "",
|
|
125
125
|
)}/ this session`;
|
|
126
126
|
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
useInput((input, key) => {
|
|
130
|
-
if (key.escape) {
|
|
131
|
-
onCancel();
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const result = pasteDetectorRef.current.process(input);
|
|
136
|
-
if (result.kind === "consume") {
|
|
137
|
-
// Content of an in-flight bracketed paste: hold it, never submit.
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
let cleanInput: string;
|
|
141
|
-
if (result.kind === "paste") {
|
|
142
|
-
cleanInput = (result.leadingInput ?? "") + result.text;
|
|
143
|
-
} else {
|
|
144
|
-
cleanInput = result.input;
|
|
145
|
-
}
|
|
146
|
-
|
|
127
|
+
const dispatchInput = (input: string, key: Key) => {
|
|
147
128
|
if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
|
|
148
129
|
questionDispatch({
|
|
149
130
|
type: "HANDLE_KEY",
|
|
150
|
-
input
|
|
131
|
+
input,
|
|
151
132
|
key,
|
|
152
133
|
questions,
|
|
153
134
|
});
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
dispatch({
|
|
138
|
+
type: "HANDLE_KEY",
|
|
139
|
+
input,
|
|
140
|
+
key,
|
|
141
|
+
toolName,
|
|
142
|
+
toolInput,
|
|
143
|
+
suggestedPrefix,
|
|
144
|
+
hidePersistentOption,
|
|
145
|
+
outsideSafeZoneDirectory,
|
|
146
|
+
permissionMode,
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
useInput((input, key) => {
|
|
151
|
+
if (key.escape) {
|
|
152
|
+
onCancel();
|
|
153
|
+
return;
|
|
166
154
|
}
|
|
155
|
+
dispatchInput(input, key);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// A pasted string arrives here as one chunk with no key flags, so it can fill
|
|
159
|
+
// an answer/instruction field but never confirm the decision itself.
|
|
160
|
+
usePaste((text) => {
|
|
161
|
+
dispatchInput(text, {} as Key);
|
|
167
162
|
});
|
|
168
163
|
|
|
169
164
|
const placeholderText = "Type here to tell Wave what to change";
|
|
@@ -385,7 +385,7 @@ export const DiffDisplay = ({
|
|
|
385
385
|
|
|
386
386
|
return (
|
|
387
387
|
<Box flexDirection="column">
|
|
388
|
-
<Box paddingLeft={2}
|
|
388
|
+
<Box paddingLeft={2} flexDirection="column">
|
|
389
389
|
<Box flexDirection="column">{diffLines}</Box>
|
|
390
390
|
</Box>
|
|
391
391
|
</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 {
|
|
4
4
|
HOOK_EVENT_SUMMARIES,
|
|
5
5
|
type HookEvent,
|
|
@@ -60,7 +60,7 @@ export const HooksManager: React.FC<HooksManagerProps> = ({
|
|
|
60
60
|
hooks,
|
|
61
61
|
}) => {
|
|
62
62
|
const [state, dispatch] = useReducer(hooksManagerReducer, initialState);
|
|
63
|
-
const {
|
|
63
|
+
const { rows } = useWindowSize();
|
|
64
64
|
|
|
65
65
|
// Handle pending effects
|
|
66
66
|
useEffect(() => {
|
|
@@ -125,7 +125,7 @@ export const HooksManager: React.FC<HooksManagerProps> = ({
|
|
|
125
125
|
// Window slice: center the selected item within the visible area, clamping
|
|
126
126
|
// to the terminal's available rows (reusable pattern from
|
|
127
127
|
// AgentsManager/SkillsManager).
|
|
128
|
-
const availableRows =
|
|
128
|
+
const availableRows = rows || 24;
|
|
129
129
|
const maxVisible = Math.max(3, Math.min(15, availableRows - 12));
|
|
130
130
|
const selectedFlatIndex = entries.findIndex(
|
|
131
131
|
(e) => e.selectableIndex === state.selectedIndex,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useCallback } from "react";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
-
import { useInput } from "ink";
|
|
3
|
+
import { useInput, usePaste } from "ink";
|
|
4
4
|
import { FileSelector } from "./FileSelector.js";
|
|
5
5
|
import { CommandSelector } from "./CommandSelector.js";
|
|
6
6
|
import { HistorySearch } from "./HistorySearch.js";
|
|
@@ -13,6 +13,7 @@ import { RewindCommand } from "./RewindCommand.js";
|
|
|
13
13
|
import { ResumeCommand } from "./ResumeCommand.js";
|
|
14
14
|
import { HelpView } from "./HelpView.js";
|
|
15
15
|
import { StatusCommand } from "./StatusCommand.js";
|
|
16
|
+
import { RemoteControlView } from "./RemoteControlView.js";
|
|
16
17
|
import { LoginCommand } from "./LoginCommand.js";
|
|
17
18
|
import { PluginManagerShell } from "./PluginManagerShell.js";
|
|
18
19
|
import { ModelSelector } from "./ModelSelector.js";
|
|
@@ -161,6 +162,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
161
162
|
showResumeSelector,
|
|
162
163
|
showHelp,
|
|
163
164
|
showStatusCommand,
|
|
165
|
+
showRemoteControl,
|
|
164
166
|
showLoginCommand,
|
|
165
167
|
showPluginManager,
|
|
166
168
|
showModelSelector,
|
|
@@ -174,6 +176,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
174
176
|
setShowResumeSelector,
|
|
175
177
|
setShowHelp,
|
|
176
178
|
setShowStatusCommand,
|
|
179
|
+
setShowRemoteControl,
|
|
177
180
|
setShowLoginCommand,
|
|
178
181
|
setShowPluginManager,
|
|
179
182
|
setShowModelSelector,
|
|
@@ -189,6 +192,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
189
192
|
escClearPending,
|
|
190
193
|
// Main handler
|
|
191
194
|
handleInput,
|
|
195
|
+
insertPastedText,
|
|
192
196
|
// Manager ready state
|
|
193
197
|
isManagerReady,
|
|
194
198
|
// Direct state setters
|
|
@@ -231,13 +235,11 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
231
235
|
setPermissionMode(chatPermissionMode);
|
|
232
236
|
}, [chatPermissionMode, setPermissionMode]);
|
|
233
237
|
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (
|
|
240
|
-
planView ||
|
|
238
|
+
// These views have their own useInput handlers that handle escape and
|
|
239
|
+
// navigation. While one is open it owns the keyboard: InputBox must skip its
|
|
240
|
+
// own handling to avoid duplicate dispatches or state update conflicts.
|
|
241
|
+
const overlayOwnsKeyboard = Boolean(
|
|
242
|
+
planView ||
|
|
241
243
|
showRewindManager ||
|
|
242
244
|
showResumeSelector ||
|
|
243
245
|
showHelp ||
|
|
@@ -250,13 +252,32 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
250
252
|
showAgentsManager ||
|
|
251
253
|
showWorkflowManager ||
|
|
252
254
|
showSkillsManager ||
|
|
253
|
-
showHooksManager
|
|
254
|
-
|
|
255
|
+
showHooksManager ||
|
|
256
|
+
showRemoteControl,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// Use the InputManager's unified input handler
|
|
260
|
+
useInput(async (input, key) => {
|
|
261
|
+
if (overlayOwnsKeyboard) {
|
|
255
262
|
return;
|
|
256
263
|
}
|
|
257
264
|
await handleInput(input, key);
|
|
258
265
|
});
|
|
259
266
|
|
|
267
|
+
// ink enables bracketed paste mode (DECSET 2004) while this hook is active and
|
|
268
|
+
// delivers the whole paste as one string here instead of through useInput, so
|
|
269
|
+
// its newlines are inserted rather than submitted. Deactivating it while an
|
|
270
|
+
// overlay owns the keyboard hands pasted text back to ink's `useInput`
|
|
271
|
+
// fallback, where the overlay's own handler sees it — text-entry overlays
|
|
272
|
+
// (login token, plugin source form, confirmation answer) additionally keep a
|
|
273
|
+
// `usePaste` of their own, which gets the empty-key chunk directly.
|
|
274
|
+
usePaste(
|
|
275
|
+
(text) => {
|
|
276
|
+
insertPastedText(text);
|
|
277
|
+
},
|
|
278
|
+
{ isActive: !overlayOwnsKeyboard },
|
|
279
|
+
);
|
|
280
|
+
|
|
260
281
|
const handleRewindCancel = () => {
|
|
261
282
|
if (setShowRewindManager) {
|
|
262
283
|
setShowRewindManager(false);
|
|
@@ -408,6 +429,10 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
408
429
|
<StatusCommand onCancel={() => setShowStatusCommand(false)} />
|
|
409
430
|
)}
|
|
410
431
|
|
|
432
|
+
{showRemoteControl && (
|
|
433
|
+
<RemoteControlView onCancel={() => setShowRemoteControl(false)} />
|
|
434
|
+
)}
|
|
435
|
+
|
|
411
436
|
{showLoginCommand && (
|
|
412
437
|
<LoginCommand onCancel={() => setShowLoginCommand(false)} />
|
|
413
438
|
)}
|
|
@@ -453,7 +478,8 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
453
478
|
showLoginCommand ||
|
|
454
479
|
showPluginManager ||
|
|
455
480
|
showModelSelector ||
|
|
456
|
-
showWorkflowManager ||
|
|
481
|
+
showWorkflowManager ||
|
|
482
|
+
showRemoteControl || (
|
|
457
483
|
<Box flexDirection="column">
|
|
458
484
|
{escClearPending && (
|
|
459
485
|
<Text color="gray">Press Esc again to clear input</Text>
|
|
@@ -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;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import type { Message, MessageBlock } from "wave-agent-sdk";
|
|
4
|
-
import { MessageSource } from "wave-agent-sdk";
|
|
5
4
|
import { ToolDisplay } from "./ToolDisplay.js";
|
|
6
5
|
import { CompactDisplay } from "./CompactDisplay.js";
|
|
7
6
|
import { ReasoningDisplay } from "./ReasoningDisplay.js";
|
|
@@ -25,11 +24,6 @@ export const MessageBlockItem = ({
|
|
|
25
24
|
<Box flexDirection="column" paddingTop={paddingTop}>
|
|
26
25
|
{block.type === "text" && block.content.trim() && (
|
|
27
26
|
<Box>
|
|
28
|
-
{block.source === MessageSource.HOOK && (
|
|
29
|
-
<Text color="magenta" bold>
|
|
30
|
-
~{" "}
|
|
31
|
-
</Text>
|
|
32
|
-
)}
|
|
33
27
|
{message.role === "user" || isExpanded ? (
|
|
34
28
|
<Text
|
|
35
29
|
backgroundColor={message.role === "user" ? "gray" : undefined}
|
|
@@ -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>
|