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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "CLI-based code assistant powered by AI, built with React and Ink",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,13 +28,14 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
+ "@anthropic-ai/sandbox-runtime": "^0.0.81",
31
32
  "@vscode/ripgrep": "^1.18.0",
32
33
  "qrcode": "^1.5.4",
33
34
  "sharp": "^0.35.4"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@types/qrcode": "^1.5.6",
37
- "@types/react": "^19.1.8",
38
+ "@types/react": "^19.3.0",
38
39
  "@types/semver": "^7.7.1",
39
40
  "@types/yargs": "^17.0.0",
40
41
  "@vitest/coverage-v8": "^4.1.7",
@@ -45,12 +46,11 @@
45
46
  "eslint-plugin-react-hooks": "^5.2.0",
46
47
  "glob": "^13.0.0",
47
48
  "highlight.js": "^11.11.1",
48
- "ink": "^7.1.1",
49
- "ink-testing-library": "^4.0.0",
49
+ "ink": "^8.0.0",
50
50
  "marked": "^17.0.2",
51
51
  "node-html-parser": "^7.0.2",
52
- "react": "^19.2.4",
53
- "react-dom": "19.2.4",
52
+ "react": "^19.3.1",
53
+ "react-dom": "19.3.1",
54
54
  "rimraf": "^6.1.2",
55
55
  "semver": "^7.7.4",
56
56
  "string-width": "^8.2.2",
@@ -59,7 +59,7 @@
59
59
  "wrap-ansi": "^10.0.0",
60
60
  "yargs": "^17.7.2",
61
61
  "zod": "^3.23.8",
62
- "wave-agent-sdk": "1.4.0"
62
+ "wave-agent-sdk": "1.5.0"
63
63
  },
64
64
  "engines": {
65
65
  "node": ">=22"
package/src/cli.tsx CHANGED
@@ -41,24 +41,13 @@ export async function startCli(options: CliOptions): Promise<void> {
41
41
  // Continue with ink-based UI for normal mode
42
42
  let shouldRemoveWorktree = false;
43
43
 
44
- // Enable bracketed paste (DECSET 2004) so terminals wrap pasted text in
45
- // \x1b[200~ ... \x1b[201~ markers. The input pipeline uses these to insert
46
- // pasted text without submitting (a pasted trailing \r is not an Enter).
47
- // Terminals without bracketed-paste support ignore the sequence and paste
48
- // without markers, falling back to legacy behavior. No-op when stdout is
49
- // not a TTY (piped input, stdio mode).
50
- const stdoutIsTTY = process.stdout.isTTY === true;
51
- if (stdoutIsTTY) {
52
- process.stdout.write("\x1b[?2004h");
53
- }
54
-
55
44
  const handleExit = (shouldRemove: boolean) => {
56
45
  shouldRemoveWorktree = shouldRemove;
57
46
  unmount();
58
47
  };
59
48
 
60
49
  // Render the application
61
- const { unmount, waitUntilExit } = render(
50
+ const { clear, unmount, waitUntilExit } = render(
62
51
  <App
63
52
  restoreSessionId={restoreSessionId}
64
53
  continueLastSession={continueLastSession}
@@ -75,21 +64,24 @@ export async function startCli(options: CliOptions): Promise<void> {
75
64
  version={version}
76
65
  model={model}
77
66
  mcpServers={mcpServers}
67
+ // Late-bound: the hook is created before `render()` returns the instance
68
+ // whose `clear()` it calls.
69
+ onClearOutput={() => clear()}
78
70
  onExit={handleExit}
79
71
  />,
80
- { exitOnCtrlC: false },
72
+ {
73
+ exitOnCtrlC: false,
74
+ // Redraw only the lines that changed instead of rewriting the whole
75
+ // frame. Measured in a real terminal (tmux, 100x30) while a Bash command
76
+ // streamed 60 lines: 71 KB -> 20 KB written, 733 -> 78 cursor moves, same
77
+ // output on screen. ink falls back to whole-line updates for lines
78
+ // carrying controls it cannot diff around.
79
+ incrementalRendering: true,
80
+ },
81
81
  );
82
82
 
83
83
  // Wait for the app to finish unmounting
84
- try {
85
- await waitUntilExit();
86
- } finally {
87
- // Disable bracketed paste (DECSET 2004) so the terminal returns to its
88
- // previous paste handling.
89
- if (stdoutIsTTY) {
90
- process.stdout.write("\x1b[?2004l");
91
- }
92
- }
84
+ await waitUntilExit();
93
85
 
94
86
  try {
95
87
  // Clean up old log files
@@ -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 { SubagentConfiguration } from "wave-agent-sdk";
4
4
  import { Markdown } from "./Markdown.js";
5
5
  import {
@@ -47,7 +47,7 @@ export const AgentsManager: React.FC<AgentsManagerProps> = ({
47
47
  agentDefinitions,
48
48
  }) => {
49
49
  const [state, dispatch] = useReducer(agentsManagerReducer, initialState);
50
- const { stdout } = useStdout();
50
+ const { rows } = useWindowSize();
51
51
 
52
52
  // Handle pending effects
53
53
  useEffect(() => {
@@ -107,7 +107,7 @@ export const AgentsManager: React.FC<AgentsManagerProps> = ({
107
107
  // Window slice: center the selected item within the visible area, clamping
108
108
  // to the terminal's available rows (reusable pattern from
109
109
  // BackgroundTaskManager).
110
- const availableRows = stdout?.rows ?? 24;
110
+ const availableRows = rows || 24;
111
111
  const maxVisible = Math.max(3, Math.min(15, availableRows - 12));
112
112
  const selectedFlatIndex = entries.findIndex(
113
113
  (e) => e.selectableIndex === state.selectedIndex,
@@ -102,6 +102,7 @@ const AppWithProviders: React.FC<AppWithProvidersProps> = ({
102
102
  version,
103
103
  model,
104
104
  mcpServers,
105
+ onClearOutput,
105
106
  onExit,
106
107
  }) => {
107
108
  // Handle Ctrl-C for non-worktree sessions (immediate exit)
@@ -131,6 +132,7 @@ const AppWithProviders: React.FC<AppWithProvidersProps> = ({
131
132
  version={version}
132
133
  model={model}
133
134
  mcpServers={mcpServers}
135
+ onClearOutput={onClearOutput}
134
136
  >
135
137
  <ChatWithExitPrompt worktreeSession={worktreeSession} onExit={onExit} />
136
138
  </ChatProvider>
@@ -152,6 +154,7 @@ const AppWithProviders: React.FC<AppWithProvidersProps> = ({
152
154
  version={version}
153
155
  model={model}
154
156
  mcpServers={mcpServers}
157
+ onClearOutput={onClearOutput}
155
158
  >
156
159
  <ChatInterface />
157
160
  </ChatProvider>
@@ -174,6 +177,7 @@ export const App: React.FC<AppProps> = ({
174
177
  version,
175
178
  model,
176
179
  mcpServers,
180
+ onClearOutput,
177
181
  onExit,
178
182
  }) => {
179
183
  return (
@@ -195,6 +199,7 @@ export const App: React.FC<AppProps> = ({
195
199
  version={version}
196
200
  model={model}
197
201
  mcpServers={mcpServers}
202
+ onClearOutput={onClearOutput}
198
203
  onExit={onExit}
199
204
  />
200
205
  </AppProvider>
@@ -1,22 +1,75 @@
1
- import React from "react";
2
- import { Box, Text } from "ink";
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useWindowSize } from "ink";
3
3
  import { Markdown } from "./Markdown.js";
4
+ import { ScrolledContent } from "./ScrolledContent.js";
5
+ import { useLineScroll } from "../hooks/useLineScroll.js";
4
6
  import { BtwState } from "../managers/inputReducer.js";
5
7
  import { streamingTail } from "../utils/streamingText.js";
6
8
 
9
+ /** Rows moved by one ↑/↓ press (aligned with Claude Code's btw pager). */
10
+ export const BTW_SCROLL_LINES = 3;
11
+ /**
12
+ * Rows the overlay leaves free inside the terminal so the frame never outgrows
13
+ * it: its own chrome (top margin, question line, content margin, hint = 4) plus
14
+ * the rows around it that must stay on screen (the streaming tail above, and
15
+ * the input box + status line below = 4), plus slack for a wrapped question.
16
+ * Claude Code reserves the same 11 rows (CHROME_ROWS 5 + OUTER_CHROME_ROWS 6).
17
+ */
18
+ export const BTW_CHROME_ROWS = 11;
19
+ /** Smallest usable answer viewport, whatever the terminal size. */
20
+ export const BTW_MIN_VIEWPORT_ROWS = 5;
21
+ /** Footer hint with scrolling enabled — shown only while the answer overflows. */
22
+ export const BTW_SCROLL_HINT = "↑/↓ to scroll · Esc to dismiss";
23
+ /** Footer hint for an answer that fits — unchanged from before scrolling. */
24
+ export const BTW_DISMISS_HINT = "Escape to dismiss";
25
+
7
26
  interface BtwDisplayProps {
8
27
  btwState: BtwState;
28
+ /** Terminal rows. Defaults to the live window size; tests pass it explicitly
29
+ * so the viewport budget is deterministic. */
30
+ rows?: number;
9
31
  }
10
32
 
11
- export const BtwDisplay: React.FC<BtwDisplayProps> = ({ btwState }) => {
33
+ /**
34
+ * `/btw` overlay: the question line, the answer (Markdown, or the loading
35
+ * indicator while it streams) and the dismiss hint.
36
+ *
37
+ * The answer is rendered inside a fixed-height viewport rather than in the
38
+ * frame's natural height, so a long answer cannot push the top of the terminal
39
+ * out of reach: it scrolls with ↑/↓ (and Ctrl+P/Ctrl+N) while it overflows,
40
+ * and the viewport is recomputed from the terminal size on resize. Esc — and
41
+ * only Esc — dismisses the overlay.
42
+ */
43
+ export const BtwDisplay: React.FC<BtwDisplayProps> = ({ btwState, rows }) => {
44
+ const { rows: terminalRows } = useWindowSize();
45
+ const availableRows = rows ?? terminalRows;
46
+ const viewportRows = Math.max(
47
+ BTW_MIN_VIEWPORT_ROWS,
48
+ availableRows - BTW_CHROME_ROWS,
49
+ );
50
+
51
+ const answer = btwState.answer ?? "";
52
+
53
+ // Rows really occupied by the answer (wrapped rows included), measured after
54
+ // layout by <ScrolledContent> — see that component for why it is measured
55
+ // instead of counted from the Markdown source. Zero while the answer is still
56
+ // streaming: the loading line is a single row and has nothing to scroll.
57
+ const [measuredRows, setMeasuredRows] = useState(0);
58
+ const contentRows = btwState.isLoading ? 0 : measuredRows;
59
+
60
+ const { scrollOffset, canScroll } = useLineScroll({
61
+ totalLines: contentRows,
62
+ visibleCount: viewportRows,
63
+ lineStep: BTW_SCROLL_LINES,
64
+ resetKey: btwState.question,
65
+ });
66
+
12
67
  // Rendered for a real question (loading or answered) and for the bare
13
68
  // `/btw` usage message (question === "", answer set).
14
69
  if (!btwState.question && !btwState.answer) {
15
70
  return null;
16
71
  }
17
72
 
18
- const answer = btwState.answer ?? "";
19
-
20
73
  // The SDK surfaces failure as the answer string; classify by prefix so it
21
74
  // renders in error color (aligned with Claude Code's error display).
22
75
  const isError =
@@ -46,20 +99,23 @@ export const BtwDisplay: React.FC<BtwDisplayProps> = ({ btwState }) => {
46
99
  </Text>
47
100
  )}
48
101
  </Box>
49
- ) : isError ? (
50
- <Text color="error">{answer}</Text>
51
102
  ) : (
52
- <Markdown>{answer}</Markdown>
103
+ <ScrolledContent
104
+ visibleRows={viewportRows}
105
+ scrollOffset={scrollOffset}
106
+ onMeasuredRows={setMeasuredRows}
107
+ >
108
+ {isError ? (
109
+ <Text color="error">{answer}</Text>
110
+ ) : (
111
+ <Markdown>{answer}</Markdown>
112
+ )}
113
+ </ScrolledContent>
53
114
  )}
54
115
  </Box>
55
- {btwState.question && btwState.answer && (
56
- <Box marginTop={1}>
57
- <Text dimColor>Escape to dismiss</Text>
58
- </Box>
59
- )}
60
- {!btwState.question && btwState.answer && (
116
+ {btwState.answer && (
61
117
  <Box marginTop={1}>
62
- <Text dimColor>Escape to dismiss</Text>
118
+ <Text dimColor>{canScroll ? BTW_SCROLL_HINT : BTW_DISMISS_HINT}</Text>
63
119
  </Box>
64
120
  )}
65
121
  </Box>
@@ -26,12 +26,7 @@ export const CompactDisplay: React.FC<CompactDisplayProps> = ({ block }) => {
26
26
 
27
27
  {content && (
28
28
  <Box marginTop={1} flexDirection="column">
29
- <Box
30
- paddingLeft={2}
31
- borderLeft
32
- borderColor="gray"
33
- flexDirection="column"
34
- >
29
+ <Box paddingLeft={2} flexDirection="column">
35
30
  <Text color="white">{displayContent}</Text>
36
31
  </Box>
37
32
  </Box>
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useState } from "react";
2
- import { Box, Text, useInput, useStdout } from "ink";
2
+ import { Box, Text, useInput, useWindowSize } from "ink";
3
3
  import {
4
4
  BASH_TOOL_NAME,
5
5
  EDIT_TOOL_NAME,
@@ -36,25 +36,37 @@ const getActionDescription = (
36
36
  return "Enter plan mode for complex task planning";
37
37
  case ASK_USER_QUESTION_TOOL_NAME:
38
38
  return "Answer questions to clarify intent";
39
- case ARTIFACT_TOOL_NAME:
40
- switch (toolInput.action) {
39
+ case ARTIFACT_TOOL_NAME: {
40
+ // `action` omitted means publish, so the publish wording must not be the
41
+ // switch's fallback: a comment or asset action added later would then be
42
+ // announced as "Publish file:".
43
+ const action = toolInput.action ?? "publish";
44
+ const target = toolInput.url || toolInput.file_path || "unknown artifact";
45
+ switch (action) {
41
46
  case "list":
42
47
  return `List artifacts (scope: ${toolInput.scope || "mine"})`;
43
48
  case "upload_asset":
44
49
  return `Upload asset: ${toolInput.file_path || "unknown file"}`;
45
50
  case "list_assets":
46
- return `List assets of artifact: ${toolInput.url || "unknown artifact"}`;
51
+ return `List assets of artifact: ${target}`;
47
52
  case "read_asset":
48
53
  return `Read asset: ${toolInput.asset_id || "unknown asset"}`;
49
54
  case "delete_asset":
50
55
  return `Delete asset: ${toolInput.asset_id || "unknown asset"}`;
51
56
  case "copy_from":
52
- return `Copy assets into artifact: ${toolInput.url || "unknown artifact"}`;
57
+ return `Copy assets into artifact: ${target}`;
53
58
  case "read":
54
- return `Read artifact: ${toolInput.url || "unknown artifact"}`;
55
- default:
59
+ return `Read artifact: ${target}`;
60
+ case "reply":
61
+ return `Reply to comment thread ${toolInput.thread_id || "unknown thread"} on: ${target}`;
62
+ case "resolve":
63
+ return `${toolInput.resolved === false ? "Reopen" : "Resolve"} comment thread ${toolInput.thread_id || "unknown thread"} on: ${target}`;
64
+ case "publish":
56
65
  return `Publish file: ${toolInput.file_path || "unknown file"}`;
66
+ default:
67
+ return `Artifact action "${String(action)}" on: ${target}`;
57
68
  }
69
+ }
58
70
  default:
59
71
  return "Execute operation";
60
72
  }
@@ -89,8 +101,8 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
89
101
 
90
102
  // Terminal width is needed to render the plan's Markdown (table column widths
91
103
  // are computed from it), matching <Markdown>'s own default.
92
- const { stdout } = useStdout();
93
- const columns = stdout?.columns ?? 80;
104
+ const { columns: terminalColumns } = useWindowSize();
105
+ const columns = terminalColumns || 80;
94
106
 
95
107
  const headerRows = 2 + (warning ? 1 : 0);
96
108
 
@@ -125,12 +137,7 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
125
137
  ? highlightToAnsi(JSON.stringify(toolInput, null, 2), "json")
126
138
  .split("\n")
127
139
  .map((line, index) => (
128
- <Box
129
- key={`json-${index}`}
130
- paddingLeft={2}
131
- borderLeft
132
- borderColor="cyan"
133
- >
140
+ <Box key={`json-${index}`} paddingLeft={2}>
134
141
  <Text>{line || " "}</Text>
135
142
  </Box>
136
143
  ))
@@ -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";
@@ -98,7 +99,6 @@ export const InputBox: React.FC<InputBoxProps> = ({
98
99
  compact,
99
100
  addDir,
100
101
  renameSession,
101
- remoteControlCommand,
102
102
  currentModel,
103
103
  configuredModels,
104
104
  setModel,
@@ -162,6 +162,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
162
162
  showResumeSelector,
163
163
  showHelp,
164
164
  showStatusCommand,
165
+ showRemoteControl,
165
166
  showLoginCommand,
166
167
  showPluginManager,
167
168
  showModelSelector,
@@ -175,6 +176,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
175
176
  setShowResumeSelector,
176
177
  setShowHelp,
177
178
  setShowStatusCommand,
179
+ setShowRemoteControl,
178
180
  setShowLoginCommand,
179
181
  setShowPluginManager,
180
182
  setShowModelSelector,
@@ -190,6 +192,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
190
192
  escClearPending,
191
193
  // Main handler
192
194
  handleInput,
195
+ insertPastedText,
193
196
  // Manager ready state
194
197
  isManagerReady,
195
198
  // Direct state setters
@@ -202,7 +205,6 @@ export const InputBox: React.FC<InputBoxProps> = ({
202
205
  onReloadPlugins: reloadPlugins,
203
206
  onAddDir: addDir,
204
207
  onRenameSession: renameSession,
205
- onRemoteControlCommand: remoteControlCommand,
206
208
  onHasSlashCommand: hasSlashCommand,
207
209
  onAbortMessage: abortMessage,
208
210
  onBackgroundCurrentTask: backgroundCurrentTask,
@@ -233,13 +235,11 @@ export const InputBox: React.FC<InputBoxProps> = ({
233
235
  setPermissionMode(chatPermissionMode);
234
236
  }, [chatPermissionMode, setPermissionMode]);
235
237
 
236
- // Use the InputManager's unified input handler
237
- useInput(async (input, key) => {
238
- // These views have their own useInput handlers that handle escape and navigation.
239
- // If they are active, we should skip InputBox's global input handling to avoid
240
- // duplicate dispatches or state update conflicts.
241
- if (
242
- 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 ||
243
243
  showRewindManager ||
244
244
  showResumeSelector ||
245
245
  showHelp ||
@@ -252,13 +252,32 @@ export const InputBox: React.FC<InputBoxProps> = ({
252
252
  showAgentsManager ||
253
253
  showWorkflowManager ||
254
254
  showSkillsManager ||
255
- showHooksManager
256
- ) {
255
+ showHooksManager ||
256
+ showRemoteControl,
257
+ );
258
+
259
+ // Use the InputManager's unified input handler
260
+ useInput(async (input, key) => {
261
+ if (overlayOwnsKeyboard) {
257
262
  return;
258
263
  }
259
264
  await handleInput(input, key);
260
265
  });
261
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
+
262
281
  const handleRewindCancel = () => {
263
282
  if (setShowRewindManager) {
264
283
  setShowRewindManager(false);
@@ -410,6 +429,10 @@ export const InputBox: React.FC<InputBoxProps> = ({
410
429
  <StatusCommand onCancel={() => setShowStatusCommand(false)} />
411
430
  )}
412
431
 
432
+ {showRemoteControl && (
433
+ <RemoteControlView onCancel={() => setShowRemoteControl(false)} />
434
+ )}
435
+
413
436
  {showLoginCommand && (
414
437
  <LoginCommand onCancel={() => setShowLoginCommand(false)} />
415
438
  )}
@@ -455,7 +478,8 @@ export const InputBox: React.FC<InputBoxProps> = ({
455
478
  showLoginCommand ||
456
479
  showPluginManager ||
457
480
  showModelSelector ||
458
- showWorkflowManager || (
481
+ showWorkflowManager ||
482
+ showRemoteControl || (
459
483
  <Box flexDirection="column">
460
484
  {escClearPending && (
461
485
  <Text color="gray">Press Esc again to clear input</Text>