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,6 +1,10 @@
1
- import React, { useState, useEffect } from "react";
1
+ import React, { useReducer, useEffect, useState } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { PromptHistoryManager, type PromptEntry } from "wave-agent-sdk";
4
+ import {
5
+ selectorReducer,
6
+ type SelectorState,
7
+ } from "../reducers/selectorReducer.js";
4
8
 
5
9
  export interface HistorySearchProps {
6
10
  searchQuery: string;
@@ -14,57 +18,46 @@ export const HistorySearch: React.FC<HistorySearchProps> = ({
14
18
  onCancel,
15
19
  }) => {
16
20
  const MAX_VISIBLE_ITEMS = 5;
17
- const [selectedIndex, setSelectedIndex] = useState(0);
21
+ const [state, dispatch] = useReducer(selectorReducer, {
22
+ selectedIndex: 0,
23
+ pendingDecision: null,
24
+ } as SelectorState);
18
25
  const [entries, setEntries] = useState<PromptEntry[]>([]);
19
26
 
20
- const entriesRef = React.useRef<PromptEntry[]>([]);
21
- const selectedIndexRef = React.useRef(0);
22
-
23
- useEffect(() => {
24
- entriesRef.current = entries;
25
- }, [entries]);
26
-
27
- useEffect(() => {
28
- selectedIndexRef.current = selectedIndex;
29
- }, [selectedIndex]);
27
+ const { selectedIndex, pendingDecision } = state;
30
28
 
31
29
  useEffect(() => {
32
30
  const fetchHistory = async () => {
33
31
  const results = await PromptHistoryManager.searchHistory(searchQuery);
34
32
  const limitedResults = results.slice(0, 20);
35
- setEntries(limitedResults); // Limit to 20 results
36
- setSelectedIndex(0);
33
+ setEntries(limitedResults);
34
+ dispatch({ type: "RESET_INDEX" });
37
35
  };
38
36
  fetchHistory();
39
37
  }, [searchQuery]);
40
38
 
41
- useInput((input, key) => {
42
- if (key.return) {
43
- if (
44
- entriesRef.current.length > 0 &&
45
- selectedIndexRef.current < entriesRef.current.length
46
- ) {
47
- onSelect(entriesRef.current[selectedIndexRef.current]);
48
- }
49
- return;
50
- }
39
+ // Handle decisions from reducer
40
+ useEffect(() => {
41
+ if (!pendingDecision) return;
51
42
 
52
- if (key.escape) {
43
+ if (pendingDecision === "select") {
44
+ if (entries.length > 0 && selectedIndex < entries.length) {
45
+ onSelect(entries[selectedIndex]);
46
+ }
47
+ } else if (pendingDecision === "cancel") {
53
48
  onCancel();
54
- return;
55
49
  }
56
50
 
57
- if (key.upArrow) {
58
- setSelectedIndex((prev) => Math.max(0, prev - 1));
59
- return;
60
- }
51
+ dispatch({ type: "CLEAR_DECISION" });
52
+ }, [pendingDecision, selectedIndex, entries, onSelect, onCancel]);
61
53
 
62
- if (key.downArrow) {
63
- setSelectedIndex((prev) =>
64
- Math.min(entriesRef.current.length - 1, prev + 1),
65
- );
66
- return;
67
- }
54
+ useInput((input, key) => {
55
+ dispatch({
56
+ type: "HANDLE_KEY",
57
+ key,
58
+ maxIndex: entries.length - 1,
59
+ hasInsert: false,
60
+ });
68
61
  });
69
62
 
70
63
  if (entries.length === 0) {
@@ -57,7 +57,6 @@ export const InputBox: React.FC<InputBoxProps> = ({
57
57
  const {
58
58
  permissionMode: chatPermissionMode,
59
59
  setPermissionMode: setChatPermissionMode,
60
- allowBypassInCycle: chatAllowBypassInCycle,
61
60
  handleRewindSelect,
62
61
  backgroundCurrentTask,
63
62
  messages,
@@ -114,7 +113,6 @@ export const InputBox: React.FC<InputBoxProps> = ({
114
113
  // Permission mode
115
114
  permissionMode,
116
115
  setPermissionMode,
117
- setAllowBypassInCycle,
118
116
  // BTW state
119
117
  btwState,
120
118
  // Main handler
@@ -138,13 +136,22 @@ export const InputBox: React.FC<InputBoxProps> = ({
138
136
  setPermissionMode(chatPermissionMode);
139
137
  }, [chatPermissionMode, setPermissionMode]);
140
138
 
141
- // Sync allowBypassInCycle from useChat to InputManager
142
- useEffect(() => {
143
- setAllowBypassInCycle(chatAllowBypassInCycle);
144
- }, [chatAllowBypassInCycle, setAllowBypassInCycle]);
145
-
146
139
  // Use the InputManager's unified input handler
147
140
  useInput(async (input, key) => {
141
+ // These views have their own useInput handlers that handle escape and navigation.
142
+ // If they are active, we should skip InputBox's global input handling to avoid
143
+ // duplicate dispatches or state update conflicts.
144
+ if (
145
+ showRewindManager ||
146
+ showHelp ||
147
+ showStatusCommand ||
148
+ showPluginManager ||
149
+ showModelSelector ||
150
+ showBackgroundTaskManager ||
151
+ showMcpManager
152
+ ) {
153
+ return;
154
+ }
148
155
  await handleInput(input, key, attachedImages, clearImages);
149
156
  });
150
157
 
@@ -1,26 +1,36 @@
1
- import React, { useState } from "react";
1
+ import React, { useReducer, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { usePluginManagerContext } from "../contexts/PluginManagerContext.js";
4
+ import { selectorReducer } from "../reducers/selectorReducer.js";
4
5
 
5
6
  export const InstalledView: React.FC = () => {
6
7
  const { installedPlugins, actions } = usePluginManagerContext();
7
- const [selectedIndex, setSelectedIndex] = useState(0);
8
+ const [state, dispatch] = useReducer(selectorReducer, {
9
+ selectedIndex: 0,
10
+ pendingDecision: null,
11
+ });
12
+
13
+ const { selectedIndex, pendingDecision } = state;
8
14
 
9
- useInput((input, key) => {
10
- if (key.upArrow) {
11
- setSelectedIndex(Math.max(0, selectedIndex - 1));
12
- } else if (key.downArrow) {
13
- setSelectedIndex(
14
- Math.min(installedPlugins.length - 1, selectedIndex + 1),
15
- );
16
- } else if (key.return) {
15
+ useInput((_input, key) => {
16
+ dispatch({
17
+ type: "HANDLE_KEY",
18
+ key,
19
+ maxIndex: installedPlugins.length - 1,
20
+ hasInsert: false,
21
+ });
22
+ });
23
+
24
+ useEffect(() => {
25
+ if (pendingDecision === "select") {
17
26
  const plugin = installedPlugins[selectedIndex];
18
27
  if (plugin) {
19
28
  actions.setSelectedId(`${plugin.name}@${plugin.marketplace}`);
20
29
  actions.setView("PLUGIN_DETAIL");
21
30
  }
31
+ dispatch({ type: "CLEAR_DECISION" });
22
32
  }
23
- });
33
+ }, [pendingDecision, selectedIndex, installedPlugins, actions]);
24
34
 
25
35
  if (installedPlugins.length === 0) {
26
36
  return (
@@ -1,34 +1,47 @@
1
- import React, { useState } from "react";
1
+ import React, { useReducer, useEffect, useMemo } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { usePluginManagerContext } from "../contexts/PluginManagerContext.js";
4
+ import { selectorReducer } from "../reducers/selectorReducer.js";
4
5
 
5
6
  export const MarketplaceDetail: React.FC = () => {
6
7
  const { state, marketplaces, actions } = usePluginManagerContext();
7
- const [selectedActionIndex, setSelectedActionIndex] = useState(0);
8
+ const [selectorState, dispatch] = useReducer(selectorReducer, {
9
+ selectedIndex: 0,
10
+ pendingDecision: null,
11
+ });
12
+
13
+ const { selectedIndex: selectedActionIndex, pendingDecision } = selectorState;
8
14
 
9
15
  const marketplace = marketplaces.find((m) => m.name === state.selectedId);
10
16
 
11
- const ACTIONS = [
12
- {
13
- id: "toggle-auto-update",
14
- label: `${marketplace?.autoUpdate ? "Disable" : "Enable"} auto-update`,
15
- },
16
- { id: "update", label: "Update marketplace" },
17
- { id: "remove", label: "Remove marketplace" },
18
- ] as const;
19
-
20
- useInput((input, key) => {
21
- if (key.escape) {
22
- actions.setView("MARKETPLACES");
23
- } else if (key.upArrow) {
24
- setSelectedActionIndex((prev) =>
25
- prev > 0 ? prev - 1 : ACTIONS.length - 1,
26
- );
27
- } else if (key.downArrow) {
28
- setSelectedActionIndex((prev) =>
29
- prev < ACTIONS.length - 1 ? prev + 1 : 0,
30
- );
31
- } else if (key.return && marketplace && !state.isLoading) {
17
+ const ACTIONS = useMemo(
18
+ () =>
19
+ [
20
+ {
21
+ id: "toggle-auto-update",
22
+ label: `${marketplace?.autoUpdate ? "Disable" : "Enable"} auto-update`,
23
+ },
24
+ { id: "update", label: "Update marketplace" },
25
+ { id: "remove", label: "Remove marketplace" },
26
+ ] as const,
27
+ [marketplace?.autoUpdate],
28
+ );
29
+
30
+ useInput((_input, key) => {
31
+ if (state.isLoading && !key.escape) return;
32
+
33
+ dispatch({
34
+ type: "HANDLE_KEY",
35
+ key,
36
+ maxIndex: ACTIONS.length - 1,
37
+ hasInsert: false,
38
+ });
39
+ });
40
+
41
+ useEffect(() => {
42
+ if (!pendingDecision) return;
43
+
44
+ if (pendingDecision === "select" && marketplace && !state.isLoading) {
32
45
  const action = ACTIONS[selectedActionIndex].id;
33
46
  if (action === "toggle-auto-update") {
34
47
  actions.toggleAutoUpdate(marketplace.name, !marketplace.autoUpdate);
@@ -37,8 +50,19 @@ export const MarketplaceDetail: React.FC = () => {
37
50
  } else {
38
51
  actions.removeMarketplace(marketplace.name);
39
52
  }
53
+ } else if (pendingDecision === "cancel") {
54
+ actions.setView("MARKETPLACES");
40
55
  }
41
- });
56
+
57
+ dispatch({ type: "CLEAR_DECISION" });
58
+ }, [
59
+ pendingDecision,
60
+ selectedActionIndex,
61
+ marketplace,
62
+ state.isLoading,
63
+ actions,
64
+ ACTIONS,
65
+ ]);
42
66
 
43
67
  if (!marketplace) {
44
68
  return (
@@ -1,28 +1,43 @@
1
- import React, { useState } from "react";
1
+ import React, { useReducer, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { usePluginManagerContext } from "../contexts/PluginManagerContext.js";
4
+ import { selectorReducer } from "../reducers/selectorReducer.js";
4
5
 
5
6
  import { MarketplaceList } from "./MarketplaceList.js";
6
7
 
7
8
  export const MarketplaceView: React.FC = () => {
8
9
  const { marketplaces, actions } = usePluginManagerContext();
9
- const [selectedIndex, setSelectedIndex] = useState(0);
10
+ const [state, dispatch] = useReducer(selectorReducer, {
11
+ selectedIndex: 0,
12
+ pendingDecision: null,
13
+ });
14
+
15
+ const { selectedIndex, pendingDecision } = state;
10
16
 
11
17
  useInput((input, key) => {
12
- if (key.upArrow) {
13
- setSelectedIndex(Math.max(0, selectedIndex - 1));
14
- } else if (key.downArrow) {
15
- setSelectedIndex(Math.min(marketplaces.length - 1, selectedIndex + 1));
16
- } else if (key.return) {
18
+ if (input === "a") {
19
+ actions.setView("ADD_MARKETPLACE");
20
+ return;
21
+ }
22
+
23
+ dispatch({
24
+ type: "HANDLE_KEY",
25
+ key,
26
+ maxIndex: marketplaces.length - 1,
27
+ hasInsert: false,
28
+ });
29
+ });
30
+
31
+ useEffect(() => {
32
+ if (pendingDecision === "select") {
17
33
  const mk = marketplaces[selectedIndex];
18
34
  if (mk) {
19
35
  actions.setSelectedId(mk.name);
20
36
  actions.setView("MARKETPLACE_DETAIL");
21
37
  }
22
- } else if (input === "a") {
23
- actions.setView("ADD_MARKETPLACE");
38
+ dispatch({ type: "CLEAR_DECISION" });
24
39
  }
25
- });
40
+ }, [pendingDecision, selectedIndex, marketplaces, actions]);
26
41
 
27
42
  return (
28
43
  <Box flexDirection="column">
@@ -1,4 +1,4 @@
1
- import React, { useReducer } from "react";
1
+ import React, { useReducer, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { McpServerStatus } from "wave-agent-sdk";
4
4
  import {
@@ -16,6 +16,7 @@ export interface McpManagerProps {
16
16
  const initialState: McpManagerState = {
17
17
  selectedIndex: 0,
18
18
  viewMode: "list",
19
+ pendingEffect: null,
19
20
  };
20
21
 
21
22
  export const McpManager: React.FC<McpManagerProps> = ({
@@ -26,6 +27,26 @@ export const McpManager: React.FC<McpManagerProps> = ({
26
27
  }) => {
27
28
  const [state, dispatch] = useReducer(mcpManagerReducer, initialState);
28
29
 
30
+ // Handle pending effects
31
+ useEffect(() => {
32
+ if (!state.pendingEffect) return;
33
+
34
+ const effect = state.pendingEffect;
35
+ dispatch({ type: "CLEAR_PENDING_EFFECT" });
36
+
37
+ switch (effect.type) {
38
+ case "CANCEL":
39
+ onCancel();
40
+ break;
41
+ case "CONNECT_SERVER":
42
+ onConnectServer(effect.serverName);
43
+ break;
44
+ case "DISCONNECT_SERVER":
45
+ onDisconnectServer(effect.serverName);
46
+ break;
47
+ }
48
+ }, [state.pendingEffect, onCancel, onConnectServer, onDisconnectServer]);
49
+
29
50
  // Dynamically calculate selectedServer based on selectedIndex and servers
30
51
  const selectedServer =
31
52
  state.viewMode === "detail" &&
@@ -64,60 +85,14 @@ export const McpManager: React.FC<McpManagerProps> = ({
64
85
  }
65
86
  };
66
87
 
67
- const handleConnect = async (serverName: string) => {
68
- await onConnectServer(serverName);
69
- };
70
-
71
- const handleDisconnect = async (serverName: string) => {
72
- await onDisconnectServer(serverName);
73
- };
74
-
75
88
  useInput((input, key) => {
76
- if (key.return) {
77
- if (state.viewMode === "list") {
78
- dispatch({ type: "SET_VIEW_MODE", viewMode: "detail" });
79
- }
80
- return;
81
- }
82
-
83
- if (key.escape) {
84
- if (state.viewMode === "detail") {
85
- dispatch({ type: "SET_VIEW_MODE", viewMode: "list" });
86
- } else {
87
- onCancel();
88
- }
89
- return;
90
- }
91
-
92
- if (key.upArrow) {
93
- dispatch({ type: "MOVE_UP", serverCount: servers.length });
94
- return;
95
- }
96
-
97
- if (key.downArrow) {
98
- dispatch({ type: "MOVE_DOWN", serverCount: servers.length });
99
- return;
100
- }
101
-
102
- // Hotkeys for server actions
103
- if (input === "c") {
104
- const server = servers[state.selectedIndex];
105
- if (
106
- server &&
107
- (server.status === "disconnected" || server.status === "error")
108
- ) {
109
- handleConnect(server.name);
110
- }
111
- return;
112
- }
113
-
114
- if (input === "d") {
115
- const server = servers[state.selectedIndex];
116
- if (server && server.status === "connected") {
117
- handleDisconnect(server.name);
118
- }
119
- return;
120
- }
89
+ dispatch({
90
+ type: "HANDLE_KEY",
91
+ input,
92
+ key,
93
+ serverCount: servers.length,
94
+ servers: servers.map((s) => ({ name: s.name, status: s.status })),
95
+ });
121
96
  });
122
97
 
123
98
  if (state.viewMode === "detail" && selectedServer) {
@@ -1,5 +1,6 @@
1
- import React, { useState } from "react";
1
+ import React, { useReducer, useEffect } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
+ import { selectorReducer } from "../reducers/selectorReducer.js";
3
4
 
4
5
  export interface ModelSelectorProps {
5
6
  onCancel: () => void;
@@ -16,37 +17,43 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
16
17
  configuredModels,
17
18
  onSelectModel,
18
19
  }) => {
19
- const [selectedIndex, setSelectedIndex] = useState(() => {
20
- const index = configuredModels.indexOf(currentModel);
21
- return index !== -1 ? index : 0;
20
+ const [state, dispatch] = useReducer(selectorReducer, {
21
+ selectedIndex:
22
+ configuredModels.indexOf(currentModel) !== -1
23
+ ? configuredModels.indexOf(currentModel)
24
+ : 0,
25
+ pendingDecision: null,
22
26
  });
23
27
 
28
+ const { selectedIndex, pendingDecision } = state;
29
+
24
30
  useInput((_input, key) => {
25
- if (key.return) {
31
+ dispatch({
32
+ type: "HANDLE_KEY",
33
+ key,
34
+ maxIndex: configuredModels.length - 1,
35
+ hasInsert: false,
36
+ });
37
+ });
38
+
39
+ useEffect(() => {
40
+ if (pendingDecision === "select") {
26
41
  if (configuredModels.length > 0) {
27
42
  onSelectModel(configuredModels[selectedIndex]);
28
43
  }
29
44
  onCancel();
30
- return;
31
- }
32
-
33
- if (key.escape) {
45
+ dispatch({ type: "CLEAR_DECISION" });
46
+ } else if (pendingDecision === "cancel") {
34
47
  onCancel();
35
- return;
48
+ dispatch({ type: "CLEAR_DECISION" });
36
49
  }
37
-
38
- if (key.upArrow) {
39
- setSelectedIndex((prev) => Math.max(0, prev - 1));
40
- return;
41
- }
42
-
43
- if (key.downArrow) {
44
- setSelectedIndex((prev) =>
45
- Math.min(configuredModels.length - 1, prev + 1),
46
- );
47
- return;
48
- }
49
- });
50
+ }, [
51
+ pendingDecision,
52
+ selectedIndex,
53
+ configuredModels,
54
+ onSelectModel,
55
+ onCancel,
56
+ ]);
50
57
 
51
58
  // Calculate visible window
52
59
  const startIndex = Math.max(
@@ -1,4 +1,4 @@
1
- import React, { useReducer } from "react";
1
+ import React, { useReducer, useEffect, useMemo } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { usePluginManagerContext } from "../contexts/PluginManagerContext.js";
4
4
  import {
@@ -18,8 +18,12 @@ export const PluginDetail: React.FC = () => {
18
18
  const [detailState, dispatch] = useReducer(pluginDetailReducer, {
19
19
  selectedScopeIndex: 0,
20
20
  selectedActionIndex: 0,
21
+ pendingDecision: null,
21
22
  } as PluginDetailState);
22
23
 
24
+ const { selectedScopeIndex, selectedActionIndex, pendingDecision } =
25
+ detailState;
26
+
23
27
  const plugin =
24
28
  discoverablePlugins.find(
25
29
  (p) => `${p.name}@${p.marketplace}` === state.selectedId,
@@ -28,34 +32,35 @@ export const PluginDetail: React.FC = () => {
28
32
  (p) => `${p.name}@${p.marketplace}` === state.selectedId,
29
33
  );
30
34
 
31
- const INSTALLED_ACTIONS = [
32
- { id: "update", label: "Update plugin (reinstall)" },
33
- { id: "uninstall", label: "Uninstall plugin" },
34
- ] as const;
35
+ const INSTALLED_ACTIONS = useMemo(
36
+ () =>
37
+ [
38
+ { id: "update", label: "Update plugin (reinstall)" },
39
+ { id: "uninstall", label: "Uninstall plugin" },
40
+ ] as const,
41
+ [],
42
+ );
35
43
 
36
44
  const isInstalledAndEnabled = plugin && "enabled" in plugin && plugin.enabled;
37
45
 
38
- useInput((input, key) => {
39
- if (key.escape) {
40
- const isFromDiscover = discoverablePlugins.find(
41
- (p) => `${p.name}@${p.marketplace}` === state.selectedId,
42
- );
43
- actions.setView(isFromDiscover ? "DISCOVER" : "INSTALLED");
44
- } else if (key.upArrow) {
45
- dispatch({
46
- type: "MOVE_ACTION_UP",
47
- maxIndex: INSTALLED_ACTIONS.length - 1,
48
- });
49
- dispatch({ type: "MOVE_SCOPE_UP", maxIndex: SCOPES.length - 1 });
50
- } else if (key.downArrow) {
51
- dispatch({
52
- type: "MOVE_ACTION_DOWN",
53
- maxIndex: INSTALLED_ACTIONS.length - 1,
54
- });
55
- dispatch({ type: "MOVE_SCOPE_DOWN", maxIndex: SCOPES.length - 1 });
56
- } else if (key.return && plugin && !state.isLoading) {
46
+ useInput((_input, key) => {
47
+ if (state.isLoading && !key.escape) return;
48
+
49
+ dispatch({
50
+ type: "HANDLE_KEY",
51
+ key,
52
+ maxIndex: isInstalledAndEnabled
53
+ ? INSTALLED_ACTIONS.length - 1
54
+ : SCOPES.length - 1,
55
+ });
56
+ });
57
+
58
+ useEffect(() => {
59
+ if (!pendingDecision) return;
60
+
61
+ if (pendingDecision === "select" && plugin && !state.isLoading) {
57
62
  if (isInstalledAndEnabled) {
58
- const action = INSTALLED_ACTIONS[detailState.selectedActionIndex].id;
63
+ const action = INSTALLED_ACTIONS[selectedActionIndex].id;
59
64
  if (action === "uninstall") {
60
65
  actions.uninstallPlugin(plugin.name, plugin.marketplace);
61
66
  } else {
@@ -65,11 +70,29 @@ export const PluginDetail: React.FC = () => {
65
70
  actions.installPlugin(
66
71
  plugin.name,
67
72
  plugin.marketplace,
68
- SCOPES[detailState.selectedScopeIndex].id,
73
+ SCOPES[selectedScopeIndex].id,
69
74
  );
70
75
  }
76
+ } else if (pendingDecision === "cancel") {
77
+ const isFromDiscover = discoverablePlugins.find(
78
+ (p) => `${p.name}@${p.marketplace}` === state.selectedId,
79
+ );
80
+ actions.setView(isFromDiscover ? "DISCOVER" : "INSTALLED");
71
81
  }
72
- });
82
+
83
+ dispatch({ type: "CLEAR_DECISION" });
84
+ }, [
85
+ pendingDecision,
86
+ selectedActionIndex,
87
+ selectedScopeIndex,
88
+ plugin,
89
+ isInstalledAndEnabled,
90
+ state.isLoading,
91
+ discoverablePlugins,
92
+ state.selectedId,
93
+ actions,
94
+ INSTALLED_ACTIONS,
95
+ ]);
73
96
 
74
97
  if (!plugin) {
75
98
  return (