@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.
- package/PLAN-messages.md +681 -0
- package/PLAN.md +47 -0
- package/README.md +25 -0
- package/bun.lock +1024 -0
- package/dev-docs/ARCHITECTURE.md +178 -0
- package/dev-docs/CODE_ORGANIZATION.md +232 -0
- package/dev-docs/STANDARDS.md +235 -0
- package/dev-docs/UI_DESIGN.md +425 -0
- package/eslint.config.cjs +194 -0
- package/images/nui.png +0 -0
- package/llxprt.png +0 -0
- package/llxprt.svg +128 -0
- package/package.json +66 -0
- package/scripts/check-limits.ts +177 -0
- package/scripts/start.js +71 -0
- package/src/app.tsx +599 -0
- package/src/bootstrap.tsx +23 -0
- package/src/commands/AuthCommand.tsx +80 -0
- package/src/commands/ModelCommand.tsx +102 -0
- package/src/commands/ProviderCommand.tsx +103 -0
- package/src/commands/ThemeCommand.tsx +71 -0
- package/src/features/chat/history.ts +178 -0
- package/src/features/chat/index.ts +3 -0
- package/src/features/chat/persistentHistory.ts +102 -0
- package/src/features/chat/responder.ts +217 -0
- package/src/features/completion/completions.ts +161 -0
- package/src/features/completion/index.ts +3 -0
- package/src/features/completion/slash.test.ts +82 -0
- package/src/features/completion/slash.ts +248 -0
- package/src/features/completion/suggestions.test.ts +51 -0
- package/src/features/completion/suggestions.ts +112 -0
- package/src/features/config/configSession.test.ts +189 -0
- package/src/features/config/configSession.ts +179 -0
- package/src/features/config/index.ts +4 -0
- package/src/features/config/llxprtAdapter.integration.test.ts +202 -0
- package/src/features/config/llxprtAdapter.test.ts +139 -0
- package/src/features/config/llxprtAdapter.ts +257 -0
- package/src/features/config/llxprtCommands.test.ts +40 -0
- package/src/features/config/llxprtCommands.ts +35 -0
- package/src/features/config/llxprtConfig.test.ts +261 -0
- package/src/features/config/llxprtConfig.ts +418 -0
- package/src/features/theme/index.ts +2 -0
- package/src/features/theme/theme.test.ts +51 -0
- package/src/features/theme/theme.ts +105 -0
- package/src/features/theme/themeManager.ts +84 -0
- package/src/hooks/useAppCommands.ts +129 -0
- package/src/hooks/useApprovalKeyboard.ts +156 -0
- package/src/hooks/useChatStore.test.ts +112 -0
- package/src/hooks/useChatStore.ts +252 -0
- package/src/hooks/useInputManager.ts +99 -0
- package/src/hooks/useKeyboardHandlers.ts +130 -0
- package/src/hooks/useListNavigation.test.ts +166 -0
- package/src/hooks/useListNavigation.ts +62 -0
- package/src/hooks/usePersistentHistory.ts +94 -0
- package/src/hooks/useScrollManagement.ts +107 -0
- package/src/hooks/useSelectionClipboard.ts +48 -0
- package/src/hooks/useSessionManager.test.ts +85 -0
- package/src/hooks/useSessionManager.ts +101 -0
- package/src/hooks/useStreamingLifecycle.ts +71 -0
- package/src/hooks/useStreamingResponder.ts +401 -0
- package/src/hooks/useSuggestionSetup.ts +23 -0
- package/src/hooks/useToolApproval.test.ts +140 -0
- package/src/hooks/useToolApproval.ts +264 -0
- package/src/hooks/useToolScheduler.ts +432 -0
- package/src/index.ts +3 -0
- package/src/jsx.d.ts +11 -0
- package/src/lib/clipboard.ts +18 -0
- package/src/lib/logger.ts +107 -0
- package/src/lib/random.ts +5 -0
- package/src/main.tsx +13 -0
- package/src/test/mockTheme.ts +51 -0
- package/src/types/events.ts +87 -0
- package/src/types.ts +13 -0
- package/src/ui/components/ChatLayout.tsx +694 -0
- package/src/ui/components/CommandComponents.tsx +74 -0
- package/src/ui/components/DiffViewer.tsx +306 -0
- package/src/ui/components/FilterInput.test.ts +69 -0
- package/src/ui/components/FilterInput.tsx +62 -0
- package/src/ui/components/HeaderBar.tsx +137 -0
- package/src/ui/components/RadioSelect.test.ts +140 -0
- package/src/ui/components/RadioSelect.tsx +88 -0
- package/src/ui/components/SelectableList.test.ts +83 -0
- package/src/ui/components/SelectableList.tsx +35 -0
- package/src/ui/components/StatusBar.tsx +45 -0
- package/src/ui/components/SuggestionPanel.tsx +102 -0
- package/src/ui/components/messages/ModelMessage.tsx +14 -0
- package/src/ui/components/messages/SystemMessage.tsx +29 -0
- package/src/ui/components/messages/ThinkingMessage.tsx +14 -0
- package/src/ui/components/messages/UserMessage.tsx +26 -0
- package/src/ui/components/messages/index.ts +15 -0
- package/src/ui/components/messages/renderMessage.test.ts +49 -0
- package/src/ui/components/messages/renderMessage.tsx +43 -0
- package/src/ui/components/messages/types.test.ts +24 -0
- package/src/ui/components/messages/types.ts +36 -0
- package/src/ui/modals/AuthModal.tsx +106 -0
- package/src/ui/modals/ModalShell.tsx +60 -0
- package/src/ui/modals/SearchSelectModal.tsx +236 -0
- package/src/ui/modals/ThemeModal.tsx +204 -0
- package/src/ui/modals/ToolApprovalModal.test.ts +206 -0
- package/src/ui/modals/ToolApprovalModal.tsx +282 -0
- package/src/ui/modals/index.ts +20 -0
- package/src/ui/modals/modals.test.ts +26 -0
- package/src/ui/modals/types.ts +19 -0
- package/src/uicontext/Command.tsx +102 -0
- package/src/uicontext/Dialog.tsx +65 -0
- package/src/uicontext/index.ts +2 -0
- package/themes/ansi-light.json +59 -0
- package/themes/ansi.json +59 -0
- package/themes/atom-one-dark.json +59 -0
- package/themes/ayu-light.json +59 -0
- package/themes/ayu.json +59 -0
- package/themes/default-light.json +59 -0
- package/themes/default.json +59 -0
- package/themes/dracula.json +59 -0
- package/themes/github-dark.json +59 -0
- package/themes/github-light.json +59 -0
- package/themes/googlecode.json +59 -0
- package/themes/green-screen.json +59 -0
- package/themes/no-color.json +59 -0
- package/themes/shades-of-purple.json +59 -0
- package/themes/xcode.json +59 -0
- package/tsconfig.json +28 -0
- 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 };
|