wave-code 0.14.4 → 0.15.1

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 (116) hide show
  1. package/dist/acp/agent.d.ts.map +1 -1
  2. package/dist/acp/agent.js +10 -0
  3. package/dist/components/BackgroundTaskManager.d.ts.map +1 -1
  4. package/dist/components/BackgroundTaskManager.js +17 -53
  5. package/dist/components/CommandSelector.d.ts.map +1 -1
  6. package/dist/components/CommandSelector.js +38 -28
  7. package/dist/components/ConfirmationSelector.d.ts.map +1 -1
  8. package/dist/components/ConfirmationSelector.js +18 -196
  9. package/dist/components/DiscoverView.d.ts.map +1 -1
  10. package/dist/components/DiscoverView.js +19 -11
  11. package/dist/components/FileSelector.d.ts.map +1 -1
  12. package/dist/components/FileSelector.js +29 -25
  13. package/dist/components/HelpView.d.ts.map +1 -1
  14. package/dist/components/HelpView.js +24 -28
  15. package/dist/components/HistorySearch.d.ts.map +1 -1
  16. package/dist/components/HistorySearch.js +26 -28
  17. package/dist/components/InputBox.d.ts.map +1 -1
  18. package/dist/components/InputBox.js +14 -6
  19. package/dist/components/InstalledView.d.ts.map +1 -1
  20. package/dist/components/InstalledView.js +19 -11
  21. package/dist/components/MarketplaceDetail.d.ts.map +1 -1
  22. package/dist/components/MarketplaceDetail.js +35 -16
  23. package/dist/components/MarketplaceView.d.ts.map +1 -1
  24. package/dist/components/MarketplaceView.js +21 -12
  25. package/dist/components/McpManager.d.ts.map +1 -1
  26. package/dist/components/McpManager.js +27 -46
  27. package/dist/components/ModelSelector.d.ts.map +1 -1
  28. package/dist/components/ModelSelector.js +27 -17
  29. package/dist/components/PluginDetail.d.ts.map +1 -1
  30. package/dist/components/PluginDetail.js +39 -26
  31. package/dist/components/RewindCommand.d.ts.map +1 -1
  32. package/dist/components/RewindCommand.js +27 -21
  33. package/dist/components/SessionSelector.d.ts.map +1 -1
  34. package/dist/components/SessionSelector.js +21 -16
  35. package/dist/components/StatusLine.d.ts.map +1 -1
  36. package/dist/components/StatusLine.js +5 -1
  37. package/dist/components/WorktreeExitPrompt.d.ts.map +1 -1
  38. package/dist/components/WorktreeExitPrompt.js +21 -12
  39. package/dist/contexts/useChat.d.ts +0 -1
  40. package/dist/contexts/useChat.d.ts.map +1 -1
  41. package/dist/contexts/useChat.js +13 -20
  42. package/dist/hooks/useInputManager.d.ts +5 -19
  43. package/dist/hooks/useInputManager.d.ts.map +1 -1
  44. package/dist/hooks/useInputManager.js +222 -79
  45. package/dist/managers/inputHandlers.d.ts +1 -1
  46. package/dist/managers/inputHandlers.d.ts.map +1 -1
  47. package/dist/managers/inputHandlers.js +3 -6
  48. package/dist/managers/inputReducer.d.ts +46 -4
  49. package/dist/managers/inputReducer.d.ts.map +1 -1
  50. package/dist/managers/inputReducer.js +519 -4
  51. package/dist/print-cli.d.ts.map +1 -1
  52. package/dist/print-cli.js +21 -9
  53. package/dist/reducers/backgroundTaskManagerReducer.d.ts +20 -0
  54. package/dist/reducers/backgroundTaskManagerReducer.d.ts.map +1 -1
  55. package/dist/reducers/backgroundTaskManagerReducer.js +83 -0
  56. package/dist/reducers/confirmationReducer.d.ts +11 -0
  57. package/dist/reducers/confirmationReducer.d.ts.map +1 -1
  58. package/dist/reducers/confirmationReducer.js +146 -0
  59. package/dist/reducers/helpSelectorReducer.d.ts +26 -0
  60. package/dist/reducers/helpSelectorReducer.d.ts.map +1 -0
  61. package/dist/reducers/helpSelectorReducer.js +52 -0
  62. package/dist/reducers/mcpManagerReducer.d.ts +22 -0
  63. package/dist/reducers/mcpManagerReducer.d.ts.map +1 -1
  64. package/dist/reducers/mcpManagerReducer.js +57 -0
  65. package/dist/reducers/pluginDetailReducer.d.ts +8 -0
  66. package/dist/reducers/pluginDetailReducer.d.ts.map +1 -1
  67. package/dist/reducers/pluginDetailReducer.js +32 -0
  68. package/dist/reducers/questionReducer.d.ts +21 -0
  69. package/dist/reducers/questionReducer.d.ts.map +1 -1
  70. package/dist/reducers/questionReducer.js +177 -0
  71. package/dist/reducers/rewindSelectorReducer.d.ts +22 -0
  72. package/dist/reducers/rewindSelectorReducer.d.ts.map +1 -0
  73. package/dist/reducers/rewindSelectorReducer.js +37 -0
  74. package/dist/reducers/selectorReducer.d.ts +22 -0
  75. package/dist/reducers/selectorReducer.d.ts.map +1 -0
  76. package/dist/reducers/selectorReducer.js +40 -0
  77. package/dist/utils/inputUtils.d.ts +18 -0
  78. package/dist/utils/inputUtils.d.ts.map +1 -0
  79. package/dist/utils/inputUtils.js +67 -0
  80. package/dist/utils/worktree.d.ts.map +1 -1
  81. package/dist/utils/worktree.js +1 -2
  82. package/package.json +2 -2
  83. package/src/acp/agent.ts +10 -0
  84. package/src/components/BackgroundTaskManager.tsx +19 -58
  85. package/src/components/CommandSelector.tsx +53 -44
  86. package/src/components/ConfirmationSelector.tsx +18 -202
  87. package/src/components/DiscoverView.tsx +21 -11
  88. package/src/components/FileSelector.tsx +40 -35
  89. package/src/components/HelpView.tsx +37 -43
  90. package/src/components/HistorySearch.tsx +29 -36
  91. package/src/components/InputBox.tsx +14 -7
  92. package/src/components/InstalledView.tsx +21 -11
  93. package/src/components/MarketplaceDetail.tsx +48 -24
  94. package/src/components/MarketplaceView.tsx +25 -10
  95. package/src/components/McpManager.tsx +29 -54
  96. package/src/components/ModelSelector.tsx +30 -23
  97. package/src/components/PluginDetail.tsx +50 -27
  98. package/src/components/RewindCommand.tsx +38 -32
  99. package/src/components/SessionSelector.tsx +23 -20
  100. package/src/components/StatusLine.tsx +10 -1
  101. package/src/components/WorktreeExitPrompt.tsx +23 -13
  102. package/src/contexts/useChat.tsx +13 -31
  103. package/src/hooks/useInputManager.ts +273 -141
  104. package/src/managers/inputHandlers.ts +1 -6
  105. package/src/managers/inputReducer.ts +675 -7
  106. package/src/print-cli.ts +20 -11
  107. package/src/reducers/backgroundTaskManagerReducer.ts +110 -1
  108. package/src/reducers/confirmationReducer.ts +173 -1
  109. package/src/reducers/helpSelectorReducer.ts +77 -0
  110. package/src/reducers/mcpManagerReducer.ts +82 -1
  111. package/src/reducers/pluginDetailReducer.ts +42 -1
  112. package/src/reducers/questionReducer.ts +204 -0
  113. package/src/reducers/rewindSelectorReducer.ts +54 -0
  114. package/src/reducers/selectorReducer.ts +57 -0
  115. package/src/utils/inputUtils.ts +94 -0
  116. package/src/utils/worktree.ts +1 -7
@@ -1,14 +1,18 @@
1
- import React, { useState } from "react";
1
+ import React, { useReducer, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import type { SlashCommand } from "wave-agent-sdk";
4
4
  import { AVAILABLE_COMMANDS } from "../constants/commands.js";
5
+ import {
6
+ selectorReducer,
7
+ type SelectorState,
8
+ } from "../reducers/selectorReducer.js";
5
9
 
6
10
  export interface CommandSelectorProps {
7
11
  searchQuery: string;
8
12
  onSelect: (command: string) => void;
9
- onInsert?: (command: string) => void; // New: Tab key to insert command into input box
13
+ onInsert?: (command: string) => void;
10
14
  onCancel: () => void;
11
- commands?: SlashCommand[]; // New optional command list parameter
15
+ commands?: SlashCommand[];
12
16
  }
13
17
 
14
18
  export const CommandSelector: React.FC<CommandSelectorProps> = ({
@@ -16,14 +20,19 @@ export const CommandSelector: React.FC<CommandSelectorProps> = ({
16
20
  onSelect,
17
21
  onInsert,
18
22
  onCancel,
19
- commands = [], // Default to empty array
23
+ commands = [],
20
24
  }) => {
21
25
  const MAX_VISIBLE_ITEMS = 3;
22
- const [selectedIndex, setSelectedIndex] = useState(0);
26
+ const [state, dispatch] = useReducer(selectorReducer, {
27
+ selectedIndex: 0,
28
+ pendingDecision: null,
29
+ } as SelectorState);
30
+
31
+ const { selectedIndex, pendingDecision } = state;
23
32
 
24
33
  // Reset selected index when search query changes
25
- React.useEffect(() => {
26
- setSelectedIndex(0);
34
+ useEffect(() => {
35
+ dispatch({ type: "RESET_INDEX" });
27
36
  }, [searchQuery]);
28
37
 
29
38
  // Merge agent commands and local commands
@@ -36,58 +45,58 @@ export const CommandSelector: React.FC<CommandSelectorProps> = ({
36
45
  command.id.toLowerCase().includes(searchQuery.toLowerCase()),
37
46
  );
38
47
 
39
- // Calculate visible window
40
- const startIndex = Math.max(
41
- 0,
42
- Math.min(
43
- selectedIndex - Math.floor(MAX_VISIBLE_ITEMS / 2),
44
- Math.max(0, filteredCommands.length - MAX_VISIBLE_ITEMS),
45
- ),
46
- );
47
- const visibleCommands = filteredCommands.slice(
48
- startIndex,
49
- startIndex + MAX_VISIBLE_ITEMS,
50
- );
48
+ // Handle decisions from reducer
49
+ useEffect(() => {
50
+ if (!pendingDecision) return;
51
51
 
52
- useInput((input, key) => {
53
- if (key.return) {
52
+ if (pendingDecision === "select") {
54
53
  if (
55
54
  filteredCommands.length > 0 &&
56
55
  selectedIndex < filteredCommands.length
57
56
  ) {
58
- const selectedCommand = filteredCommands[selectedIndex].id;
59
- onSelect(selectedCommand);
57
+ onSelect(filteredCommands[selectedIndex].id);
60
58
  }
61
- return;
62
- }
63
-
64
- if (key.tab && onInsert) {
59
+ } else if (pendingDecision === "insert" && onInsert) {
65
60
  if (
66
61
  filteredCommands.length > 0 &&
67
62
  selectedIndex < filteredCommands.length
68
63
  ) {
69
- const selectedCommand = filteredCommands[selectedIndex].id;
70
- onInsert(selectedCommand);
64
+ onInsert(filteredCommands[selectedIndex].id);
71
65
  }
72
- return;
73
- }
74
-
75
- if (key.escape) {
66
+ } else if (pendingDecision === "cancel") {
76
67
  onCancel();
77
- return;
78
68
  }
79
69
 
80
- if (key.upArrow) {
81
- setSelectedIndex(Math.max(0, selectedIndex - 1));
82
- return;
83
- }
70
+ dispatch({ type: "CLEAR_DECISION" });
71
+ }, [
72
+ pendingDecision,
73
+ selectedIndex,
74
+ filteredCommands,
75
+ onSelect,
76
+ onInsert,
77
+ onCancel,
78
+ ]);
84
79
 
85
- if (key.downArrow) {
86
- setSelectedIndex(
87
- Math.min(filteredCommands.length - 1, selectedIndex + 1),
88
- );
89
- return;
90
- }
80
+ // Calculate visible window
81
+ const startIndex = Math.max(
82
+ 0,
83
+ Math.min(
84
+ selectedIndex - Math.floor(MAX_VISIBLE_ITEMS / 2),
85
+ Math.max(0, filteredCommands.length - MAX_VISIBLE_ITEMS),
86
+ ),
87
+ );
88
+ const visibleCommands = filteredCommands.slice(
89
+ startIndex,
90
+ startIndex + MAX_VISIBLE_ITEMS,
91
+ );
92
+
93
+ useInput((input, key) => {
94
+ dispatch({
95
+ type: "HANDLE_KEY",
96
+ key,
97
+ maxIndex: filteredCommands.length - 1,
98
+ hasInsert: !!onInsert,
99
+ });
91
100
  });
92
101
 
93
102
  if (filteredCommands.length === 0) {
@@ -7,10 +7,7 @@ import {
7
7
  ENTER_PLAN_MODE_TOOL_NAME,
8
8
  ASK_USER_QUESTION_TOOL_NAME,
9
9
  } from "wave-agent-sdk";
10
- import {
11
- confirmationReducer,
12
- type ConfirmationState,
13
- } from "../reducers/confirmationReducer.js";
10
+ import { confirmationReducer } from "../reducers/confirmationReducer.js";
14
11
  import { questionReducer } from "../reducers/questionReducer.js";
15
12
 
16
13
  const getHeaderColor = (header: string) => {
@@ -67,12 +64,14 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
67
64
  useEffect(() => {
68
65
  if (state.decision) {
69
66
  onDecision(state.decision);
67
+ dispatch({ type: "CLEAR_DECISION" });
70
68
  }
71
69
  }, [state.decision, onDecision]);
72
70
 
73
71
  useEffect(() => {
74
72
  if (questionState.decision) {
75
73
  onDecision(questionState.decision);
74
+ questionDispatch({ type: "CLEAR_DECISION" });
76
75
  }
77
76
  }, [questionState.decision, onDecision]);
78
77
 
@@ -105,206 +104,23 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
105
104
  }
106
105
 
107
106
  if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
108
- if (!currentQuestion) return;
109
- const options = [...currentQuestion.options, { label: "Other" }];
110
- const isMultiSelect = currentQuestion.multiSelect;
111
-
112
- if (key.return) {
113
- questionDispatch({
114
- type: "CONFIRM_ANSWER",
115
- currentQuestion,
116
- options,
117
- isMultiSelect: !!isMultiSelect,
118
- questions,
119
- });
120
- return;
121
- }
122
-
123
- if (input === " ") {
124
- const isOtherFocused =
125
- questionState.selectedOptionIndex === options.length - 1;
126
- if (
127
- isMultiSelect &&
128
- (!isOtherFocused ||
129
- !questionState.selectedOptionIndices.has(
130
- questionState.selectedOptionIndex,
131
- ))
132
- ) {
133
- questionDispatch({
134
- type: "TOGGLE_MULTI_SELECT",
135
- optionsLength: options.length,
136
- });
137
- return;
138
- }
139
- // If it's other and focused, we don't return here, allowing the input handler below to handle it
140
- }
141
-
142
- if (key.upArrow) {
143
- questionDispatch({
144
- type: "MOVE_OPTION_UP",
145
- maxIndex: options.length - 1,
146
- });
147
- return;
148
- }
149
- if (key.downArrow) {
150
- questionDispatch({
151
- type: "MOVE_OPTION_DOWN",
152
- maxIndex: options.length - 1,
153
- });
154
- return;
155
- }
156
- if (key.tab) {
157
- questionDispatch({
158
- type: "CYCLE_QUESTION",
159
- shift: key.shift,
160
- questionCount: questions.length,
161
- });
162
- return;
163
- }
164
-
165
- // Always dispatch Other actions unconditionally; the reducer checks
166
- // isOtherFocused from the latest state, so this works correctly even
167
- // when inputs are batched before React re-renders.
168
- if (key.leftArrow) {
169
- questionDispatch({
170
- type: "MOVE_OTHER_LEFT",
171
- optionsLength: options.length,
172
- });
173
- return;
174
- }
175
- if (key.rightArrow) {
176
- questionDispatch({
177
- type: "MOVE_OTHER_RIGHT",
178
- optionsLength: options.length,
179
- });
180
- return;
181
- }
182
- if (key.backspace || key.delete) {
183
- questionDispatch({
184
- type: "DELETE_OTHER",
185
- optionsLength: options.length,
186
- });
187
- return;
188
- }
189
- if (input && !key.ctrl && !key.meta) {
190
- questionDispatch({
191
- type: "INSERT_OTHER",
192
- text: input,
193
- optionsLength: options.length,
194
- });
195
- return;
196
- }
197
- return;
198
- }
199
-
200
- if (key.return) {
201
- let decision: PermissionDecision | null = null;
202
- if (state.selectedOption === "clear") {
203
- decision = {
204
- behavior: "allow",
205
- newPermissionMode: "acceptEdits",
206
- clearContext: true,
207
- };
208
- } else if (state.selectedOption === "allow") {
209
- if (toolName === EXIT_PLAN_MODE_TOOL_NAME) {
210
- decision = { behavior: "allow", newPermissionMode: "default" };
211
- } else if (toolName === ENTER_PLAN_MODE_TOOL_NAME) {
212
- decision = { behavior: "allow", newPermissionMode: "plan" };
213
- } else {
214
- decision = { behavior: "allow" };
215
- }
216
- } else if (state.selectedOption === "auto") {
217
- if (toolName === BASH_TOOL_NAME) {
218
- const command = (toolInput?.command as string) || "";
219
- if (command.trim().startsWith("mkdir")) {
220
- decision = { behavior: "allow", newPermissionMode: "acceptEdits" };
221
- } else {
222
- const rule = suggestedPrefix
223
- ? `Bash(${suggestedPrefix})`
224
- : `Bash(${toolInput?.command})`;
225
- decision = { behavior: "allow", newPermissionRule: rule };
226
- }
227
- } else if (toolName === ENTER_PLAN_MODE_TOOL_NAME) {
228
- decision = { behavior: "allow", newPermissionMode: "plan" };
229
- } else if (toolName.startsWith("mcp__")) {
230
- decision = { behavior: "allow", newPermissionRule: toolName };
231
- } else {
232
- decision = { behavior: "allow", newPermissionMode: "acceptEdits" };
233
- }
234
- } else if (state.alternativeText.trim()) {
235
- decision = { behavior: "deny", message: state.alternativeText.trim() };
236
- } else if (toolName === ENTER_PLAN_MODE_TOOL_NAME) {
237
- decision = {
238
- behavior: "deny",
239
- message: "User chose not to enter plan mode",
240
- };
241
- }
242
- if (decision) {
243
- dispatch({ type: "CONFIRM", decision });
244
- }
245
- return;
246
- }
247
-
248
- if (state.selectedOption === "alternative") {
249
- if (key.leftArrow) {
250
- dispatch({ type: "MOVE_CURSOR_LEFT" });
251
- return;
252
- }
253
- if (key.rightArrow) {
254
- dispatch({ type: "MOVE_CURSOR_RIGHT" });
255
- return;
256
- }
257
- }
258
-
259
- const availableOptions: ConfirmationState["selectedOption"][] = [];
260
- if (toolName === EXIT_PLAN_MODE_TOOL_NAME) availableOptions.push("clear");
261
- availableOptions.push("allow");
262
- if (!hidePersistentOption) availableOptions.push("auto");
263
- availableOptions.push("alternative");
264
-
265
- if (key.upArrow) {
266
- const currentIndex = availableOptions.indexOf(state.selectedOption);
267
- if (currentIndex > 0) {
268
- dispatch({
269
- type: "SELECT_OPTION",
270
- option: availableOptions[currentIndex - 1],
271
- });
272
- }
273
- return;
274
- }
275
-
276
- if (key.downArrow) {
277
- const currentIndex = availableOptions.indexOf(state.selectedOption);
278
- if (currentIndex < availableOptions.length - 1) {
279
- dispatch({
280
- type: "SELECT_OPTION",
281
- option: availableOptions[currentIndex + 1],
282
- });
283
- }
284
- return;
285
- }
286
-
287
- if (key.tab) {
288
- const currentIndex = availableOptions.indexOf(state.selectedOption);
289
- const direction = key.shift ? -1 : 1;
290
- let nextIndex = currentIndex + direction;
291
- if (nextIndex < 0) nextIndex = availableOptions.length - 1;
292
- if (nextIndex >= availableOptions.length) nextIndex = 0;
107
+ questionDispatch({
108
+ type: "HANDLE_KEY",
109
+ input,
110
+ key,
111
+ currentQuestion,
112
+ questions,
113
+ });
114
+ } else {
293
115
  dispatch({
294
- type: "SELECT_OPTION",
295
- option: availableOptions[nextIndex],
116
+ type: "HANDLE_KEY",
117
+ input,
118
+ key,
119
+ toolName,
120
+ toolInput,
121
+ suggestedPrefix,
122
+ hidePersistentOption,
296
123
  });
297
- return;
298
- }
299
-
300
- if (input && !key.ctrl && !key.meta && !("alt" in key && key.alt)) {
301
- dispatch({ type: "INSERT_TEXT", text: input });
302
- return;
303
- }
304
-
305
- if (key.backspace || key.delete) {
306
- dispatch({ type: "BACKSPACE" });
307
- return;
308
124
  }
309
125
  });
310
126
 
@@ -1,27 +1,37 @@
1
- import React, { useState } from "react";
1
+ import React, { useReducer, useEffect } from "react";
2
2
  import { Box, useInput } from "ink";
3
3
  import { usePluginManagerContext } from "../contexts/PluginManagerContext.js";
4
4
  import { PluginList } from "./PluginList.js";
5
+ import { selectorReducer } from "../reducers/selectorReducer.js";
5
6
 
6
7
  export const DiscoverView: React.FC = () => {
7
8
  const { discoverablePlugins, actions } = usePluginManagerContext();
8
- const [selectedIndex, setSelectedIndex] = useState(0);
9
+ const [state, dispatch] = useReducer(selectorReducer, {
10
+ selectedIndex: 0,
11
+ pendingDecision: null,
12
+ });
13
+
14
+ const { selectedIndex, pendingDecision } = state;
9
15
 
10
- useInput((input, key) => {
11
- if (key.upArrow) {
12
- setSelectedIndex(Math.max(0, selectedIndex - 1));
13
- } else if (key.downArrow) {
14
- setSelectedIndex(
15
- Math.min(discoverablePlugins.length - 1, selectedIndex + 1),
16
- );
17
- } else if (key.return) {
16
+ useInput((_input, key) => {
17
+ dispatch({
18
+ type: "HANDLE_KEY",
19
+ key,
20
+ maxIndex: discoverablePlugins.length - 1,
21
+ hasInsert: false,
22
+ });
23
+ });
24
+
25
+ useEffect(() => {
26
+ if (pendingDecision === "select") {
18
27
  const plugin = discoverablePlugins[selectedIndex];
19
28
  if (plugin) {
20
29
  actions.setSelectedId(`${plugin.name}@${plugin.marketplace}`);
21
30
  actions.setView("PLUGIN_DETAIL");
22
31
  }
32
+ dispatch({ type: "CLEAR_DECISION" });
23
33
  }
24
- });
34
+ }, [pendingDecision, selectedIndex, discoverablePlugins, actions]);
25
35
 
26
36
  return (
27
37
  <Box flexDirection="column">
@@ -1,6 +1,10 @@
1
- import React, { useState } from "react";
1
+ import React, { useReducer, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import type { FileItem } from "wave-agent-sdk";
4
+ import {
5
+ selectorReducer,
6
+ type SelectorState,
7
+ } from "../reducers/selectorReducer.js";
4
8
 
5
9
  export { type FileItem } from "wave-agent-sdk";
6
10
 
@@ -19,30 +23,40 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
19
23
  onSelect,
20
24
  onCancel,
21
25
  }) => {
22
- const [selectedIndex, setSelectedIndex] = useState(0);
26
+ const [state, dispatch] = useReducer(selectorReducer, {
27
+ selectedIndex: 0,
28
+ pendingDecision: null,
29
+ } as SelectorState);
23
30
 
24
- useInput((input, key) => {
25
- if (key.return || key.tab) {
31
+ const { selectedIndex, pendingDecision } = state;
32
+
33
+ // Reset selected index when files change
34
+ useEffect(() => {
35
+ dispatch({ type: "RESET_INDEX" });
36
+ }, [files]);
37
+
38
+ // Handle decisions from reducer
39
+ useEffect(() => {
40
+ if (!pendingDecision) return;
41
+
42
+ if (pendingDecision === "select" || pendingDecision === "insert") {
26
43
  if (files.length > 0 && selectedIndex < files.length) {
27
44
  onSelect(files[selectedIndex].path);
28
45
  }
29
- return;
30
- }
31
-
32
- if (key.escape) {
46
+ } else if (pendingDecision === "cancel") {
33
47
  onCancel();
34
- return;
35
48
  }
36
49
 
37
- if (key.upArrow) {
38
- setSelectedIndex(Math.max(0, selectedIndex - 1));
39
- return;
40
- }
50
+ dispatch({ type: "CLEAR_DECISION" });
51
+ }, [pendingDecision, selectedIndex, files, onSelect, onCancel]);
41
52
 
42
- if (key.downArrow) {
43
- setSelectedIndex(Math.min(files.length - 1, selectedIndex + 1));
44
- return;
45
- }
53
+ useInput((input, key) => {
54
+ dispatch({
55
+ type: "HANDLE_KEY",
56
+ key,
57
+ maxIndex: files.length - 1,
58
+ hasInsert: true, // For FileSelector, Tab is also select
59
+ });
46
60
  });
47
61
 
48
62
  if (files.length === 0) {
@@ -72,24 +86,15 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
72
86
  const maxDisplay = 5;
73
87
 
74
88
  // Calculate display window start and end positions
75
- const getDisplayWindow = () => {
76
- const startIndex = Math.max(
77
- 0,
78
- Math.min(
79
- selectedIndex - Math.floor(maxDisplay / 2),
80
- Math.max(0, files.length - maxDisplay),
81
- ),
82
- );
83
- const endIndex = Math.min(files.length, startIndex + maxDisplay);
84
-
85
- return {
86
- startIndex,
87
- endIndex,
88
- displayFiles: files.slice(startIndex, endIndex),
89
- };
90
- };
91
-
92
- const { startIndex, displayFiles } = getDisplayWindow();
89
+ const startIndex = Math.max(
90
+ 0,
91
+ Math.min(
92
+ selectedIndex - Math.floor(maxDisplay / 2),
93
+ Math.max(0, files.length - maxDisplay),
94
+ ),
95
+ );
96
+ const endIndex = Math.min(files.length, startIndex + maxDisplay);
97
+ const displayFiles = files.slice(startIndex, endIndex);
93
98
 
94
99
  return (
95
100
  <Box
@@ -1,7 +1,8 @@
1
- import React, { useState } from "react";
1
+ import React, { useReducer, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import type { SlashCommand } from "wave-agent-sdk";
4
4
  import { AVAILABLE_COMMANDS } from "../constants/commands.js";
5
+ import { helpSelectorReducer } from "../reducers/helpSelectorReducer.js";
5
6
 
6
7
  export interface HelpViewProps {
7
8
  onCancel: () => void;
@@ -12,10 +13,6 @@ export const HelpView: React.FC<HelpViewProps> = ({
12
13
  onCancel,
13
14
  commands = [],
14
15
  }) => {
15
- const [activeTab, setActiveTab] = useState<
16
- "general" | "commands" | "custom-commands"
17
- >("general");
18
- const [selectedIndex, setSelectedIndex] = useState(0);
19
16
  const MAX_VISIBLE_ITEMS = 10;
20
17
 
21
18
  const tabs: ("general" | "commands" | "custom-commands")[] = [
@@ -26,33 +23,45 @@ export const HelpView: React.FC<HelpViewProps> = ({
26
23
  tabs.push("custom-commands");
27
24
  }
28
25
 
29
- useInput((_, key) => {
30
- if (key.escape) {
26
+ const [state, dispatch] = useReducer(helpSelectorReducer, {
27
+ selectedIndex: 0,
28
+ activeTab: "general",
29
+ pendingDecision: null,
30
+ });
31
+
32
+ const { selectedIndex, activeTab, pendingDecision } = state;
33
+
34
+ // Handle decisions from reducer
35
+ useEffect(() => {
36
+ if (pendingDecision === "cancel") {
31
37
  onCancel();
32
- return;
38
+ dispatch({ type: "CLEAR_DECISION" });
33
39
  }
40
+ }, [pendingDecision, onCancel]);
34
41
 
35
- if (key.tab) {
36
- setActiveTab((prev) => {
37
- const currentIndex = tabs.indexOf(prev);
38
- const nextIndex = (currentIndex + 1) % tabs.length;
39
- return tabs[nextIndex];
40
- });
41
- setSelectedIndex(0);
42
- return;
43
- }
42
+ const currentCommands =
43
+ activeTab === "commands" ? AVAILABLE_COMMANDS : commands;
44
44
 
45
- if (activeTab === "commands" || activeTab === "custom-commands") {
46
- const currentCommands =
47
- activeTab === "commands" ? AVAILABLE_COMMANDS : commands;
48
- if (key.upArrow) {
49
- setSelectedIndex((prev) => Math.max(0, prev - 1));
50
- } else if (key.downArrow) {
51
- setSelectedIndex((prev) =>
52
- Math.min(currentCommands.length - 1, prev + 1),
53
- );
54
- }
55
- }
45
+ // Calculate visible window for commands
46
+ const startIndex = Math.max(
47
+ 0,
48
+ Math.min(
49
+ selectedIndex - Math.floor(MAX_VISIBLE_ITEMS / 2),
50
+ Math.max(0, currentCommands.length - MAX_VISIBLE_ITEMS),
51
+ ),
52
+ );
53
+ const visibleCommands = currentCommands.slice(
54
+ startIndex,
55
+ startIndex + MAX_VISIBLE_ITEMS,
56
+ );
57
+
58
+ useInput((_, key) => {
59
+ dispatch({
60
+ type: "HANDLE_KEY",
61
+ key,
62
+ maxIndex: activeTab === "general" ? 0 : currentCommands.length - 1,
63
+ tabs,
64
+ });
56
65
  });
57
66
 
58
67
  const helpItems = [
@@ -72,21 +81,6 @@ export const HelpView: React.FC<HelpViewProps> = ({
72
81
  },
73
82
  ];
74
83
 
75
- // Calculate visible window for commands
76
- const currentCommands =
77
- activeTab === "commands" ? AVAILABLE_COMMANDS : commands;
78
- const startIndex = Math.max(
79
- 0,
80
- Math.min(
81
- selectedIndex - Math.floor(MAX_VISIBLE_ITEMS / 2),
82
- Math.max(0, currentCommands.length - MAX_VISIBLE_ITEMS),
83
- ),
84
- );
85
- const visibleCommands = currentCommands.slice(
86
- startIndex,
87
- startIndex + MAX_VISIBLE_ITEMS,
88
- );
89
-
90
84
  const footerText = [
91
85
  "Tab switch",
92
86
  activeTab !== "general" && "↑↓ navigate",