wave-code 0.7.2 → 0.8.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 (110) hide show
  1. package/dist/cli.d.ts +2 -4
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +24 -52
  4. package/dist/components/App.d.ts +3 -4
  5. package/dist/components/App.d.ts.map +1 -1
  6. package/dist/components/App.js +49 -6
  7. package/dist/components/BangDisplay.d.ts +9 -0
  8. package/dist/components/BangDisplay.d.ts.map +1 -0
  9. package/dist/components/{CommandOutputDisplay.js → BangDisplay.js} +1 -1
  10. package/dist/components/ChatInterface.d.ts.map +1 -1
  11. package/dist/components/ChatInterface.js +3 -2
  12. package/dist/components/CommandSelector.d.ts.map +1 -1
  13. package/dist/components/CommandSelector.js +7 -28
  14. package/dist/components/ConfirmationSelector.d.ts.map +1 -1
  15. package/dist/components/ConfirmationSelector.js +116 -11
  16. package/dist/components/HelpView.d.ts +2 -0
  17. package/dist/components/HelpView.d.ts.map +1 -1
  18. package/dist/components/HelpView.js +49 -3
  19. package/dist/components/InputBox.d.ts.map +1 -1
  20. package/dist/components/InputBox.js +10 -4
  21. package/dist/components/MarketplaceAddForm.d.ts.map +1 -1
  22. package/dist/components/MarketplaceAddForm.js +13 -6
  23. package/dist/components/MarketplaceDetail.d.ts.map +1 -1
  24. package/dist/components/MarketplaceDetail.js +8 -3
  25. package/dist/components/MessageBlockItem.js +2 -2
  26. package/dist/components/MessageList.d.ts +4 -1
  27. package/dist/components/MessageList.d.ts.map +1 -1
  28. package/dist/components/MessageList.js +15 -8
  29. package/dist/components/PluginDetail.d.ts.map +1 -1
  30. package/dist/components/PluginDetail.js +14 -3
  31. package/dist/components/PluginManagerShell.d.ts.map +1 -1
  32. package/dist/components/PluginManagerShell.js +3 -3
  33. package/dist/components/PluginManagerTypes.d.ts +2 -0
  34. package/dist/components/PluginManagerTypes.d.ts.map +1 -1
  35. package/dist/components/SessionSelector.d.ts.map +1 -1
  36. package/dist/components/SessionSelector.js +5 -5
  37. package/dist/components/StatusCommand.d.ts +6 -0
  38. package/dist/components/StatusCommand.d.ts.map +1 -0
  39. package/dist/components/StatusCommand.js +28 -0
  40. package/dist/components/WorktreeExitPrompt.d.ts +13 -0
  41. package/dist/components/WorktreeExitPrompt.d.ts.map +1 -0
  42. package/dist/components/WorktreeExitPrompt.js +26 -0
  43. package/dist/constants/commands.d.ts +3 -0
  44. package/dist/constants/commands.d.ts.map +1 -0
  45. package/dist/constants/commands.js +38 -0
  46. package/dist/contexts/useChat.d.ts +9 -5
  47. package/dist/contexts/useChat.d.ts.map +1 -1
  48. package/dist/contexts/useChat.js +38 -8
  49. package/dist/contracts/status.d.ts +8 -0
  50. package/dist/contracts/status.d.ts.map +1 -0
  51. package/dist/contracts/status.js +1 -0
  52. package/dist/hooks/useInputManager.d.ts +2 -0
  53. package/dist/hooks/useInputManager.d.ts.map +1 -1
  54. package/dist/hooks/useInputManager.js +12 -0
  55. package/dist/hooks/usePluginManager.d.ts.map +1 -1
  56. package/dist/hooks/usePluginManager.js +41 -13
  57. package/dist/hooks/useTasks.js +2 -2
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +53 -4
  60. package/dist/managers/InputManager.d.ts +6 -0
  61. package/dist/managers/InputManager.d.ts.map +1 -1
  62. package/dist/managers/InputManager.js +32 -13
  63. package/dist/print-cli.d.ts +2 -4
  64. package/dist/print-cli.d.ts.map +1 -1
  65. package/dist/print-cli.js +31 -2
  66. package/dist/session-selector-cli.d.ts +3 -1
  67. package/dist/session-selector-cli.d.ts.map +1 -1
  68. package/dist/session-selector-cli.js +2 -2
  69. package/dist/types.d.ts +11 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +1 -0
  72. package/dist/utils/highlightUtils.d.ts.map +1 -1
  73. package/dist/utils/highlightUtils.js +66 -42
  74. package/dist/utils/worktree.d.ts +23 -0
  75. package/dist/utils/worktree.d.ts.map +1 -0
  76. package/dist/utils/worktree.js +135 -0
  77. package/package.json +2 -2
  78. package/src/cli.tsx +36 -59
  79. package/src/components/App.tsx +99 -11
  80. package/src/components/{CommandOutputDisplay.tsx → BangDisplay.tsx} +4 -4
  81. package/src/components/ChatInterface.tsx +8 -0
  82. package/src/components/CommandSelector.tsx +7 -29
  83. package/src/components/ConfirmationSelector.tsx +131 -12
  84. package/src/components/HelpView.tsx +129 -14
  85. package/src/components/InputBox.tsx +14 -2
  86. package/src/components/MarketplaceAddForm.tsx +21 -8
  87. package/src/components/MarketplaceDetail.tsx +19 -4
  88. package/src/components/MessageBlockItem.tsx +3 -3
  89. package/src/components/MessageList.tsx +47 -23
  90. package/src/components/PluginDetail.tsx +30 -6
  91. package/src/components/PluginManagerShell.tsx +24 -6
  92. package/src/components/PluginManagerTypes.ts +2 -0
  93. package/src/components/SessionSelector.tsx +33 -16
  94. package/src/components/StatusCommand.tsx +94 -0
  95. package/src/components/WorktreeExitPrompt.tsx +86 -0
  96. package/src/constants/commands.ts +41 -0
  97. package/src/contexts/useChat.tsx +57 -13
  98. package/src/contracts/status.ts +7 -0
  99. package/src/hooks/useInputManager.ts +12 -0
  100. package/src/hooks/usePluginManager.ts +47 -13
  101. package/src/hooks/useTasks.ts +2 -2
  102. package/src/index.ts +71 -12
  103. package/src/managers/InputManager.ts +37 -15
  104. package/src/print-cli.ts +48 -5
  105. package/src/session-selector-cli.tsx +6 -2
  106. package/src/types.ts +11 -0
  107. package/src/utils/highlightUtils.ts +66 -42
  108. package/src/utils/worktree.ts +164 -0
  109. package/dist/components/CommandOutputDisplay.d.ts +0 -9
  110. package/dist/components/CommandOutputDisplay.d.ts.map +0 -1
package/src/cli.tsx CHANGED
@@ -2,13 +2,12 @@ import React from "react";
2
2
  import { render } from "ink";
3
3
  import { App } from "./components/App.js";
4
4
  import { cleanupLogs } from "./utils/logger.js";
5
+ import { removeWorktree } from "./utils/worktree.js";
6
+ import { BaseAppProps } from "./types.js";
5
7
 
6
- export interface CliOptions {
8
+ export interface CliOptions extends BaseAppProps {
7
9
  restoreSessionId?: string;
8
10
  continueLastSession?: boolean;
9
- bypassPermissions?: boolean;
10
- pluginDirs?: string[];
11
- tools?: string[];
12
11
  }
13
12
 
14
13
  export async function startCli(options: CliOptions): Promise<void> {
@@ -18,77 +17,55 @@ export async function startCli(options: CliOptions): Promise<void> {
18
17
  bypassPermissions,
19
18
  pluginDirs,
20
19
  tools,
20
+ worktreeSession,
21
+ workdir,
22
+ version,
23
+ model,
21
24
  } = options;
22
25
 
23
26
  // Continue with ink-based UI for normal mode
24
- // Global cleanup tracker
25
- let isCleaningUp = false;
26
- let appUnmounted = false;
27
+ let shouldRemoveWorktree = false;
27
28
 
28
- const cleanup = async () => {
29
- if (isCleaningUp) return;
30
- isCleaningUp = true;
31
-
32
- console.log("\nShutting down gracefully...");
33
-
34
- try {
35
- // Clean up old log files
36
- await cleanupLogs().catch((error) => {
37
- console.warn("Failed to cleanup old logs:", error);
38
- });
39
-
40
- // Unmount the React app to trigger cleanup
41
- if (!appUnmounted) {
42
- unmount();
43
- appUnmounted = true;
44
- // Give React time to cleanup
45
- await new Promise((resolve) => setTimeout(resolve, 100));
46
- }
47
-
48
- process.exit(0);
49
- } catch (error: unknown) {
50
- console.error("Error during cleanup:", error);
51
- process.exit(1);
52
- }
29
+ const handleExit = (shouldRemove: boolean) => {
30
+ shouldRemoveWorktree = shouldRemove;
31
+ unmount();
53
32
  };
54
33
 
55
- // Handle process signals
56
- process.on("SIGINT", cleanup);
57
- process.on("SIGTERM", cleanup);
58
-
59
- // Handle uncaught exceptions
60
- process.on("uncaughtException", (error) => {
61
- console.error("Uncaught exception:", error);
62
- cleanup();
63
- });
64
-
65
- process.on("unhandledRejection", (reason, promise) => {
66
- console.error("Unhandled rejection at:", promise, "reason:", reason);
67
- cleanup();
68
- });
69
-
70
34
  // Render the application
71
- const { unmount } = render(
35
+ const { unmount, waitUntilExit } = render(
72
36
  <App
73
37
  restoreSessionId={restoreSessionId}
74
38
  continueLastSession={continueLastSession}
75
39
  bypassPermissions={bypassPermissions}
76
40
  pluginDirs={pluginDirs}
77
41
  tools={tools}
42
+ worktreeSession={worktreeSession}
43
+ workdir={workdir}
44
+ version={version}
45
+ model={model}
46
+ onExit={handleExit}
78
47
  />,
48
+ { exitOnCtrlC: false },
79
49
  );
80
50
 
81
- // Store unmount function for cleanup when process exits normally
82
- process.on("exit", () => {
83
- if (!appUnmounted) {
84
- try {
85
- unmount();
86
- } catch {
87
- // Ignore errors during unmount
88
- }
51
+ // Wait for the app to finish unmounting
52
+ await waitUntilExit();
53
+
54
+ try {
55
+ // Clean up old log files
56
+ await cleanupLogs().catch((error) => {
57
+ console.warn("Failed to cleanup old logs:", error);
58
+ });
59
+
60
+ // Cleanup worktree if requested
61
+ if (shouldRemoveWorktree && worktreeSession) {
62
+ process.chdir(worktreeSession.repoRoot);
63
+ removeWorktree(worktreeSession);
89
64
  }
90
- });
91
65
 
92
- // Return a promise that never resolves to keep the CLI running
93
- return new Promise(() => {});
66
+ process.exit(0);
67
+ } catch (error: unknown) {
68
+ console.error("Error during cleanup:", error);
69
+ process.exit(1);
70
+ }
94
71
  }
@@ -1,27 +1,105 @@
1
- import React, { useState, useEffect, useRef } from "react";
2
- import { useStdout } from "ink";
1
+ import React, { useState, useEffect, useRef, useCallback } from "react";
2
+ import { useStdout, useInput } from "ink";
3
3
  import { ChatInterface } from "./ChatInterface.js";
4
4
  import { ChatProvider, useChat } from "../contexts/useChat.js";
5
5
  import { AppProvider } from "../contexts/useAppConfig.js";
6
+ import { WorktreeExitPrompt } from "./WorktreeExitPrompt.js";
7
+ import {
8
+ hasUncommittedChanges,
9
+ hasNewCommits,
10
+ getDefaultRemoteBranch,
11
+ } from "wave-agent-sdk";
12
+ import { BaseAppProps } from "../types.js";
6
13
 
7
- interface AppProps {
14
+ interface AppProps extends BaseAppProps {
8
15
  restoreSessionId?: string;
9
16
  continueLastSession?: boolean;
10
- bypassPermissions?: boolean;
11
- pluginDirs?: string[];
12
- tools?: string[];
17
+ onExit: (shouldRemove: boolean) => void;
13
18
  }
14
19
 
15
- const AppWithProviders: React.FC<{
16
- bypassPermissions?: boolean;
17
- pluginDirs?: string[];
18
- tools?: string[];
19
- }> = ({ bypassPermissions, pluginDirs, tools }) => {
20
+ interface AppWithProvidersProps extends BaseAppProps {
21
+ onExit: (shouldRemove: boolean) => void;
22
+ }
23
+
24
+ const AppWithProviders: React.FC<AppWithProvidersProps> = ({
25
+ bypassPermissions,
26
+ pluginDirs,
27
+ tools,
28
+ worktreeSession,
29
+ workdir,
30
+ version,
31
+ model,
32
+ onExit,
33
+ }) => {
34
+ const [isExiting, setIsExiting] = useState(false);
35
+ const [worktreeStatus, setWorktreeStatus] = useState<{
36
+ hasUncommittedChanges: boolean;
37
+ hasNewCommits: boolean;
38
+ } | null>(null);
39
+
40
+ const handleSignal = useCallback(async () => {
41
+ if (worktreeSession) {
42
+ const cwd = workdir || worktreeSession.path;
43
+ const baseBranch = getDefaultRemoteBranch(cwd);
44
+ const hasChanges = hasUncommittedChanges(cwd);
45
+ const hasCommits = hasNewCommits(cwd, baseBranch);
46
+
47
+ if (hasChanges || hasCommits) {
48
+ setWorktreeStatus({
49
+ hasUncommittedChanges: hasChanges,
50
+ hasNewCommits: hasCommits,
51
+ });
52
+ setIsExiting(true);
53
+ } else {
54
+ onExit(true);
55
+ }
56
+ } else {
57
+ onExit(false);
58
+ }
59
+ }, [worktreeSession, workdir, onExit]);
60
+
61
+ useInput((input, key) => {
62
+ if (input === "c" && key.ctrl) {
63
+ handleSignal();
64
+ }
65
+ });
66
+
67
+ useEffect(() => {
68
+ const onSigInt = () => handleSignal();
69
+ const onSigTerm = () => handleSignal();
70
+
71
+ process.on("SIGINT", onSigInt);
72
+ process.on("SIGTERM", onSigTerm);
73
+
74
+ return () => {
75
+ process.off("SIGINT", onSigInt);
76
+ process.off("SIGTERM", onSigTerm);
77
+ };
78
+ }, [handleSignal]);
79
+
80
+ if (isExiting && worktreeSession && worktreeStatus) {
81
+ return (
82
+ <WorktreeExitPrompt
83
+ name={worktreeSession.name}
84
+ path={worktreeSession.path}
85
+ hasUncommittedChanges={worktreeStatus.hasUncommittedChanges}
86
+ hasNewCommits={worktreeStatus.hasNewCommits}
87
+ onKeep={() => onExit(false)}
88
+ onRemove={() => onExit(true)}
89
+ onCancel={() => setIsExiting(false)}
90
+ />
91
+ );
92
+ }
93
+
20
94
  return (
21
95
  <ChatProvider
22
96
  bypassPermissions={bypassPermissions}
23
97
  pluginDirs={pluginDirs}
24
98
  tools={tools}
99
+ workdir={workdir}
100
+ worktreeSession={worktreeSession}
101
+ version={version}
102
+ model={model}
25
103
  >
26
104
  <ChatInterfaceWithRemount />
27
105
  </ChatProvider>
@@ -81,6 +159,11 @@ export const App: React.FC<AppProps> = ({
81
159
  bypassPermissions,
82
160
  pluginDirs,
83
161
  tools,
162
+ worktreeSession,
163
+ workdir,
164
+ version,
165
+ model,
166
+ onExit,
84
167
  }) => {
85
168
  return (
86
169
  <AppProvider
@@ -91,6 +174,11 @@ export const App: React.FC<AppProps> = ({
91
174
  bypassPermissions={bypassPermissions}
92
175
  pluginDirs={pluginDirs}
93
176
  tools={tools}
177
+ worktreeSession={worktreeSession}
178
+ workdir={workdir}
179
+ version={version}
180
+ model={model}
181
+ onExit={onExit}
94
182
  />
95
183
  </AppProvider>
96
184
  );
@@ -1,13 +1,13 @@
1
1
  import React, { useState, useEffect } from "react";
2
2
  import { Box, Text } from "ink";
3
- import type { CommandOutputBlock } from "wave-agent-sdk";
3
+ import type { BangBlock } from "wave-agent-sdk";
4
4
 
5
- interface CommandOutputDisplayProps {
6
- block: CommandOutputBlock;
5
+ interface BangDisplayProps {
6
+ block: BangBlock;
7
7
  isExpanded?: boolean;
8
8
  }
9
9
 
10
- export const CommandOutputDisplay: React.FC<CommandOutputDisplayProps> = ({
10
+ export const BangDisplay: React.FC<BangDisplayProps> = ({
11
11
  block,
12
12
  isExpanded = false,
13
13
  }) => {
@@ -36,8 +36,13 @@ export const ChatInterface: React.FC = () => {
36
36
  handleConfirmationDecision,
37
37
  handleConfirmationCancel: originalHandleConfirmationCancel,
38
38
  setWasLastDetailsTooTall,
39
+ version,
40
+ workdir,
41
+ getModelConfig,
39
42
  } = useChat();
40
43
 
44
+ const model = getModelConfig().model;
45
+
41
46
  const handleDetailsHeightMeasured = useCallback((height: number) => {
42
47
  setDetailsHeight(height);
43
48
  }, []);
@@ -91,6 +96,9 @@ export const ChatInterface: React.FC = () => {
91
96
  messages={messages}
92
97
  isExpanded={isExpanded}
93
98
  hideDynamicBlocks={isConfirmationVisible}
99
+ version={version}
100
+ workdir={workdir}
101
+ model={model}
94
102
  />
95
103
 
96
104
  {(isLoading || isCommandRunning || isCompressing) &&
@@ -1,34 +1,7 @@
1
1
  import React, { useState } from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import type { SlashCommand } from "wave-agent-sdk";
4
-
5
- const AVAILABLE_COMMANDS: SlashCommand[] = [
6
- {
7
- id: "tasks",
8
- name: "tasks",
9
- description: "View and manage background tasks (shells and subagents)",
10
- handler: () => {}, // Handler here won't be used, actual processing is in the hook
11
- },
12
- {
13
- id: "mcp",
14
- name: "mcp",
15
- description: "View and manage MCP servers",
16
- handler: () => {}, // Handler here won't be used, actual processing is in the hook
17
- },
18
- {
19
- id: "rewind",
20
- name: "rewind",
21
- description:
22
- "Revert conversation and file changes to a previous checkpoint",
23
- handler: () => {}, // Handler here won't be used, actual processing is in the hook
24
- },
25
- {
26
- id: "help",
27
- name: "help",
28
- description: "Show help and key bindings",
29
- handler: () => {}, // Handler here won't be used, actual processing is in the hook
30
- },
31
- ];
4
+ import { AVAILABLE_COMMANDS } from "../constants/commands.js";
32
5
 
33
6
  export interface CommandSelectorProps {
34
7
  searchQuery: string;
@@ -48,8 +21,13 @@ export const CommandSelector: React.FC<CommandSelectorProps> = ({
48
21
  const MAX_VISIBLE_ITEMS = 3;
49
22
  const [selectedIndex, setSelectedIndex] = useState(0);
50
23
 
24
+ // Reset selected index when search query changes
25
+ React.useEffect(() => {
26
+ setSelectedIndex(0);
27
+ }, [searchQuery]);
28
+
51
29
  // Merge agent commands and local commands
52
- const allCommands = [...commands, ...AVAILABLE_COMMANDS];
30
+ const allCommands = [...AVAILABLE_COMMANDS, ...commands];
53
31
 
54
32
  // Filter command list
55
33
  const filteredCommands = allCommands.filter(
@@ -1,4 +1,4 @@
1
- import React, { useLayoutEffect, useRef, useState } from "react";
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
2
2
  import { Box, Text, useInput, useStdout, measureElement } from "ink";
3
3
  import type { PermissionDecision, AskUserQuestionInput } from "wave-agent-sdk";
4
4
  import {
@@ -70,6 +70,25 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
70
70
  userAnswers: {} as Record<string, string>,
71
71
  otherText: "",
72
72
  otherCursorPosition: 0,
73
+ savedStates: {} as Record<
74
+ number,
75
+ {
76
+ selectedOptionIndex: number;
77
+ selectedOptionIndices: Set<number>;
78
+ otherText: string;
79
+ otherCursorPosition: number;
80
+ }
81
+ >,
82
+ });
83
+
84
+ const pendingDecisionRef = useRef<PermissionDecision | null>(null);
85
+
86
+ useEffect(() => {
87
+ if (pendingDecisionRef.current) {
88
+ const decision = pendingDecisionRef.current;
89
+ pendingDecisionRef.current = null;
90
+ onDecision(decision);
91
+ }
73
92
  });
74
93
 
75
94
  const questions =
@@ -133,23 +152,75 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
133
152
  };
134
153
 
135
154
  if (prev.currentQuestionIndex < questions.length - 1) {
136
- return {
137
- ...prev,
138
- currentQuestionIndex: prev.currentQuestionIndex + 1,
155
+ const nextIndex = prev.currentQuestionIndex + 1;
156
+ const savedStates = {
157
+ ...prev.savedStates,
158
+ [prev.currentQuestionIndex]: {
159
+ selectedOptionIndex: prev.selectedOptionIndex,
160
+ selectedOptionIndices: prev.selectedOptionIndices,
161
+ otherText: prev.otherText,
162
+ otherCursorPosition: prev.otherCursorPosition,
163
+ },
164
+ };
165
+
166
+ const nextState = savedStates[nextIndex] || {
139
167
  selectedOptionIndex: 0,
140
- selectedOptionIndices: new Set(),
141
- userAnswers: newAnswers,
168
+ selectedOptionIndices: new Set<number>(),
142
169
  otherText: "",
143
170
  otherCursorPosition: 0,
144
171
  };
172
+
173
+ return {
174
+ ...prev,
175
+ currentQuestionIndex: nextIndex,
176
+ ...nextState,
177
+ userAnswers: newAnswers,
178
+ savedStates,
179
+ };
145
180
  } else {
146
- onDecision({
181
+ const finalAnswers = { ...newAnswers };
182
+ // Also collect from savedStates for any questions that were skipped via Tab
183
+ for (const [idxStr, s] of Object.entries(prev.savedStates)) {
184
+ const idx = parseInt(idxStr);
185
+ const q = questions[idx];
186
+ if (q && !finalAnswers[q.question]) {
187
+ const opts = [...q.options, { label: "Other" }];
188
+ let a = "";
189
+ if (q.multiSelect) {
190
+ const selectedLabels = Array.from(s.selectedOptionIndices)
191
+ .filter((i) => i < q.options.length)
192
+ .map((i) => q.options[i].label);
193
+ const isOtherChecked = s.selectedOptionIndices.has(
194
+ opts.length - 1,
195
+ );
196
+ if (isOtherChecked && s.otherText.trim()) {
197
+ selectedLabels.push(s.otherText.trim());
198
+ }
199
+ a = selectedLabels.join(", ");
200
+ } else {
201
+ if (s.selectedOptionIndex === opts.length - 1) {
202
+ a = s.otherText.trim();
203
+ } else {
204
+ a = opts[s.selectedOptionIndex].label;
205
+ }
206
+ }
207
+ if (a) finalAnswers[q.question] = a;
208
+ }
209
+ }
210
+
211
+ // Only submit if all questions have been answered
212
+ const allAnswered = questions.every(
213
+ (q) => finalAnswers[q.question],
214
+ );
215
+ if (!allAnswered) return prev;
216
+
217
+ pendingDecisionRef.current = {
147
218
  behavior: "allow",
148
- message: JSON.stringify(newAnswers),
149
- });
219
+ message: JSON.stringify(finalAnswers),
220
+ };
150
221
  return {
151
222
  ...prev,
152
- userAnswers: newAnswers,
223
+ userAnswers: finalAnswers,
153
224
  };
154
225
  }
155
226
  });
@@ -196,6 +267,41 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
196
267
  }));
197
268
  return;
198
269
  }
270
+ if (key.tab) {
271
+ setQuestionState((prev) => {
272
+ const direction = key.shift ? -1 : 1;
273
+ let nextIndex = prev.currentQuestionIndex + direction;
274
+ if (nextIndex < 0) nextIndex = questions.length - 1;
275
+ if (nextIndex >= questions.length) nextIndex = 0;
276
+
277
+ if (nextIndex === prev.currentQuestionIndex) return prev;
278
+
279
+ const savedStates = {
280
+ ...prev.savedStates,
281
+ [prev.currentQuestionIndex]: {
282
+ selectedOptionIndex: prev.selectedOptionIndex,
283
+ selectedOptionIndices: prev.selectedOptionIndices,
284
+ otherText: prev.otherText,
285
+ otherCursorPosition: prev.otherCursorPosition,
286
+ },
287
+ };
288
+
289
+ const nextState = savedStates[nextIndex] || {
290
+ selectedOptionIndex: 0,
291
+ selectedOptionIndices: new Set<number>(),
292
+ otherText: "",
293
+ otherCursorPosition: 0,
294
+ };
295
+
296
+ return {
297
+ ...prev,
298
+ currentQuestionIndex: nextIndex,
299
+ ...nextState,
300
+ savedStates,
301
+ };
302
+ });
303
+ return;
304
+ }
199
305
 
200
306
  setQuestionState((prev) => {
201
307
  const isOtherFocused = prev.selectedOptionIndex === options.length - 1;
@@ -321,6 +427,19 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
321
427
  return;
322
428
  }
323
429
 
430
+ if (key.tab) {
431
+ const currentIndex = availableOptions.indexOf(state.selectedOption);
432
+ const direction = key.shift ? -1 : 1;
433
+ let nextIndex = currentIndex + direction;
434
+ if (nextIndex < 0) nextIndex = availableOptions.length - 1;
435
+ if (nextIndex >= availableOptions.length) nextIndex = 0;
436
+ setState((prev) => ({
437
+ ...prev,
438
+ selectedOption: availableOptions[nextIndex],
439
+ }));
440
+ return;
441
+ }
442
+
324
443
  if (input && !key.ctrl && !key.meta && !("alt" in key && key.alt)) {
325
444
  setState((prev) => {
326
445
  const nextText =
@@ -436,7 +555,7 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
436
555
  Question {questionState.currentQuestionIndex + 1} of{" "}
437
556
  {questions.length} •
438
557
  {currentQuestion.multiSelect ? " Space to toggle •" : ""} Use ↑↓
439
- to navigate • Enter to confirm
558
+ or Tab to navigate • Enter to confirm
440
559
  </Text>
441
560
  </Box>
442
561
  </Box>
@@ -531,7 +650,7 @@ export const ConfirmationSelector: React.FC<ConfirmationSelectorProps> = ({
531
650
  </Box>
532
651
  </Box>
533
652
  <Box marginTop={1}>
534
- <Text dimColor>Use ↑↓ to navigate • ESC to cancel</Text>
653
+ <Text dimColor>Use ↑↓ or Tab to navigate • ESC to cancel</Text>
535
654
  </Box>
536
655
  </>
537
656
  )}