@vybestack/llxprt-ui 0.7.0-nightly.251211.5750c518a

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 (123) hide show
  1. package/PLAN-messages.md +681 -0
  2. package/PLAN.md +47 -0
  3. package/README.md +25 -0
  4. package/bun.lock +1024 -0
  5. package/dev-docs/ARCHITECTURE.md +178 -0
  6. package/dev-docs/CODE_ORGANIZATION.md +232 -0
  7. package/dev-docs/STANDARDS.md +235 -0
  8. package/dev-docs/UI_DESIGN.md +425 -0
  9. package/eslint.config.cjs +194 -0
  10. package/images/nui.png +0 -0
  11. package/llxprt.png +0 -0
  12. package/llxprt.svg +128 -0
  13. package/package.json +66 -0
  14. package/scripts/check-limits.ts +177 -0
  15. package/scripts/start.js +71 -0
  16. package/src/app.tsx +599 -0
  17. package/src/bootstrap.tsx +23 -0
  18. package/src/commands/AuthCommand.tsx +80 -0
  19. package/src/commands/ModelCommand.tsx +102 -0
  20. package/src/commands/ProviderCommand.tsx +103 -0
  21. package/src/commands/ThemeCommand.tsx +71 -0
  22. package/src/features/chat/history.ts +178 -0
  23. package/src/features/chat/index.ts +3 -0
  24. package/src/features/chat/persistentHistory.ts +102 -0
  25. package/src/features/chat/responder.ts +217 -0
  26. package/src/features/completion/completions.ts +161 -0
  27. package/src/features/completion/index.ts +3 -0
  28. package/src/features/completion/slash.test.ts +82 -0
  29. package/src/features/completion/slash.ts +248 -0
  30. package/src/features/completion/suggestions.test.ts +51 -0
  31. package/src/features/completion/suggestions.ts +112 -0
  32. package/src/features/config/configSession.test.ts +189 -0
  33. package/src/features/config/configSession.ts +179 -0
  34. package/src/features/config/index.ts +4 -0
  35. package/src/features/config/llxprtAdapter.integration.test.ts +202 -0
  36. package/src/features/config/llxprtAdapter.test.ts +139 -0
  37. package/src/features/config/llxprtAdapter.ts +257 -0
  38. package/src/features/config/llxprtCommands.test.ts +40 -0
  39. package/src/features/config/llxprtCommands.ts +35 -0
  40. package/src/features/config/llxprtConfig.test.ts +261 -0
  41. package/src/features/config/llxprtConfig.ts +418 -0
  42. package/src/features/theme/index.ts +2 -0
  43. package/src/features/theme/theme.test.ts +51 -0
  44. package/src/features/theme/theme.ts +105 -0
  45. package/src/features/theme/themeManager.ts +84 -0
  46. package/src/hooks/useAppCommands.ts +129 -0
  47. package/src/hooks/useApprovalKeyboard.ts +156 -0
  48. package/src/hooks/useChatStore.test.ts +112 -0
  49. package/src/hooks/useChatStore.ts +252 -0
  50. package/src/hooks/useInputManager.ts +99 -0
  51. package/src/hooks/useKeyboardHandlers.ts +130 -0
  52. package/src/hooks/useListNavigation.test.ts +166 -0
  53. package/src/hooks/useListNavigation.ts +62 -0
  54. package/src/hooks/usePersistentHistory.ts +94 -0
  55. package/src/hooks/useScrollManagement.ts +107 -0
  56. package/src/hooks/useSelectionClipboard.ts +48 -0
  57. package/src/hooks/useSessionManager.test.ts +85 -0
  58. package/src/hooks/useSessionManager.ts +101 -0
  59. package/src/hooks/useStreamingLifecycle.ts +71 -0
  60. package/src/hooks/useStreamingResponder.ts +401 -0
  61. package/src/hooks/useSuggestionSetup.ts +23 -0
  62. package/src/hooks/useToolApproval.test.ts +140 -0
  63. package/src/hooks/useToolApproval.ts +264 -0
  64. package/src/hooks/useToolScheduler.ts +432 -0
  65. package/src/index.ts +3 -0
  66. package/src/jsx.d.ts +11 -0
  67. package/src/lib/clipboard.ts +18 -0
  68. package/src/lib/logger.ts +107 -0
  69. package/src/lib/random.ts +5 -0
  70. package/src/main.tsx +13 -0
  71. package/src/test/mockTheme.ts +51 -0
  72. package/src/types/events.ts +87 -0
  73. package/src/types.ts +13 -0
  74. package/src/ui/components/ChatLayout.tsx +694 -0
  75. package/src/ui/components/CommandComponents.tsx +74 -0
  76. package/src/ui/components/DiffViewer.tsx +306 -0
  77. package/src/ui/components/FilterInput.test.ts +69 -0
  78. package/src/ui/components/FilterInput.tsx +62 -0
  79. package/src/ui/components/HeaderBar.tsx +137 -0
  80. package/src/ui/components/RadioSelect.test.ts +140 -0
  81. package/src/ui/components/RadioSelect.tsx +88 -0
  82. package/src/ui/components/SelectableList.test.ts +83 -0
  83. package/src/ui/components/SelectableList.tsx +35 -0
  84. package/src/ui/components/StatusBar.tsx +45 -0
  85. package/src/ui/components/SuggestionPanel.tsx +102 -0
  86. package/src/ui/components/messages/ModelMessage.tsx +14 -0
  87. package/src/ui/components/messages/SystemMessage.tsx +29 -0
  88. package/src/ui/components/messages/ThinkingMessage.tsx +14 -0
  89. package/src/ui/components/messages/UserMessage.tsx +26 -0
  90. package/src/ui/components/messages/index.ts +15 -0
  91. package/src/ui/components/messages/renderMessage.test.ts +49 -0
  92. package/src/ui/components/messages/renderMessage.tsx +43 -0
  93. package/src/ui/components/messages/types.test.ts +24 -0
  94. package/src/ui/components/messages/types.ts +36 -0
  95. package/src/ui/modals/AuthModal.tsx +106 -0
  96. package/src/ui/modals/ModalShell.tsx +60 -0
  97. package/src/ui/modals/SearchSelectModal.tsx +236 -0
  98. package/src/ui/modals/ThemeModal.tsx +204 -0
  99. package/src/ui/modals/ToolApprovalModal.test.ts +206 -0
  100. package/src/ui/modals/ToolApprovalModal.tsx +282 -0
  101. package/src/ui/modals/index.ts +20 -0
  102. package/src/ui/modals/modals.test.ts +26 -0
  103. package/src/ui/modals/types.ts +19 -0
  104. package/src/uicontext/Command.tsx +102 -0
  105. package/src/uicontext/Dialog.tsx +65 -0
  106. package/src/uicontext/index.ts +2 -0
  107. package/themes/ansi-light.json +59 -0
  108. package/themes/ansi.json +59 -0
  109. package/themes/atom-one-dark.json +59 -0
  110. package/themes/ayu-light.json +59 -0
  111. package/themes/ayu.json +59 -0
  112. package/themes/default-light.json +59 -0
  113. package/themes/default.json +59 -0
  114. package/themes/dracula.json +59 -0
  115. package/themes/github-dark.json +59 -0
  116. package/themes/github-light.json +59 -0
  117. package/themes/googlecode.json +59 -0
  118. package/themes/green-screen.json +59 -0
  119. package/themes/no-color.json +59 -0
  120. package/themes/shades-of-purple.json +59 -0
  121. package/themes/xcode.json +59 -0
  122. package/tsconfig.json +28 -0
  123. package/vitest.config.ts +10 -0
@@ -0,0 +1,206 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type {
3
+ ToolApprovalOutcome,
4
+ ToolApprovalDetails,
5
+ ToolApprovalModalProps,
6
+ } from './ToolApprovalModal';
7
+ import type { ThemeDefinition } from '../../features/theme';
8
+ import type { ToolConfirmationType } from '../../types/events';
9
+
10
+ describe('ToolApprovalModal', () => {
11
+ const mockTheme: ThemeDefinition = {
12
+ slug: 'test',
13
+ name: 'Test Theme',
14
+ kind: 'dark',
15
+ colors: {
16
+ background: '#000000',
17
+ text: {
18
+ primary: '#ffffff',
19
+ muted: '#888888',
20
+ user: '#00ff00',
21
+ responder: '#0088ff',
22
+ thinking: '#ff8800',
23
+ tool: '#ff00ff',
24
+ },
25
+ input: {
26
+ fg: '#ffffff',
27
+ bg: '#000000',
28
+ border: '#333333',
29
+ placeholder: '#666666',
30
+ },
31
+ panel: {
32
+ bg: '#111111',
33
+ border: '#333333',
34
+ },
35
+ status: {
36
+ fg: '#ffffff',
37
+ },
38
+ accent: {
39
+ primary: '#00ffff',
40
+ },
41
+ selection: {
42
+ fg: '#000000',
43
+ bg: '#ffffff',
44
+ },
45
+ diff: {
46
+ addedBg: '#003300',
47
+ addedFg: '#00ff00',
48
+ removedBg: '#330000',
49
+ removedFg: '#ff0000',
50
+ },
51
+ message: {
52
+ userBorder: '#00ff00',
53
+ systemBorder: '#888888',
54
+ systemText: '#888888',
55
+ },
56
+ },
57
+ };
58
+
59
+ describe('ToolApprovalOutcome type', () => {
60
+ it('accepts valid outcome values', () => {
61
+ const allowOnce: ToolApprovalOutcome = 'allow_once';
62
+ const allowAlways: ToolApprovalOutcome = 'allow_always';
63
+ const cancel: ToolApprovalOutcome = 'cancel';
64
+
65
+ expect(allowOnce).toBe('allow_once');
66
+ expect(allowAlways).toBe('allow_always');
67
+ expect(cancel).toBe('cancel');
68
+ });
69
+ });
70
+
71
+ describe('ToolApprovalDetails type', () => {
72
+ it('accepts required properties for edit confirmation', () => {
73
+ const details: ToolApprovalDetails = {
74
+ callId: 'call-123',
75
+ toolName: 'write_file',
76
+ confirmationType: 'edit' as ToolConfirmationType,
77
+ question: 'Allow file write?',
78
+ preview: 'Writing to /path/to/file.ts',
79
+ params: { path: '/path/to/file.ts', content: 'new content' },
80
+ canAllowAlways: true,
81
+ };
82
+
83
+ expect(details.callId).toBe('call-123');
84
+ expect(details.toolName).toBe('write_file');
85
+ expect(details.confirmationType).toBe('edit');
86
+ expect(details.canAllowAlways).toBe(true);
87
+ });
88
+
89
+ it('accepts exec confirmation type', () => {
90
+ const details: ToolApprovalDetails = {
91
+ callId: 'call-456',
92
+ toolName: 'run_command',
93
+ confirmationType: 'exec' as ToolConfirmationType,
94
+ question: 'Allow command execution?',
95
+ preview: 'npm install express',
96
+ params: { command: 'npm install express' },
97
+ canAllowAlways: false,
98
+ };
99
+
100
+ expect(details.confirmationType).toBe('exec');
101
+ expect(details.preview).toBe('npm install express');
102
+ });
103
+
104
+ it('accepts mcp confirmation type', () => {
105
+ const details: ToolApprovalDetails = {
106
+ callId: 'call-789',
107
+ toolName: 'mcp_tool',
108
+ confirmationType: 'mcp' as ToolConfirmationType,
109
+ question: 'Allow MCP tool call?',
110
+ preview: 'Calling external service',
111
+ params: { serverName: 'my-mcp-server', action: 'query' },
112
+ canAllowAlways: true,
113
+ };
114
+
115
+ expect(details.confirmationType).toBe('mcp');
116
+ });
117
+
118
+ it('accepts info confirmation type', () => {
119
+ const details: ToolApprovalDetails = {
120
+ callId: 'call-info',
121
+ toolName: 'read_file',
122
+ confirmationType: 'info' as ToolConfirmationType,
123
+ question: 'Allow file read?',
124
+ preview: 'Reading /path/to/file.ts',
125
+ params: { path: '/path/to/file.ts' },
126
+ canAllowAlways: true,
127
+ };
128
+
129
+ expect(details.confirmationType).toBe('info');
130
+ });
131
+ });
132
+
133
+ describe('ToolApprovalModalProps type', () => {
134
+ it('accepts required props', () => {
135
+ const details: ToolApprovalDetails = {
136
+ callId: 'call-123',
137
+ toolName: 'write_file',
138
+ confirmationType: 'edit' as ToolConfirmationType,
139
+ question: 'Allow file write?',
140
+ preview: 'Writing to file',
141
+ params: {},
142
+ canAllowAlways: true,
143
+ };
144
+
145
+ const props: ToolApprovalModalProps = {
146
+ details,
147
+ onDecision: () => {},
148
+ onClose: () => {},
149
+ };
150
+
151
+ expect(props.details).toBe(details);
152
+ expect(typeof props.onDecision).toBe('function');
153
+ expect(typeof props.onClose).toBe('function');
154
+ });
155
+
156
+ it('accepts optional theme prop', () => {
157
+ const details: ToolApprovalDetails = {
158
+ callId: 'call-123',
159
+ toolName: 'write_file',
160
+ confirmationType: 'edit' as ToolConfirmationType,
161
+ question: 'Allow file write?',
162
+ preview: 'Writing to file',
163
+ params: {},
164
+ canAllowAlways: false,
165
+ };
166
+
167
+ const props: ToolApprovalModalProps = {
168
+ details,
169
+ onDecision: () => {},
170
+ onClose: () => {},
171
+ theme: mockTheme,
172
+ };
173
+
174
+ expect(props.theme).toBe(mockTheme);
175
+ });
176
+
177
+ it('onDecision callback receives correct arguments', () => {
178
+ let receivedCallId: string | null = null;
179
+ let receivedOutcome: ToolApprovalOutcome | null = null;
180
+
181
+ const details: ToolApprovalDetails = {
182
+ callId: 'call-test',
183
+ toolName: 'test_tool',
184
+ confirmationType: 'exec' as ToolConfirmationType,
185
+ question: 'Allow?',
186
+ preview: 'Test preview',
187
+ params: {},
188
+ canAllowAlways: true,
189
+ };
190
+
191
+ const props: ToolApprovalModalProps = {
192
+ details,
193
+ onDecision: (callId: string, outcome: ToolApprovalOutcome) => {
194
+ receivedCallId = callId;
195
+ receivedOutcome = outcome;
196
+ },
197
+ onClose: () => {},
198
+ };
199
+
200
+ props.onDecision('call-test', 'allow_once');
201
+
202
+ expect(receivedCallId).toBe('call-test');
203
+ expect(receivedOutcome).toBe('allow_once');
204
+ });
205
+ });
206
+ });
@@ -0,0 +1,282 @@
1
+ import React from 'react';
2
+ import { useCallback, useMemo } from 'react';
3
+ import type { ThemeDefinition } from '../../features/theme';
4
+ import type { ToolConfirmationType } from '../../types/events';
5
+ import type { ToolCallConfirmationDetails } from '@vybestack/llxprt-code-core';
6
+ import { ModalShell } from './ModalShell';
7
+ import { RadioSelect, type RadioSelectOption } from '../components/RadioSelect';
8
+ import { DiffViewer } from '../components/DiffViewer';
9
+
10
+ export type ToolApprovalOutcome = 'allow_once' | 'allow_always' | 'cancel';
11
+
12
+ export interface ToolApprovalDetails {
13
+ readonly callId: string;
14
+ readonly toolName: string;
15
+ readonly confirmationType: ToolConfirmationType;
16
+ readonly question: string;
17
+ readonly preview: string;
18
+ readonly params: Record<string, unknown>;
19
+ readonly canAllowAlways: boolean;
20
+ /** Full confirmation details from CoreToolScheduler (includes diff for edits) */
21
+ readonly coreDetails?: ToolCallConfirmationDetails;
22
+ }
23
+
24
+ export interface ToolApprovalModalProps {
25
+ readonly details: ToolApprovalDetails;
26
+ readonly onDecision: (callId: string, outcome: ToolApprovalOutcome) => void;
27
+ readonly onClose: () => void;
28
+ readonly theme?: ThemeDefinition;
29
+ }
30
+
31
+ function getTypeIcon(type: ToolConfirmationType): string {
32
+ switch (type) {
33
+ case 'edit':
34
+ return '✎';
35
+ case 'exec':
36
+ return '⚡';
37
+ case 'mcp':
38
+ return '⚙';
39
+ case 'info':
40
+ return 'ℹ';
41
+ default:
42
+ return '?';
43
+ }
44
+ }
45
+
46
+ function getTypeLabel(type: ToolConfirmationType): string {
47
+ switch (type) {
48
+ case 'edit':
49
+ return 'File Edit';
50
+ case 'exec':
51
+ return 'Shell Command';
52
+ case 'mcp':
53
+ return 'MCP Tool';
54
+ case 'info':
55
+ return 'Information Request';
56
+ default:
57
+ return 'Tool';
58
+ }
59
+ }
60
+
61
+ interface PreviewContentProps {
62
+ readonly details: ToolApprovalDetails;
63
+ readonly theme?: ThemeDefinition;
64
+ }
65
+
66
+ /**
67
+ * Render the preview content based on confirmation type
68
+ */
69
+ function PreviewContent(props: PreviewContentProps): React.ReactNode {
70
+ const { details, theme } = props;
71
+
72
+ // For edit confirmations with core details, show diff
73
+ if (
74
+ details.confirmationType === 'edit' &&
75
+ details.coreDetails?.type === 'edit'
76
+ ) {
77
+ const editDetails = details.coreDetails;
78
+ return (
79
+ <DiffViewer
80
+ diffContent={editDetails.fileDiff}
81
+ filename={editDetails.fileName}
82
+ maxHeight={15}
83
+ {...(theme !== undefined ? { theme } : {})}
84
+ />
85
+ );
86
+ }
87
+
88
+ // For exec confirmations, show command
89
+ if (
90
+ details.confirmationType === 'exec' &&
91
+ details.coreDetails?.type === 'exec'
92
+ ) {
93
+ const execDetails = details.coreDetails;
94
+ return (
95
+ <box
96
+ border
97
+ style={{
98
+ padding: 1,
99
+ borderColor: theme?.colors.panel.border,
100
+ backgroundColor: theme?.colors.panel.bg,
101
+ flexDirection: 'column',
102
+ gap: 0,
103
+ }}
104
+ >
105
+ <text fg={theme?.colors.text.muted}>Command:</text>
106
+ <text fg={theme?.colors.accent.warning ?? theme?.colors.text.primary}>
107
+ {execDetails.command}
108
+ </text>
109
+ </box>
110
+ );
111
+ }
112
+
113
+ // For info confirmations (web fetch), show prompt and URLs
114
+ if (
115
+ details.confirmationType === 'info' &&
116
+ details.coreDetails?.type === 'info'
117
+ ) {
118
+ const infoDetails = details.coreDetails;
119
+ return (
120
+ <box
121
+ border
122
+ style={{
123
+ padding: 1,
124
+ borderColor: theme?.colors.panel.border,
125
+ backgroundColor: theme?.colors.panel.bg,
126
+ flexDirection: 'column',
127
+ gap: 0,
128
+ }}
129
+ >
130
+ <text fg={theme?.colors.text.muted}>Prompt:</text>
131
+ <text fg={theme?.colors.text.tool}>{infoDetails.prompt}</text>
132
+ {infoDetails.urls && infoDetails.urls.length > 0 && (
133
+ <>
134
+ <text fg={theme?.colors.text.muted} style={{ marginTop: 1 }}>
135
+ URLs:
136
+ </text>
137
+ {infoDetails.urls.map((url, index) => (
138
+ <text key={`url-${index}`} fg={theme?.colors.accent.primary}>
139
+ • {url}
140
+ </text>
141
+ ))}
142
+ </>
143
+ )}
144
+ </box>
145
+ );
146
+ }
147
+
148
+ // For MCP tool confirmations
149
+ if (
150
+ details.confirmationType === 'mcp' &&
151
+ details.coreDetails?.type === 'mcp'
152
+ ) {
153
+ const mcpDetails = details.coreDetails;
154
+ return (
155
+ <box
156
+ border
157
+ style={{
158
+ padding: 1,
159
+ borderColor: theme?.colors.panel.border,
160
+ backgroundColor: theme?.colors.panel.bg,
161
+ flexDirection: 'column',
162
+ gap: 0,
163
+ }}
164
+ >
165
+ <text fg={theme?.colors.text.muted}>
166
+ MCP Server: {mcpDetails.serverName}
167
+ </text>
168
+ <text fg={theme?.colors.text.muted}>
169
+ Tool: {mcpDetails.toolDisplayName}
170
+ </text>
171
+ </box>
172
+ );
173
+ }
174
+
175
+ // Fallback: show raw preview text
176
+ const previewLines = details.preview.split('\n').slice(0, 20);
177
+ return (
178
+ <box
179
+ border
180
+ style={{
181
+ padding: 1,
182
+ borderColor: theme?.colors.panel.border,
183
+ backgroundColor: theme?.colors.panel.bg,
184
+ flexDirection: 'column',
185
+ gap: 0,
186
+ maxHeight: 15,
187
+ overflow: 'hidden',
188
+ }}
189
+ >
190
+ {previewLines.map((line, index) => (
191
+ <text key={`preview-${index}`} fg={theme?.colors.text.tool}>
192
+ {line}
193
+ </text>
194
+ ))}
195
+ </box>
196
+ );
197
+ }
198
+
199
+ export function ToolApprovalModal(
200
+ props: ToolApprovalModalProps,
201
+ ): React.ReactNode {
202
+ const { details, onDecision, onClose, theme } = props;
203
+
204
+ const options = useMemo((): RadioSelectOption<ToolApprovalOutcome>[] => {
205
+ const result: RadioSelectOption<ToolApprovalOutcome>[] = [
206
+ { label: 'Yes, allow once', value: 'allow_once', key: 'allow_once' },
207
+ ];
208
+
209
+ if (details.canAllowAlways) {
210
+ result.push({
211
+ label: 'Yes, allow always',
212
+ value: 'allow_always',
213
+ key: 'allow_always',
214
+ });
215
+ }
216
+
217
+ result.push({
218
+ label: 'No, cancel (esc)',
219
+ value: 'cancel',
220
+ key: 'cancel',
221
+ });
222
+
223
+ return result;
224
+ }, [details.canAllowAlways]);
225
+
226
+ const handleSelect = useCallback(
227
+ (outcome: ToolApprovalOutcome): void => {
228
+ onDecision(details.callId, outcome);
229
+ onClose();
230
+ },
231
+ [details.callId, onDecision, onClose],
232
+ );
233
+
234
+ const typeIcon = getTypeIcon(details.confirmationType);
235
+ const typeLabel = getTypeLabel(details.confirmationType);
236
+
237
+ // For edits, include filename in title
238
+ let title = `${typeIcon} ${typeLabel}: ${details.toolName}`;
239
+ if (
240
+ details.confirmationType === 'edit' &&
241
+ details.coreDetails?.type === 'edit'
242
+ ) {
243
+ title = `${typeIcon} ${typeLabel}: ${details.coreDetails.fileName}`;
244
+ }
245
+
246
+ const footer = (
247
+ <text fg={theme?.colors.text.muted}>
248
+ ↑/↓ to navigate, Enter to select, Esc to cancel
249
+ </text>
250
+ );
251
+
252
+ return (
253
+ <ModalShell
254
+ title={title}
255
+ subtitle={details.question}
256
+ onClose={onClose}
257
+ theme={theme}
258
+ footer={footer}
259
+ width="80%"
260
+ >
261
+ <box
262
+ flexDirection="column"
263
+ style={{
264
+ gap: 1,
265
+ paddingLeft: 1,
266
+ paddingRight: 1,
267
+ }}
268
+ >
269
+ <PreviewContent details={details} theme={theme} />
270
+
271
+ <box style={{ marginTop: 1 }}>
272
+ <RadioSelect
273
+ options={options}
274
+ onSelect={handleSelect}
275
+ theme={theme}
276
+ isFocused={true}
277
+ />
278
+ </box>
279
+ </box>
280
+ </ModalShell>
281
+ );
282
+ }
@@ -0,0 +1,20 @@
1
+ export { ModalShell, type ModalShellProps } from './ModalShell';
2
+ export { SearchSelectModal, type SearchSelectProps } from './SearchSelectModal';
3
+ export { AuthModal, type AuthOption } from './AuthModal';
4
+ export { ThemeModal } from './ThemeModal';
5
+ export { filterItems, type SearchItem } from './types';
6
+ export {
7
+ ToolApprovalModal,
8
+ type ToolApprovalModalProps,
9
+ type ToolApprovalDetails,
10
+ type ToolApprovalOutcome,
11
+ } from './ToolApprovalModal';
12
+
13
+ // Default auth options used by the auth dialog
14
+ import type { AuthOption } from './AuthModal';
15
+ export const AUTH_DEFAULTS: AuthOption[] = [
16
+ { id: 'gemini', label: '1. Gemini (Google OAuth)', enabled: true },
17
+ { id: 'qwen', label: '2. Qwen (OAuth)', enabled: true },
18
+ { id: 'anthropic', label: '3. Anthropic Claude (OAuth)', enabled: true },
19
+ { id: 'close', label: '4. Close', enabled: false },
20
+ ];
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { filterItems, type SearchItem } from './types';
3
+
4
+ const SAMPLE_ITEMS: SearchItem[] = [
5
+ { id: 'a', label: 'Alpha' },
6
+ { id: 'b', label: 'Beta' },
7
+ { id: 'g', label: 'Gamma' },
8
+ ];
9
+
10
+ describe('filterItems', () => {
11
+ it('filters by substring', () => {
12
+ const result = filterItems(SAMPLE_ITEMS, 'al');
13
+ expect(result.map((item) => item.id)).toStrictEqual(['a']);
14
+ });
15
+
16
+ it('sorts alphabetically when requested', () => {
17
+ const result = filterItems(SAMPLE_ITEMS, '', true);
18
+ expect(result.map((item) => item.id)).toStrictEqual(['a', 'b', 'g']);
19
+ });
20
+
21
+ it('preserves order when not sorting', () => {
22
+ const reversed = [...SAMPLE_ITEMS].reverse();
23
+ const result = filterItems(reversed, '');
24
+ expect(result.map((item) => item.id)).toStrictEqual(['g', 'b', 'a']);
25
+ });
26
+ });
@@ -0,0 +1,19 @@
1
+ export interface SearchItem {
2
+ readonly id: string;
3
+ readonly label: string;
4
+ }
5
+
6
+ export function filterItems(
7
+ items: SearchItem[],
8
+ query: string,
9
+ alphabetical?: boolean,
10
+ ): SearchItem[] {
11
+ const normalized = query.trim().toLowerCase();
12
+ const filtered = items.filter((item) =>
13
+ item.label.toLowerCase().includes(normalized),
14
+ );
15
+ if (alphabetical === true) {
16
+ return [...filtered].sort((a, b) => a.label.localeCompare(b.label));
17
+ }
18
+ return filtered;
19
+ }
@@ -0,0 +1,102 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import type { DialogContextValue } from './Dialog';
11
+
12
+ interface CommandDef {
13
+ readonly name: string;
14
+ readonly title: string;
15
+ readonly category?: string;
16
+ readonly onExecute: (dialog: DialogContextValue) => void | Promise<void>;
17
+ }
18
+
19
+ interface CommandContextValue {
20
+ readonly register: (commands: CommandDef[]) => () => void;
21
+ readonly trigger: (name: string) => Promise<boolean>;
22
+ readonly getCommands: () => CommandDef[];
23
+ }
24
+
25
+ const CommandContext = createContext<CommandContextValue | null>(null);
26
+
27
+ export function useCommand(): CommandContextValue {
28
+ const context = useContext(CommandContext);
29
+ if (context === null) {
30
+ throw new Error('useCommand must be used within Command');
31
+ }
32
+ return context;
33
+ }
34
+
35
+ interface CommandProps {
36
+ readonly children: ReactNode;
37
+ readonly dialogContext: DialogContextValue;
38
+ }
39
+
40
+ let registrationId = 0;
41
+
42
+ export function Command({
43
+ children,
44
+ dialogContext,
45
+ }: CommandProps): React.ReactNode {
46
+ const [commands, setCommands] = useState<Map<string, CommandDef>>(new Map());
47
+ const mountedComponents = useRef(new Set<number>());
48
+
49
+ const register = useCallback((newCommands: CommandDef[]) => {
50
+ registrationId += 1;
51
+ const componentId = registrationId;
52
+ mountedComponents.current.add(componentId);
53
+
54
+ setCommands((prev) => {
55
+ const next = new Map(prev);
56
+ for (const command of newCommands) {
57
+ next.set(command.name, command);
58
+ }
59
+ return next;
60
+ });
61
+
62
+ return () => {
63
+ mountedComponents.current.delete(componentId);
64
+ setCommands((prev) => {
65
+ const next = new Map(prev);
66
+ for (const command of newCommands) {
67
+ next.delete(command.name);
68
+ }
69
+ return next;
70
+ });
71
+ };
72
+ }, []);
73
+
74
+ const trigger = useCallback(
75
+ async (name: string): Promise<boolean> => {
76
+ const command = commands.get(name);
77
+ if (command === undefined) {
78
+ return false;
79
+ }
80
+ await command.onExecute(dialogContext);
81
+ return true;
82
+ },
83
+ [commands, dialogContext],
84
+ );
85
+
86
+ const getCommands = useCallback((): CommandDef[] => {
87
+ return Array.from(commands.values());
88
+ }, [commands]);
89
+
90
+ const contextValue = useMemo(
91
+ () => ({ register, trigger, getCommands }),
92
+ [register, trigger, getCommands],
93
+ );
94
+
95
+ return (
96
+ <CommandContext.Provider value={contextValue}>
97
+ {children}
98
+ </CommandContext.Provider>
99
+ );
100
+ }
101
+
102
+ export type { CommandDef };