wave-code 1.2.0 → 1.4.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 (64) hide show
  1. package/dist/bundle/wave.mjs +1738 -893
  2. package/package.json +6 -3
  3. package/src/cli.tsx +9 -0
  4. package/src/commands/plugin/install.ts +6 -7
  5. package/src/commands/plugin/uninstall.ts +11 -5
  6. package/src/commands/update.ts +62 -22
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/ChatInterface.tsx +1 -0
  9. package/src/components/ConfirmationDetails.tsx +51 -16
  10. package/src/components/ConfirmationSelector.tsx +39 -0
  11. package/src/components/InputBox.tsx +41 -2
  12. package/src/components/Markdown.tsx +21 -8
  13. package/src/components/MarketplaceDetail.tsx +10 -27
  14. package/src/components/MarketplaceView.tsx +17 -3
  15. package/src/components/MessageBlockItem.tsx +0 -6
  16. package/src/components/Notifications.tsx +7 -0
  17. package/src/components/PlanView.tsx +22 -17
  18. package/src/components/PluginManagerShell.tsx +3 -3
  19. package/src/components/PluginManagerTypes.ts +2 -1
  20. package/src/components/ResumeCommand.tsx +84 -0
  21. package/src/components/ScrolledContent.tsx +71 -0
  22. package/src/components/SessionSelector.tsx +184 -6
  23. package/src/components/SessionSelectorFlow.tsx +246 -0
  24. package/src/components/WorkflowManager.tsx +38 -138
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +24 -0
  27. package/src/contexts/useChat.tsx +517 -155
  28. package/src/daemon/commands.ts +582 -50
  29. package/src/daemon-cli.ts +12 -1
  30. package/src/hooks/useInputManager.ts +20 -0
  31. package/src/hooks/usePluginManager.ts +104 -78
  32. package/src/hooks/useRemoteControl.ts +324 -0
  33. package/src/index.ts +97 -8
  34. package/src/managers/inputHandlers.ts +16 -9
  35. package/src/managers/inputReducer.ts +30 -9
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/confirmationReducer.ts +20 -1
  38. package/src/reducers/selectorReducer.ts +3 -0
  39. package/src/reducers/workflowManagerReducer.ts +21 -2
  40. package/src/remote/frameBudget.ts +45 -0
  41. package/src/remote/hostIdentity.ts +128 -0
  42. package/src/remote/messageWindow.ts +97 -0
  43. package/src/remote/notificationCoalescer.ts +174 -0
  44. package/src/remote/relayClient.ts +668 -0
  45. package/src/remote/remoteControlCliHost.ts +391 -0
  46. package/src/remote/remoteControlCommands.ts +197 -0
  47. package/src/remote/remoteControlConfig.ts +334 -0
  48. package/src/remote/remoteControlService.ts +799 -0
  49. package/src/remote/remoteControlSink.ts +77 -0
  50. package/src/remote/remoteNotifications.ts +194 -0
  51. package/src/session-selector-cli.tsx +9 -255
  52. package/src/stdio/agentBridge.ts +1001 -165
  53. package/src/stdio/daemonServer.ts +30 -0
  54. package/src/stdio/jsonRpcConnection.ts +11 -2
  55. package/src/stdio/protocol.ts +14 -106
  56. package/src/stdio/stdioServer.ts +33 -2
  57. package/src/stdio-cli.ts +16 -1
  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/sessionOwnership.ts +62 -0
  62. package/src/utils/workspaceDiff.ts +620 -0
  63. package/src/utils/worktree.ts +97 -0
  64. package/src/components/PlanDisplay.tsx +0 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "CLI-based code assistant powered by AI, built with React and Ink",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,9 +28,12 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@vscode/ripgrep": "^1.18.0"
31
+ "@vscode/ripgrep": "^1.18.0",
32
+ "qrcode": "^1.5.4",
33
+ "sharp": "^0.35.4"
32
34
  },
33
35
  "devDependencies": {
36
+ "@types/qrcode": "^1.5.6",
34
37
  "@types/react": "^19.1.8",
35
38
  "@types/semver": "^7.7.1",
36
39
  "@types/yargs": "^17.0.0",
@@ -56,7 +59,7 @@
56
59
  "wrap-ansi": "^10.0.0",
57
60
  "yargs": "^17.7.2",
58
61
  "zod": "^3.23.8",
59
- "wave-agent-sdk": "1.2.0"
62
+ "wave-agent-sdk": "1.4.0"
60
63
  },
61
64
  "engines": {
62
65
  "node": ">=22"
package/src/cli.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { render } from "ink";
3
+ import { ensureRuntimeDeps } from "wave-agent-sdk";
3
4
  import { App } from "./components/App.js";
4
5
  import { cleanupLogs } from "./utils/logger.js";
5
6
  import { removeWorktree } from "./utils/worktree.js";
@@ -11,6 +12,14 @@ export interface CliOptions extends BaseAppProps {
11
12
  }
12
13
 
13
14
  export async function startCli(options: CliOptions): Promise<void> {
15
+ // Install the on-demand runtime dependencies (image codec + ripgrep) before
16
+ // the UI comes up. Waiting is the point: pasting an image or running a search
17
+ // a second later must not race a download. Costs ~16ms once they are
18
+ // installed (marker hit), ~6MB/20MB worth of time on the first run of a
19
+ // host-bundled CLI. A failed download only degrades the affected tool — never
20
+ // throws.
21
+ await ensureRuntimeDeps();
22
+
14
23
  const {
15
24
  restoreSessionId,
16
25
  continueLastSession,
@@ -8,17 +8,16 @@ export async function installPluginCommand(argv: {
8
8
  const pluginCore = new PluginCore(workdir);
9
9
 
10
10
  try {
11
- const installed = await pluginCore.installPlugin(argv.plugin);
11
+ // When no scope is given, default to user scope (matches Claude Code).
12
+ const scope = argv.scope ?? "user";
13
+ const installed = await pluginCore.installPlugin(argv.plugin, scope);
12
14
  console.log(
13
15
  `Successfully installed plugin: ${installed.name} v${installed.version} from ${installed.marketplace}`,
14
16
  );
15
17
  console.log(`Cache path: ${installed.cachePath}`);
16
-
17
- // When no scope is given, default to user scope (matches Claude Code).
18
- const scope = argv.scope ?? "user";
19
- const pluginId = `${installed.name}@${installed.marketplace}`;
20
- await pluginCore.enablePlugin(pluginId, scope);
21
- console.log(`Plugin ${pluginId} enabled in ${scope} scope`);
18
+ console.log(
19
+ `Plugin ${installed.name}@${installed.marketplace} enabled in ${scope} scope`,
20
+ );
22
21
 
23
22
  process.exit(0);
24
23
  } catch (error) {
@@ -1,13 +1,19 @@
1
- import { PluginCore } from "wave-agent-sdk";
1
+ import { PluginCore, Scope } from "wave-agent-sdk";
2
2
 
3
- export async function uninstallPluginCommand(argv: { plugin: string }) {
3
+ export async function uninstallPluginCommand(argv: {
4
+ plugin: string;
5
+ scope?: Scope;
6
+ }) {
4
7
  const workdir = process.cwd();
5
8
  const pluginCore = new PluginCore(workdir);
6
9
 
7
10
  try {
8
- await pluginCore.uninstallPlugin(argv.plugin);
9
- console.log(`Successfully uninstalled plugin: ${argv.plugin}`);
10
- console.log(`Cleaned up plugin configuration from all scopes`);
11
+ // Only the given scope is uninstalled (spec plugin A-015). Without --scope
12
+ // the core picks the scope the plugin is enabled in for this directory.
13
+ const scope = await pluginCore.uninstallPlugin(argv.plugin, argv.scope);
14
+ console.log(
15
+ `Successfully uninstalled plugin: ${argv.plugin} (scope: ${scope})`,
16
+ );
11
17
  process.exit(0);
12
18
  } catch (error) {
13
19
  const message = error instanceof Error ? error.message : String(error);
@@ -1,32 +1,70 @@
1
1
  import { spawn, spawnSync } from "child_process";
2
- import https from "https";
3
2
  import chalk from "chalk";
4
3
  import { isUpdateAvailable } from "../utils/version.js";
5
4
  import { readNearestPackageJson } from "../utils/readPackageJson.js";
6
5
 
7
6
  const currentVersion = readNearestPackageJson().version;
8
7
 
9
- async function getLatestVersion(): Promise<string> {
10
- return new Promise((resolve, reject) => {
11
- https
12
- .get("https://registry.npmjs.org/wave-code/latest", (res) => {
13
- let data = "";
14
- res.on("data", (chunk) => {
15
- data += chunk;
16
- });
17
- res.on("end", () => {
18
- try {
19
- const json = JSON.parse(data);
20
- resolve(json.version);
21
- } catch {
22
- reject(new Error("Failed to parse npm registry response"));
23
- }
24
- });
25
- })
26
- .on("error", (err) => {
27
- reject(err);
28
- });
8
+ /** npm registry mirror for China users (the CLI downloads its runtime deps from the same one). */
9
+ const REGISTRY_MIRROR = "https://registry.npmmirror.com";
10
+ /** Official registry — the fallback when the mirror is unreachable. */
11
+ const REGISTRY_OFFICIAL = "https://registry.npmjs.org";
12
+ /** Cap each registry request so a hanging one cannot stall the fallback. */
13
+ const REGISTRY_TIMEOUT_MS = 5000;
14
+
15
+ interface LatestVersion {
16
+ version: string;
17
+ /** The registry that answered — the install reuses it (see getLatestVersion). */
18
+ registry: string;
19
+ }
20
+
21
+ async function fetchLatestVersion(registry: string): Promise<string> {
22
+ const response = await fetch(`${registry}/wave-code/latest`, {
23
+ signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
29
24
  });
25
+ if (!response.ok) {
26
+ throw new Error(
27
+ `HTTP ${response.status} fetching ${registry}/wave-code/latest`,
28
+ );
29
+ }
30
+ const json = (await response.json()) as { version?: string };
31
+ if (!json.version) {
32
+ throw new Error(`No version in the response from ${registry}`);
33
+ }
34
+ return json.version;
35
+ }
36
+
37
+ /**
38
+ * The mirror answers first so the check is fast in CN; the official registry is
39
+ * a silent fallback so an unreachable mirror never blocks the check.
40
+ *
41
+ * The install reuses whichever registry answered: a machine that can only reach
42
+ * the official registry would fail the install for the very reason the check
43
+ * fell back.
44
+ */
45
+ async function getLatestVersion(): Promise<LatestVersion> {
46
+ try {
47
+ return {
48
+ version: await fetchLatestVersion(REGISTRY_MIRROR),
49
+ registry: REGISTRY_MIRROR,
50
+ };
51
+ } catch (mirrorError) {
52
+ try {
53
+ return {
54
+ version: await fetchLatestVersion(REGISTRY_OFFICIAL),
55
+ registry: REGISTRY_OFFICIAL,
56
+ };
57
+ } catch (officialError) {
58
+ throw new Error(
59
+ `Could not reach ${REGISTRY_MIRROR} (${errorText(mirrorError)}) ` +
60
+ `or ${REGISTRY_OFFICIAL} (${errorText(officialError)})`,
61
+ );
62
+ }
63
+ }
64
+ }
65
+
66
+ function errorText(error: unknown): string {
67
+ return error instanceof Error ? error.message : String(error);
30
68
  }
31
69
 
32
70
  function detectPackageManager(): "npm" | "pnpm" | "yarn" {
@@ -53,7 +91,7 @@ export async function updateCommand() {
53
91
  console.log(chalk.dim(`Current version: ${currentVersion}`));
54
92
 
55
93
  try {
56
- const latestVersion = await getLatestVersion();
94
+ const { version: latestVersion, registry } = await getLatestVersion();
57
95
  console.log(chalk.dim(`Latest version: ${latestVersion}`));
58
96
 
59
97
  if (!isUpdateAvailable(currentVersion, latestVersion)) {
@@ -79,6 +117,8 @@ export async function updateCommand() {
79
117
  updateCmd = "npm";
80
118
  args = ["install", "-g", "wave-code@latest"];
81
119
  }
120
+ // Appended once so every package manager (including a future branch) gets it.
121
+ args.push(`--registry=${registry}`);
82
122
 
83
123
  console.log(chalk.blue(`Updating WAVE Code using ${packageManager}...`));
84
124
  console.log(chalk.dim(`Running: ${updateCmd} ${args.join(" ")}`));
@@ -6,6 +6,10 @@ import {
6
6
  backgroundTaskManagerReducer,
7
7
  type BackgroundTaskManagerState,
8
8
  } from "../reducers/backgroundTaskManagerReducer.js";
9
+ import WorkflowRunDetail, {
10
+ formatDuration,
11
+ formatTime,
12
+ } from "./WorkflowRunDetail.js";
9
13
 
10
14
  export interface BackgroundTaskManagerProps {
11
15
  onCancel: () => void;
@@ -14,8 +18,12 @@ export interface BackgroundTaskManagerProps {
14
18
  export const BackgroundTaskManager: React.FC<BackgroundTaskManagerProps> = ({
15
19
  onCancel,
16
20
  }) => {
17
- const { backgroundTasks, getBackgroundTaskOutput, stopBackgroundTask } =
18
- useChat();
21
+ const {
22
+ backgroundTasks,
23
+ workflowRuns,
24
+ getBackgroundTaskOutput,
25
+ stopBackgroundTask,
26
+ } = useChat();
19
27
  const MAX_VISIBLE_ITEMS = 3;
20
28
 
21
29
  const [state, dispatch] = useReducer(backgroundTaskManagerReducer, {
@@ -71,18 +79,6 @@ export const BackgroundTaskManager: React.FC<BackgroundTaskManagerProps> = ({
71
79
  }
72
80
  }, [viewMode, detailTaskId, getBackgroundTaskOutput]);
73
81
 
74
- const formatDuration = (ms: number): string => {
75
- if (ms < 1000) return `${ms}ms`;
76
- if (ms < 60000) return `${Math.round(ms / 1000)}s`;
77
- const minutes = Math.floor(ms / 60000);
78
- const seconds = Math.round((ms % 60000) / 1000);
79
- return `${minutes}m ${seconds}s`;
80
- };
81
-
82
- const formatTime = (timestamp: number): string => {
83
- return new Date(timestamp).toLocaleTimeString();
84
- };
85
-
86
82
  // Calculate visible window
87
83
  const startIndex = Math.max(
88
84
  0,
@@ -104,6 +100,15 @@ export const BackgroundTaskManager: React.FC<BackgroundTaskManagerProps> = ({
104
100
  return null;
105
101
  }
106
102
 
103
+ // A workflow background task's id IS its run id, so a workflow task can show
104
+ // real progress instead of the (always empty) text output: the same run
105
+ // fields `/workflows` shows, via the same component. Absent run (e.g. the
106
+ // session was cleared) falls back to the plain task detail below.
107
+ const selectedRun =
108
+ task.type === "workflow"
109
+ ? (workflowRuns.find((r) => r.runId === task.id) ?? null)
110
+ : null;
111
+
107
112
  return (
108
113
  <Box
109
114
  flexDirection="column"
@@ -134,53 +139,70 @@ export const BackgroundTaskManager: React.FC<BackgroundTaskManagerProps> = ({
134
139
  </Text>
135
140
  </Box>
136
141
  )}
137
- <Box>
138
- <Text>
139
- <Text color="blue">Status:</Text> {task.status}
140
- {task.exitCode !== undefined && ` (exit code: ${task.exitCode})`}
141
- </Text>
142
- </Box>
143
- <Box>
144
- <Text>
145
- <Text color="blue">Started:</Text> {formatTime(task.startTime)}
146
- {task.runtime !== undefined && (
142
+ {/* With the run block below, the task's own status and start time
143
+ would repeat it — the run is the authority on how far the
144
+ workflow got. The header keeps only what identifies the task. */}
145
+ {!selectedRun && (
146
+ <>
147
+ <Box>
147
148
  <Text>
148
- {" "}
149
- | <Text color="blue">Runtime:</Text>{" "}
150
- {formatDuration(task.runtime)}
149
+ <Text color="blue">Status:</Text> {task.status}
150
+ {task.exitCode !== undefined &&
151
+ ` (exit code: ${task.exitCode})`}
152
+ </Text>
153
+ </Box>
154
+ <Box>
155
+ <Text>
156
+ <Text color="blue">Started:</Text>{" "}
157
+ {formatTime(task.startTime)}
158
+ {task.runtime !== undefined && (
159
+ <Text>
160
+ {" "}
161
+ | <Text color="blue">Runtime:</Text>{" "}
162
+ {formatDuration(task.runtime)}
163
+ </Text>
164
+ )}
151
165
  </Text>
166
+ </Box>
167
+ {task.outputPath && (
168
+ <Box>
169
+ <Text>
170
+ <Text color="blue">Log File:</Text> {task.outputPath}
171
+ </Text>
172
+ </Box>
152
173
  )}
153
- </Text>
154
- </Box>
155
- {task.outputPath && (
156
- <Box>
157
- <Text>
158
- <Text color="blue">Log File:</Text> {task.outputPath}
159
- </Text>
160
- </Box>
174
+ </>
161
175
  )}
162
176
  </Box>
163
177
 
164
- {detailOutput.stdout && (
165
- <Box flexDirection="column" marginTop={1}>
166
- <Text color="green" bold>
167
- OUTPUT (last 10 lines):
168
- </Text>
169
- <Box borderStyle="single" borderColor="green" padding={1}>
170
- <Text>{getLastLines(detailOutput.stdout, 10)}</Text>
171
- </Box>
172
- </Box>
173
- )}
178
+ {selectedRun ? (
179
+ <WorkflowRunDetail run={selectedRun} />
180
+ ) : (
181
+ <>
182
+ {detailOutput.stdout && (
183
+ <Box flexDirection="column" marginTop={1}>
184
+ <Text color="green" bold>
185
+ OUTPUT (last 10 lines):
186
+ </Text>
187
+ <Box borderStyle="single" borderColor="green" padding={1}>
188
+ <Text>{getLastLines(detailOutput.stdout, 10)}</Text>
189
+ </Box>
190
+ </Box>
191
+ )}
174
192
 
175
- {detailOutput.stderr && (
176
- <Box flexDirection="column" marginTop={1}>
177
- <Text color="red" bold>
178
- ERRORS:
179
- </Text>
180
- <Box borderStyle="single" borderColor="red" padding={1}>
181
- <Text color="red">{getLastLines(detailOutput.stderr, 10)}</Text>
182
- </Box>
183
- </Box>
193
+ {detailOutput.stderr && (
194
+ <Box flexDirection="column" marginTop={1}>
195
+ <Text color="red" bold>
196
+ ERRORS:
197
+ </Text>
198
+ <Box borderStyle="single" borderColor="red" padding={1}>
199
+ <Text color="red">
200
+ {getLastLines(detailOutput.stderr, 10)}
201
+ </Text>
202
+ </Box>
203
+ </Box>
204
+ )}
205
+ </>
184
206
  )}
185
207
 
186
208
  <Box marginTop={1}>
@@ -132,6 +132,7 @@ export const ChatInterface: React.FC = () => {
132
132
  toolInput={confirmingTool!.input}
133
133
  suggestedPrefix={confirmingTool!.suggestedPrefix}
134
134
  hidePersistentOption={confirmingTool!.hidePersistentOption}
135
+ outsideSafeZoneDirectory={confirmingTool!.outsideSafeZoneDirectory}
135
136
  permissionMode={confirmingTool!.permissionMode}
136
137
  isExpanded={isExpanded}
137
138
  onDecision={handleConfirmationDecision}
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useState } from "react";
2
- import { Box, Text, useInput } from "ink";
2
+ import { Box, Text, useInput, useStdout } from "ink";
3
3
  import {
4
4
  BASH_TOOL_NAME,
5
5
  EDIT_TOOL_NAME,
@@ -10,6 +10,8 @@ import {
10
10
  ARTIFACT_TOOL_NAME,
11
11
  } from "wave-agent-sdk";
12
12
  import { buildDiffLinesFromParams } from "./DiffDisplay.js";
13
+ import { renderMarkdownToAnsi } from "./Markdown.js";
14
+ import { ScrolledContent } from "./ScrolledContent.js";
13
15
  import { highlightToAnsi } from "../utils/highlightUtils.js";
14
16
 
15
17
  // Helper function to generate descriptive action text
@@ -35,7 +37,24 @@ const getActionDescription = (
35
37
  case ASK_USER_QUESTION_TOOL_NAME:
36
38
  return "Answer questions to clarify intent";
37
39
  case ARTIFACT_TOOL_NAME:
38
- return `Publish file: ${toolInput.file_path || "unknown file"}`;
40
+ switch (toolInput.action) {
41
+ case "list":
42
+ return `List artifacts (scope: ${toolInput.scope || "mine"})`;
43
+ case "upload_asset":
44
+ return `Upload asset: ${toolInput.file_path || "unknown file"}`;
45
+ case "list_assets":
46
+ return `List assets of artifact: ${toolInput.url || "unknown artifact"}`;
47
+ case "read_asset":
48
+ return `Read asset: ${toolInput.asset_id || "unknown asset"}`;
49
+ case "delete_asset":
50
+ return `Delete asset: ${toolInput.asset_id || "unknown asset"}`;
51
+ case "copy_from":
52
+ return `Copy assets into artifact: ${toolInput.url || "unknown artifact"}`;
53
+ case "read":
54
+ return `Read artifact: ${toolInput.url || "unknown artifact"}`;
55
+ default:
56
+ return `Publish file: ${toolInput.file_path || "unknown file"}`;
57
+ }
39
58
  default:
40
59
  return "Execute operation";
41
60
  }
@@ -68,6 +87,11 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
68
87
  (toolInput?.startLineNumber as number | undefined) ??
69
88
  (toolName === WRITE_TOOL_NAME ? 1 : undefined);
70
89
 
90
+ // Terminal width is needed to render the plan's Markdown (table column widths
91
+ // are computed from it), matching <Markdown>'s own default.
92
+ const { stdout } = useStdout();
93
+ const columns = stdout?.columns ?? 80;
94
+
71
95
  const headerRows = 2 + (warning ? 1 : 0);
72
96
 
73
97
  // Number of content rows rendered at once. The header stays fixed above the
@@ -117,12 +141,17 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
117
141
  toolName === EXIT_PLAN_MODE_TOOL_NAME &&
118
142
  !!planContent;
119
143
 
144
+ // The plan is Markdown (the agent writes it to a .md file), so it is rendered
145
+ // with the same renderer as assistant messages and then linearized into rows
146
+ // for the row-slicing scroll above — same treatment as the JSON/diff rows.
120
147
  const planLines = showPlan
121
- ? planContent!.split("\n").map((line, index) => (
122
- <Box key={`plan-${index}`}>
123
- <Text>{line || " "}</Text>
124
- </Box>
125
- ))
148
+ ? renderMarkdownToAnsi(planContent!, columns)
149
+ .split("\n")
150
+ .map((line, index) => (
151
+ <Box key={`plan-${index}`}>
152
+ <Text>{line || " "}</Text>
153
+ </Box>
154
+ ))
126
155
  : [];
127
156
 
128
157
  const contentLines: React.ReactNode[] = [
@@ -130,9 +159,11 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
130
159
  ...jsonLines,
131
160
  ...planLines,
132
161
  ];
133
- const totalLines = contentLines.length;
134
- const maxScroll = Math.max(0, totalLines - visibleCount);
135
162
 
163
+ // Rows really occupied by the content (wrapped rows included), measured after
164
+ // layout by <ScrolledContent> — see that component for why it is measured
165
+ // instead of counted from the row array.
166
+ const [contentRows, setContentRows] = useState(0);
136
167
  const [scrollOffset, setScrollOffset] = useState(0);
137
168
 
138
169
  // Reset scrolling when a new confirmation appears.
@@ -140,6 +171,8 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
140
171
  setScrollOffset(0);
141
172
  }, [toolName, toolInput, planContent]);
142
173
 
174
+ const maxScroll = Math.max(0, contentRows - visibleCount);
175
+
143
176
  const halfPage = Math.max(1, Math.ceil(visibleCount / 2));
144
177
  useInput((input, key) => {
145
178
  if (key.pageUp) {
@@ -159,12 +192,8 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
159
192
  });
160
193
 
161
194
  const clampedOffset = Math.min(scrollOffset, maxScroll);
162
- const visibleLines = contentLines.slice(
163
- clampedOffset,
164
- clampedOffset + visibleCount,
165
- );
166
195
  const hasMoreAbove = clampedOffset > 0;
167
- const hasMoreBelow = clampedOffset + visibleCount < totalLines;
196
+ const hasMoreBelow = clampedOffset + visibleCount < contentRows;
168
197
 
169
198
  return (
170
199
  <Box
@@ -195,11 +224,17 @@ export const ConfirmationDetails: React.FC<ConfirmationDetailsProps> = ({
195
224
  </Text>
196
225
  </Box>
197
226
  )}
198
- {visibleLines}
227
+ <ScrolledContent
228
+ visibleRows={visibleCount}
229
+ scrollOffset={clampedOffset}
230
+ onMeasuredRows={setContentRows}
231
+ >
232
+ {contentLines}
233
+ </ScrolledContent>
199
234
  {hasMoreBelow && (
200
235
  <Box>
201
236
  <Text color="gray" dimColor>
202
- ↓ {totalLines - clampedOffset - visibleCount} more
237
+ ↓ {contentRows - clampedOffset - visibleCount} more
203
238
  </Text>
204
239
  </Box>
205
240
  )}
@@ -7,8 +7,10 @@ import type {
7
7
  } from "wave-agent-sdk";
8
8
  import {
9
9
  BASH_TOOL_NAME,
10
+ EDIT_TOOL_NAME,
10
11
  EXIT_PLAN_MODE_TOOL_NAME,
11
12
  ENTER_PLAN_MODE_TOOL_NAME,
13
+ WRITE_TOOL_NAME,
12
14
  ASK_USER_QUESTION_TOOL_NAME,
13
15
  } from "wave-agent-sdk";
14
16
  import { confirmationReducer } from "../reducers/confirmationReducer.js";
@@ -24,11 +26,19 @@ const getHeaderColor = (header: string) => {
24
26
  return colors[Math.abs(hash) % colors.length];
25
27
  };
26
28
 
29
+ /** Last path segment of a directory path (handles both `/` and `\` separators). */
30
+ const lastPathSegment = (dirPath: string): string => {
31
+ const segments = dirPath.split(/[\\/]/).filter(Boolean);
32
+ return segments[segments.length - 1] ?? dirPath;
33
+ };
34
+
27
35
  export interface ConfirmationSelectorProps {
28
36
  toolName: string;
29
37
  toolInput?: Record<string, unknown>;
30
38
  suggestedPrefix?: string;
31
39
  hidePersistentOption?: boolean;
40
+ /** Directory holding an out-of-Safe-Zone Write/Edit target, when the host reports one. */
41
+ outsideSafeZoneDirectory?: string;
32
42
  permissionMode?: PermissionMode;
33
43
  isExpanded?: boolean;
34
44
  onDecision: (decision: PermissionDecision) => void;
@@ -40,6 +50,7 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
40
50
  toolInput,
41
51
  suggestedPrefix,
42
52
  hidePersistentOption,
53
+ outsideSafeZoneDirectory,
43
54
  permissionMode,
44
55
  isExpanded = false,
45
56
  onDecision,
@@ -104,6 +115,15 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
104
115
  return "Yes, and auto-accept edits";
105
116
  };
106
117
 
118
+ // Only Write/Edit can be out of the Safe Zone, and the host only reports the
119
+ // directory for such requests.
120
+ const showAddDirectoryOption =
121
+ (toolName === EDIT_TOOL_NAME || toolName === WRITE_TOOL_NAME) &&
122
+ !!outsideSafeZoneDirectory;
123
+ const addDirectoryOptionText = `Yes, and allow all edits in ${lastPathSegment(
124
+ outsideSafeZoneDirectory ?? "",
125
+ )}/ this session`;
126
+
107
127
  const pasteDetectorRef = useRef(createBracketedPasteDetector());
108
128
 
109
129
  useInput((input, key) => {
@@ -140,6 +160,7 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
140
160
  toolInput,
141
161
  suggestedPrefix,
142
162
  hidePersistentOption,
163
+ outsideSafeZoneDirectory,
143
164
  permissionMode,
144
165
  });
145
166
  }
@@ -248,6 +269,24 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
248
269
  : "Yes, proceed"}
249
270
  </Text>
250
271
  </Box>
272
+ {showAddDirectoryOption && (
273
+ <Box key="add-directory-option">
274
+ <Text
275
+ color={
276
+ state.selectedOption === "addDirectory" ? "black" : "white"
277
+ }
278
+ backgroundColor={
279
+ state.selectedOption === "addDirectory"
280
+ ? "yellow"
281
+ : undefined
282
+ }
283
+ bold={state.selectedOption === "addDirectory"}
284
+ >
285
+ {state.selectedOption === "addDirectory" ? "> " : " "}
286
+ {addDirectoryOptionText}
287
+ </Text>
288
+ </Box>
289
+ )}
251
290
  {!hidePersistentOption && (
252
291
  <Box key="auto-option">
253
292
  <Text