wave-code 1.1.5 → 1.3.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 (46) hide show
  1. package/dist/bundle/wave.mjs +1075 -613
  2. package/package.json +4 -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/components/ChatInterface.tsx +3 -0
  7. package/src/components/ConfirmationDetails.tsx +36 -15
  8. package/src/components/ConfirmationSelector.tsx +39 -0
  9. package/src/components/HooksManager.tsx +286 -0
  10. package/src/components/InputBox.tsx +125 -51
  11. package/src/components/Markdown.tsx +21 -8
  12. package/src/components/MarketplaceDetail.tsx +10 -27
  13. package/src/components/MarketplaceView.tsx +17 -3
  14. package/src/components/MessageBlockItem.tsx +0 -5
  15. package/src/components/MessageList.tsx +0 -1
  16. package/src/components/Notifications.tsx +7 -0
  17. package/src/components/PlanView.tsx +146 -0
  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/constants/commands.ts +30 -0
  25. package/src/contexts/useChat.tsx +252 -98
  26. package/src/daemon/commands.ts +1046 -67
  27. package/src/daemon-cli.ts +6 -0
  28. package/src/hooks/useInputManager.ts +30 -0
  29. package/src/hooks/useLineScroll.ts +58 -0
  30. package/src/hooks/usePluginManager.ts +104 -78
  31. package/src/index.ts +156 -16
  32. package/src/managers/inputHandlers.ts +16 -9
  33. package/src/managers/inputReducer.ts +39 -9
  34. package/src/reducers/confirmationReducer.ts +20 -1
  35. package/src/reducers/hooksManagerReducer.ts +92 -0
  36. package/src/reducers/selectorReducer.ts +3 -0
  37. package/src/session-selector-cli.tsx +9 -255
  38. package/src/stdio/agentBridge.ts +932 -133
  39. package/src/stdio/daemonServer.ts +26 -82
  40. package/src/stdio/protocol.ts +23 -4
  41. package/src/stdio-cli.ts +60 -0
  42. package/src/utils/rewindCheckpoints.ts +2 -2
  43. package/src/utils/sessionOwnership.ts +62 -0
  44. package/src/utils/worktree.ts +175 -50
  45. package/src/components/BangDisplay.tsx +0 -41
  46. package/src/components/PlanDisplay.tsx +0 -18
package/src/index.ts CHANGED
@@ -188,7 +188,7 @@ export async function main() {
188
188
  })
189
189
  .option("scope", {
190
190
  alias: "s",
191
- describe: "Scope to enable the plugin in",
191
+ describe: "Scope to enable the plugin in (default: user)",
192
192
  choices: ["user", "project", "local"],
193
193
  type: "string",
194
194
  });
@@ -218,18 +218,30 @@ export async function main() {
218
218
  )
219
219
  .command(
220
220
  "uninstall <plugin>",
221
- "Uninstall a plugin",
221
+ "Uninstall a plugin from one scope (default: the scope it is enabled in for the current directory)",
222
222
  (yargs) => {
223
- return yargs.positional("plugin", {
224
- describe: "Plugin to uninstall (format: name@marketplace)",
225
- type: "string",
226
- });
223
+ return yargs
224
+ .positional("plugin", {
225
+ describe: "Plugin to uninstall (format: name@marketplace)",
226
+ type: "string",
227
+ })
228
+ .option("scope", {
229
+ alias: "s",
230
+ describe: "Scope to uninstall from",
231
+ choices: ["user", "project", "local"],
232
+ type: "string",
233
+ });
227
234
  },
228
235
  async (argv) => {
229
236
  const { uninstallPluginCommand } = await import(
230
237
  "./commands/plugin/uninstall.js"
231
238
  );
232
- await uninstallPluginCommand(argv as { plugin: string });
239
+ await uninstallPluginCommand(
240
+ argv as {
241
+ plugin: string;
242
+ scope?: Scope;
243
+ },
244
+ );
233
245
  },
234
246
  )
235
247
  .command(
@@ -251,10 +263,47 @@ export async function main() {
251
263
  })
252
264
  .command(
253
265
  "daemon",
254
- "Manage the wave daemon (client subcommands — to START a daemon use `wave --daemon <socket>` instead)",
266
+ "Manage the wave daemon (client subcommands — daemons start on demand, or directly via `wave --daemon <socket>`)",
255
267
  (yargs) => {
256
268
  return yargs
257
269
  .help()
270
+ .command(
271
+ "create",
272
+ "Create a new session hosted by the daemon and print its sessionId",
273
+ (yargs) => {
274
+ return yargs
275
+ .option("workdir", {
276
+ describe:
277
+ "Working directory for the new session (default: current directory)",
278
+ type: "string",
279
+ })
280
+ .option("permission-mode", {
281
+ describe:
282
+ "Permission mode for the new session (default: bypassPermissions; options: default, bypassPermissions, acceptEdits, plan, dontAsk)",
283
+ type: "string",
284
+ })
285
+ .option("model", {
286
+ describe:
287
+ "Model override for the new session (default: configured model)",
288
+ type: "string",
289
+ })
290
+ .option("worktree", {
291
+ describe:
292
+ "Create the session in a new git worktree (name optional — auto-generated when omitted)",
293
+ type: "string",
294
+ });
295
+ },
296
+ async (argv) => {
297
+ const { daemonCreateCommand, DEFAULT_DAEMON_SOCKET } =
298
+ await import("./daemon/commands.js");
299
+ await daemonCreateCommand(DEFAULT_DAEMON_SOCKET, {
300
+ workdir: argv.workdir as string | undefined,
301
+ permissionMode: argv.permissionMode as string | undefined,
302
+ model: argv.model as string | undefined,
303
+ worktree: argv.worktree as string | undefined,
304
+ });
305
+ },
306
+ )
258
307
  .command(
259
308
  "list",
260
309
  "List sessions hosted by the daemon (in-memory registry)",
@@ -275,8 +324,9 @@ export async function main() {
275
324
  type: "string",
276
325
  })
277
326
  .option("lines", {
278
- describe: "Number of recent messages to show",
279
- default: 20,
327
+ describe:
328
+ "Number of recent messages to show (0 = status line only)",
329
+ default: 1,
280
330
  type: "number",
281
331
  });
282
332
  },
@@ -290,9 +340,50 @@ export async function main() {
290
340
  );
291
341
  },
292
342
  )
343
+ .command(
344
+ "wait <sessionId>",
345
+ "Block until a session goes idle (or waits for approval), then print its final snapshot (exit 0 = idle, 3 = waiting for approval, 1 = error/timeout). Idle = no turn running, no background task/subagent running, nothing queued (same as `wave -p`); a session holding a long-running background task never goes idle, so give it --timeout",
346
+ (yargs) => {
347
+ return yargs
348
+ .positional("sessionId", {
349
+ describe: "Session ID hosted by the daemon",
350
+ type: "string",
351
+ })
352
+ .option("lines", {
353
+ describe:
354
+ "Number of recent messages to print on exit (0 = status line only)",
355
+ default: 1,
356
+ type: "number",
357
+ })
358
+ .option("from-busy", {
359
+ describe:
360
+ "Wait until the session has been observed busy at least once before accepting idle (removes the stale-snapshot race right after an async `send`; a running background task also counts as busy)",
361
+ default: false,
362
+ type: "boolean",
363
+ })
364
+ .option("timeout", {
365
+ describe:
366
+ "Seconds to wait before giving up (default: wait forever) — required for sessions with a long-running background task, which never go idle",
367
+ type: "number",
368
+ });
369
+ },
370
+ async (argv) => {
371
+ const { daemonWaitCommand, DEFAULT_DAEMON_SOCKET } =
372
+ await import("./daemon/commands.js");
373
+ await daemonWaitCommand(
374
+ DEFAULT_DAEMON_SOCKET,
375
+ argv.sessionId as string,
376
+ {
377
+ lines: argv.lines as number,
378
+ fromBusy: argv.fromBusy as boolean,
379
+ timeout: argv.timeout as number | undefined,
380
+ },
381
+ );
382
+ },
383
+ )
293
384
  .command(
294
385
  "send <sessionId> <message>",
295
- "Inject a message into a session and wait for the reply",
386
+ "Inject a message into a session (async by default; --wait N to get the reply)",
296
387
  (yargs) => {
297
388
  return yargs
298
389
  .positional("sessionId", {
@@ -303,9 +394,10 @@ export async function main() {
303
394
  describe: "Message to send",
304
395
  type: "string",
305
396
  })
306
- .option("timeout", {
307
- describe: "Seconds to wait for the reply (0 = no limit)",
308
- default: 600,
397
+ .option("wait", {
398
+ describe:
399
+ "Seconds to wait for the reply and print it (0 = fire-and-forget, the default)",
400
+ default: 0,
309
401
  type: "number",
310
402
  });
311
403
  },
@@ -316,7 +408,7 @@ export async function main() {
316
408
  DEFAULT_DAEMON_SOCKET,
317
409
  argv.sessionId as string,
318
410
  argv.message as string,
319
- { timeout: argv.timeout as number },
411
+ { wait: argv.wait as number },
320
412
  );
321
413
  },
322
414
  )
@@ -346,7 +438,8 @@ export async function main() {
346
438
  type: "string",
347
439
  })
348
440
  .option("answer", {
349
- describe: "Answers JSON for AskUserQuestion requests",
441
+ describe:
442
+ 'AskUserQuestion answers: JSON object of {question: answer} or option numbers per question (e.g. "0" or "1,0")',
350
443
  type: "string",
351
444
  })
352
445
  .option("rule", {
@@ -394,6 +487,53 @@ export async function main() {
394
487
  );
395
488
  },
396
489
  )
490
+ .command(
491
+ "destroy <sessionId>",
492
+ "Destroy a daemon session (idempotent)",
493
+ (yargs) => {
494
+ return yargs
495
+ .positional("sessionId", {
496
+ describe: "Session ID of the session to destroy",
497
+ type: "string",
498
+ })
499
+ .option("remove-worktree", {
500
+ describe:
501
+ "Also remove the session's git worktree (resolved via git, removed via protocol removeWorktree) before destroying",
502
+ type: "boolean",
503
+ });
504
+ },
505
+ async (argv) => {
506
+ const { daemonDestroyCommand, DEFAULT_DAEMON_SOCKET } =
507
+ await import("./daemon/commands.js");
508
+ await daemonDestroyCommand(
509
+ DEFAULT_DAEMON_SOCKET,
510
+ argv.sessionId as string,
511
+ {
512
+ removeWorktree: argv.removeWorktree as boolean | undefined,
513
+ },
514
+ );
515
+ },
516
+ )
517
+ .command(
518
+ "stop",
519
+ "Gracefully stop the daemon (idempotent — no-op when it is not running)",
520
+ {},
521
+ async () => {
522
+ const { daemonStopCommand, DEFAULT_DAEMON_SOCKET } =
523
+ await import("./daemon/commands.js");
524
+ await daemonStopCommand(DEFAULT_DAEMON_SOCKET);
525
+ },
526
+ )
527
+ .command(
528
+ "restart",
529
+ "Restart the daemon (graceful stop + relaunch; starts one when not running)",
530
+ {},
531
+ async () => {
532
+ const { daemonRestartCommand, DEFAULT_DAEMON_SOCKET } =
533
+ await import("./daemon/commands.js");
534
+ await daemonRestartCommand(DEFAULT_DAEMON_SOCKET);
535
+ },
536
+ )
397
537
  .demandCommand(1, "Please specify a daemon subcommand");
398
538
  },
399
539
  )
@@ -6,6 +6,7 @@ import {
6
6
  InputAction,
7
7
  InputManagerCallbacks,
8
8
  isPasteImageKey,
9
+ permissionModeCycle,
9
10
  } from "./inputReducer.js";
10
11
 
11
12
  export const expandLongTextPlaceholders = (
@@ -136,15 +137,10 @@ export const cyclePermissionMode = (
136
137
  dispatch: React.Dispatch<InputAction>,
137
138
  callbacks: Partial<InputManagerCallbacks>,
138
139
  ) => {
139
- const modes: PermissionMode[] = [
140
- "default",
141
- "acceptEdits",
142
- "bypassPermissions",
143
- "plan",
144
- ];
145
- const currentIndex = modes.indexOf(currentMode);
146
- const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % modes.length;
147
- const nextMode = modes[nextIndex];
140
+ const currentIndex = permissionModeCycle.indexOf(currentMode);
141
+ const nextIndex =
142
+ currentIndex === -1 ? 0 : (currentIndex + 1) % permissionModeCycle.length;
143
+ const nextMode = permissionModeCycle[nextIndex];
148
144
 
149
145
  callbacks.logger?.debug("Cycling permission mode", {
150
146
  from: currentMode,
@@ -372,8 +368,12 @@ export const handleCommandSelect = (
372
368
  dispatch({ type: "SET_SHOW_AGENTS_MANAGER", payload: true });
373
369
  } else if (command === "skills") {
374
370
  dispatch({ type: "SET_SHOW_SKILLS_MANAGER", payload: true });
371
+ } else if (command === "hooks") {
372
+ dispatch({ type: "SET_SHOW_HOOKS_MANAGER", payload: true });
375
373
  } else if (command === "rewind") {
376
374
  dispatch({ type: "SET_SHOW_REWIND_MANAGER", payload: true });
375
+ } else if (command === "resume") {
376
+ dispatch({ type: "SET_SHOW_RESUME_SELECTOR", payload: true });
377
377
  } else if (command === "help") {
378
378
  dispatch({ type: "SET_SHOW_HELP", payload: true });
379
379
  } else if (command === "status") {
@@ -399,8 +399,12 @@ export const handleCommandSelect = (
399
399
  await callbacks.onClearMessages?.();
400
400
  } else if (command === "compact") {
401
401
  await callbacks.onCompact?.();
402
+ } else if (command === "reload-plugins") {
403
+ await callbacks.onReloadPlugins?.();
402
404
  } else if (command === "add-dir") {
403
405
  await callbacks.onAddDir?.();
406
+ } else if (command === "rename") {
407
+ await callbacks.onRenameSession?.();
404
408
  }
405
409
  }
406
410
  })();
@@ -754,6 +758,7 @@ export const handleInput = async (
754
758
  state.showBackgroundTaskManager ||
755
759
  state.showMcpManager ||
756
760
  state.showRewindManager ||
761
+ state.showResumeSelector ||
757
762
  state.showHelp ||
758
763
  state.showStatusCommand ||
759
764
  state.showPluginManager ||
@@ -778,6 +783,7 @@ export const handleInput = async (
778
783
  state.showBackgroundTaskManager ||
779
784
  state.showMcpManager ||
780
785
  state.showRewindManager ||
786
+ state.showResumeSelector ||
781
787
  state.showHelp ||
782
788
  state.showStatusCommand ||
783
789
  state.showPluginManager ||
@@ -788,6 +794,7 @@ export const handleInput = async (
788
794
  state.showBackgroundTaskManager ||
789
795
  state.showMcpManager ||
790
796
  state.showRewindManager ||
797
+ state.showResumeSelector ||
791
798
  state.showHelp ||
792
799
  state.showStatusCommand ||
793
800
  state.showPluginManager ||
@@ -37,6 +37,18 @@ export const btwOverlayActiveRef: { current: boolean } = { current: false };
37
37
 
38
38
  export const ESC_DOUBLE_PRESS_TIMEOUT_MS = 1000;
39
39
 
40
+ /**
41
+ * Shift+Tab 循环的权限模式顺序(对齐 Claude Code:plan 之后才进入
42
+ * bypassPermissions)。单独导出,避免 handler 与 reducer 两条路径各写一份
43
+ * 而漂移。
44
+ */
45
+ export const permissionModeCycle: PermissionMode[] = [
46
+ "default",
47
+ "acceptEdits",
48
+ "plan",
49
+ "bypassPermissions",
50
+ ];
51
+
40
52
  /**
41
53
  * Paste-image shortcut: Ctrl+V everywhere, plus Alt+V on Windows — Windows
42
54
  * terminals reserve Ctrl+V for their own system paste, so the key never
@@ -108,7 +120,10 @@ export interface InputManagerCallbacks {
108
120
  ) => Promise<string>;
109
121
  onClearMessages?: () => Promise<void>;
110
122
  onCompact?: (instructions?: string) => Promise<void>;
123
+ onReloadPlugins?: () => Promise<void>;
111
124
  onAddDir?: (args?: string) => Promise<void>;
125
+ onRenameSession?: (args?: string) => Promise<void>;
126
+ onPlanCommand?: (args?: string) => Promise<void> | void;
112
127
  sessionId?: string;
113
128
  workdir?: string;
114
129
  getFullMessageThread?: () => Promise<{
@@ -141,6 +156,7 @@ export interface InputState {
141
156
  showMcpManager: boolean;
142
157
  showAgentsManager: boolean;
143
158
  showRewindManager: boolean;
159
+ showResumeSelector: boolean;
144
160
  showHelp: boolean;
145
161
  showStatusCommand: boolean;
146
162
  showLoginCommand: boolean;
@@ -148,6 +164,7 @@ export interface InputState {
148
164
  showModelSelector: boolean;
149
165
  showWorkflowManager: boolean;
150
166
  showSkillsManager: boolean;
167
+ showHooksManager: boolean;
151
168
  permissionMode: PermissionMode;
152
169
  selectorJustUsed: boolean;
153
170
  history: PromptEntry[];
@@ -180,6 +197,7 @@ export const initialState: InputState = {
180
197
  showMcpManager: false,
181
198
  showAgentsManager: false,
182
199
  showRewindManager: false,
200
+ showResumeSelector: false,
183
201
  showHelp: false,
184
202
  showStatusCommand: false,
185
203
  showLoginCommand: false,
@@ -187,6 +205,7 @@ export const initialState: InputState = {
187
205
  showModelSelector: false,
188
206
  showWorkflowManager: false,
189
207
  showSkillsManager: false,
208
+ showHooksManager: false,
190
209
  permissionMode: "default",
191
210
  selectorJustUsed: false,
192
211
  history: [],
@@ -462,6 +481,7 @@ export type InputAction =
462
481
  | { type: "SET_SHOW_MCP_MANAGER"; payload: boolean }
463
482
  | { type: "SET_SHOW_AGENTS_MANAGER"; payload: boolean }
464
483
  | { type: "SET_SHOW_REWIND_MANAGER"; payload: boolean }
484
+ | { type: "SET_SHOW_RESUME_SELECTOR"; payload: boolean }
465
485
  | { type: "SET_SHOW_HELP"; payload: boolean }
466
486
  | { type: "SET_SHOW_STATUS_COMMAND"; payload: boolean }
467
487
  | { type: "SET_SHOW_LOGIN_COMMAND"; payload: boolean }
@@ -469,6 +489,7 @@ export type InputAction =
469
489
  | { type: "SET_SHOW_MODEL_SELECTOR"; payload: boolean }
470
490
  | { type: "SET_SHOW_WORKFLOW_MANAGER"; payload: boolean }
471
491
  | { type: "SET_SHOW_SKILLS_MANAGER"; payload: boolean }
492
+ | { type: "SET_SHOW_HOOKS_MANAGER"; payload: boolean }
472
493
  | { type: "SET_PERMISSION_MODE"; payload: PermissionMode }
473
494
  | { type: "SET_SELECTOR_JUST_USED"; payload: boolean }
474
495
  | { type: "INSERT_TEXT_WITH_PLACEHOLDER"; payload: string }
@@ -663,6 +684,12 @@ export function inputReducer(
663
684
  showRewindManager: action.payload,
664
685
  selectorJustUsed: !action.payload ? true : state.selectorJustUsed,
665
686
  };
687
+ case "SET_SHOW_RESUME_SELECTOR":
688
+ return {
689
+ ...state,
690
+ showResumeSelector: action.payload,
691
+ selectorJustUsed: !action.payload ? true : state.selectorJustUsed,
692
+ };
666
693
  case "SET_SHOW_HELP":
667
694
  return {
668
695
  ...state,
@@ -705,6 +732,12 @@ export function inputReducer(
705
732
  showSkillsManager: action.payload,
706
733
  selectorJustUsed: !action.payload ? true : state.selectorJustUsed,
707
734
  };
735
+ case "SET_SHOW_HOOKS_MANAGER":
736
+ return {
737
+ ...state,
738
+ showHooksManager: action.payload,
739
+ selectorJustUsed: !action.payload ? true : state.selectorJustUsed,
740
+ };
708
741
  case "SET_PERMISSION_MODE":
709
742
  return { ...state, permissionMode: action.payload };
710
743
  case "SET_SELECTOR_JUST_USED":
@@ -1066,6 +1099,7 @@ export function inputReducer(
1066
1099
  state.showMcpManager ||
1067
1100
  state.showAgentsManager ||
1068
1101
  state.showRewindManager ||
1102
+ state.showResumeSelector ||
1069
1103
  state.showHelp ||
1070
1104
  state.showStatusCommand ||
1071
1105
  state.showLoginCommand ||
@@ -1113,16 +1147,12 @@ export function inputReducer(
1113
1147
 
1114
1148
  // 3. Special Shortcuts
1115
1149
  if (key.tab && key.shift) {
1116
- const modes: PermissionMode[] = [
1117
- "default",
1118
- "acceptEdits",
1119
- "bypassPermissions",
1120
- "plan",
1121
- ];
1122
- const currentIndex = modes.indexOf(state.permissionMode);
1150
+ const currentIndex = permissionModeCycle.indexOf(state.permissionMode);
1123
1151
  const nextIndex =
1124
- currentIndex === -1 ? 0 : (currentIndex + 1) % modes.length;
1125
- const nextMode = modes[nextIndex];
1152
+ currentIndex === -1
1153
+ ? 0
1154
+ : (currentIndex + 1) % permissionModeCycle.length;
1155
+ const nextMode = permissionModeCycle[nextIndex];
1126
1156
  return {
1127
1157
  ...state,
1128
1158
  permissionMode: nextMode,
@@ -2,12 +2,14 @@ import { Key } from "ink";
2
2
  import type { PermissionDecision, PermissionMode } from "wave-agent-sdk";
3
3
  import {
4
4
  BASH_TOOL_NAME,
5
+ EDIT_TOOL_NAME,
5
6
  EXIT_PLAN_MODE_TOOL_NAME,
6
7
  ENTER_PLAN_MODE_TOOL_NAME,
8
+ WRITE_TOOL_NAME,
7
9
  } from "wave-agent-sdk";
8
10
 
9
11
  export interface ConfirmationState {
10
- selectedOption: "auto" | "bypass" | "allow" | "alternative";
12
+ selectedOption: "auto" | "bypass" | "allow" | "addDirectory" | "alternative";
11
13
  alternativeText: string;
12
14
  alternativeCursorPosition: number;
13
15
  hasUserInput: boolean;
@@ -30,6 +32,7 @@ export type ConfirmationAction =
30
32
  toolInput?: Record<string, unknown>;
31
33
  suggestedPrefix?: string;
32
34
  hidePersistentOption?: boolean;
35
+ outsideSafeZoneDirectory?: string;
33
36
  permissionMode?: PermissionMode;
34
37
  };
35
38
 
@@ -96,6 +99,7 @@ export function confirmationReducer(
96
99
  toolInput,
97
100
  suggestedPrefix,
98
101
  hidePersistentOption,
102
+ outsideSafeZoneDirectory,
99
103
  permissionMode,
100
104
  } = action;
101
105
 
@@ -135,6 +139,16 @@ export function confirmationReducer(
135
139
  behavior: "allow",
136
140
  newPermissionMode: "bypassPermissions",
137
141
  };
142
+ } else if (
143
+ state.selectedOption === "addDirectory" &&
144
+ outsideSafeZoneDirectory
145
+ ) {
146
+ // Approve this operation AND add the out-of-Safe-Zone target
147
+ // directory to this session's Safe Zone (in-memory only).
148
+ decision = {
149
+ behavior: "allow",
150
+ newAdditionalDirectory: outsideSafeZoneDirectory,
151
+ };
138
152
  } else if (state.alternativeText.trim()) {
139
153
  decision = {
140
154
  behavior: "deny",
@@ -176,6 +190,11 @@ export function confirmationReducer(
176
190
 
177
191
  const availableOptions: ConfirmationState["selectedOption"][] = [];
178
192
  availableOptions.push("allow");
193
+ if (
194
+ (toolName === EDIT_TOOL_NAME || toolName === WRITE_TOOL_NAME) &&
195
+ outsideSafeZoneDirectory
196
+ )
197
+ availableOptions.push("addDirectory");
179
198
  if (!hidePersistentOption) availableOptions.push("auto");
180
199
  if (
181
200
  toolName === EXIT_PLAN_MODE_TOOL_NAME ||
@@ -0,0 +1,92 @@
1
+ import { Key } from "ink";
2
+
3
+ export type PendingEffect = { type: "CANCEL" };
4
+
5
+ export interface HooksManagerState {
6
+ selectedIndex: number;
7
+ viewMode: "list" | "detail";
8
+ pendingEffect: PendingEffect | null;
9
+ }
10
+
11
+ export type HooksManagerAction =
12
+ | { type: "MOVE_UP" }
13
+ | { type: "MOVE_DOWN"; itemCount: number }
14
+ | { type: "SET_VIEW_MODE"; viewMode: "list" | "detail" }
15
+ | {
16
+ type: "HANDLE_KEY";
17
+ input: string;
18
+ key: Key;
19
+ itemCount: number;
20
+ }
21
+ | { type: "CLEAR_PENDING_EFFECT" };
22
+
23
+ export function hooksManagerReducer(
24
+ state: HooksManagerState,
25
+ action: HooksManagerAction,
26
+ ): HooksManagerState {
27
+ switch (action.type) {
28
+ case "MOVE_UP":
29
+ return {
30
+ ...state,
31
+ selectedIndex: Math.max(0, state.selectedIndex - 1),
32
+ };
33
+ case "MOVE_DOWN":
34
+ return {
35
+ ...state,
36
+ selectedIndex: Math.min(
37
+ Math.max(0, action.itemCount - 1),
38
+ state.selectedIndex + 1,
39
+ ),
40
+ };
41
+ case "SET_VIEW_MODE":
42
+ return { ...state, viewMode: action.viewMode };
43
+ case "HANDLE_KEY": {
44
+ const { key, itemCount } = action;
45
+
46
+ if (key.return) {
47
+ if (state.viewMode === "list") {
48
+ return { ...state, viewMode: "detail" };
49
+ }
50
+ // Aligned with AgentsManager/SkillsManager detail view: Enter
51
+ // returns to the list.
52
+ return { ...state, viewMode: "list" };
53
+ }
54
+
55
+ if (key.escape) {
56
+ if (state.viewMode === "detail") {
57
+ return { ...state, viewMode: "list" };
58
+ }
59
+ return { ...state, pendingEffect: { type: "CANCEL" } };
60
+ }
61
+
62
+ // Detail view does not respond to arrow keys (aligned with
63
+ // AgentsManager/SkillsManager).
64
+ if (state.viewMode === "detail") {
65
+ return state;
66
+ }
67
+
68
+ if (key.upArrow) {
69
+ return {
70
+ ...state,
71
+ selectedIndex: Math.max(0, state.selectedIndex - 1),
72
+ };
73
+ }
74
+
75
+ if (key.downArrow) {
76
+ return {
77
+ ...state,
78
+ selectedIndex: Math.min(
79
+ Math.max(0, itemCount - 1),
80
+ state.selectedIndex + 1,
81
+ ),
82
+ };
83
+ }
84
+
85
+ return state;
86
+ }
87
+ case "CLEAR_PENDING_EFFECT":
88
+ return { ...state, pendingEffect: null };
89
+ default:
90
+ return state;
91
+ }
92
+ }
@@ -15,6 +15,7 @@ export type SelectorAction<T = unknown> =
15
15
  | { type: "MOVE_DOWN" }
16
16
  | { type: "RESET_INDEX" }
17
17
  | { type: "SET_ITEMS"; items: T[] }
18
+ | { type: "SET_INDEX"; index: number }
18
19
  | { type: "HANDLE_KEY"; key: Key; hasInsert: boolean }
19
20
  | { type: "TOGGLE_ALL_PROJECTS" }
20
21
  | { type: "TOGGLE_ALL_WORKTREES" }
@@ -39,6 +40,8 @@ export function selectorReducer<T = unknown>(
39
40
  return { ...state, selectedIndex: 0 };
40
41
  case "SET_ITEMS":
41
42
  return { ...state, items: action.items, selectedIndex: 0 };
43
+ case "SET_INDEX":
44
+ return { ...state, selectedIndex: action.index };
42
45
  case "TOGGLE_ALL_PROJECTS":
43
46
  return { ...state, showAllProjects: !state.showAllProjects };
44
47
  case "TOGGLE_ALL_WORKTREES":