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,6 @@
1
- import React, { useEffect, useReducer, useRef } from "react";
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 pasteDetectorRef = useRef(createBracketedPasteDetector());
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: cleanInput,
131
+ input,
151
132
  key,
152
133
  questions,
153
134
  });
154
- } else {
155
- dispatch({
156
- type: "HANDLE_KEY",
157
- input: cleanInput,
158
- key,
159
- toolName,
160
- toolInput,
161
- suggestedPrefix,
162
- hidePersistentOption,
163
- outsideSafeZoneDirectory,
164
- permissionMode,
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} borderLeft borderColor="cyan" flexDirection="column">
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, useStdout } from "ink";
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 { stdout } = useStdout();
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 = stdout?.rows ?? 24;
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
- // Use the InputManager's unified input handler
235
- useInput(async (input, key) => {
236
- // These views have their own useInput handlers that handle escape and navigation.
237
- // If they are active, we should skip InputBox's global input handling to avoid
238
- // duplicate dispatches or state update conflicts.
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 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;
@@ -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, { 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>