@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,257 @@
1
+ import {
2
+ GeminiEventType,
3
+ type ServerGeminiStreamEvent,
4
+ type ToolCallResponseInfo,
5
+ type ServerToolCallConfirmationDetails,
6
+ } from '@vybestack/llxprt-code-core';
7
+ import { getLogger } from '../../lib/logger';
8
+ import type { AdapterEvent, ToolConfirmationType } from '../../types/events';
9
+ import type { ConfigSession } from './configSession';
10
+ import { createConfigSession } from './configSession';
11
+
12
+ export type {
13
+ AdapterEvent,
14
+ ToolPendingEvent,
15
+ ToolConfirmationEvent,
16
+ } from '../../types/events';
17
+
18
+ /** Branded type for provider identifiers */
19
+ export type ProviderKey = string & { readonly __brand?: 'ProviderKey' };
20
+
21
+ export interface ProviderInfo {
22
+ readonly id: ProviderKey;
23
+ readonly label: string;
24
+ }
25
+
26
+ export interface ModelInfo {
27
+ readonly id: string;
28
+ readonly name: string;
29
+ }
30
+
31
+ export interface SessionConfig {
32
+ readonly provider: ProviderKey;
33
+ readonly model?: string;
34
+ readonly apiKey?: string;
35
+ readonly keyFilePath?: string;
36
+ readonly baseUrl?: string;
37
+ readonly ephemeralSettings?: Record<string, unknown>;
38
+ }
39
+
40
+ const logger = getLogger('nui:llxprt-adapter');
41
+
42
+ /**
43
+ * Extract display output from a tool result
44
+ */
45
+ function getToolResultOutput(info: ToolCallResponseInfo): string {
46
+ if (info.error) {
47
+ return info.error.message;
48
+ }
49
+ if (info.resultDisplay === undefined) {
50
+ return '(no output)';
51
+ }
52
+ if (typeof info.resultDisplay === 'string') {
53
+ return info.resultDisplay;
54
+ }
55
+ // FileDiff object - format as diff
56
+ const diff = info.resultDisplay;
57
+ return `File: ${diff.fileName}\n${diff.fileDiff}`;
58
+ }
59
+
60
+ /**
61
+ * Map confirmation details type to our simplified type
62
+ */
63
+ function getConfirmationType(
64
+ details: ServerToolCallConfirmationDetails,
65
+ ): ToolConfirmationType {
66
+ const type = details.details.type as string;
67
+ if (type === 'edit') return 'edit';
68
+ if (type === 'exec') return 'exec';
69
+ if (type === 'mcp') return 'mcp';
70
+ if (type === 'info') return 'info';
71
+ return 'exec'; // fallback for unknown types
72
+ }
73
+
74
+ /**
75
+ * Get the preview content for a confirmation dialog
76
+ */
77
+ function getConfirmationPreview(
78
+ details: ServerToolCallConfirmationDetails,
79
+ ): string {
80
+ const d = details.details;
81
+ const type = d.type as string;
82
+ if (type === 'edit' && 'fileDiff' in d) {
83
+ return d.fileDiff || `Edit: ${d.filePath}`;
84
+ }
85
+ if (type === 'exec' && 'command' in d) {
86
+ return d.command;
87
+ }
88
+ if (type === 'mcp' && 'toolDisplayName' in d) {
89
+ return `MCP Tool: ${d.toolDisplayName} (server: ${d.serverName})`;
90
+ }
91
+ if (type === 'info' && 'prompt' in d) {
92
+ const urls =
93
+ 'urls' in d && Array.isArray(d.urls) && d.urls.length > 0
94
+ ? `\nURLs: ${d.urls.join(', ')}`
95
+ : '';
96
+ return d.prompt + urls;
97
+ }
98
+ return '';
99
+ }
100
+
101
+ /**
102
+ * Get the question text for a confirmation dialog
103
+ */
104
+ function getConfirmationQuestion(
105
+ details: ServerToolCallConfirmationDetails,
106
+ ): string {
107
+ const d = details.details;
108
+ const type = d.type as string;
109
+ if (type === 'edit') {
110
+ return 'Apply this change?';
111
+ }
112
+ if (type === 'exec' && 'rootCommand' in d) {
113
+ return `Allow execution of: '${d.rootCommand}'?`;
114
+ }
115
+ if (type === 'mcp' && 'toolDisplayName' in d) {
116
+ return `Allow ${d.toolDisplayName}?`;
117
+ }
118
+ if (type === 'info') {
119
+ return 'Allow this fetch?';
120
+ }
121
+ return 'Allow this action?';
122
+ }
123
+
124
+ export function transformEvent(event: ServerGeminiStreamEvent): AdapterEvent {
125
+ if (event.type === GeminiEventType.Content) {
126
+ return { type: 'text_delta', text: event.value };
127
+ }
128
+ if (event.type === GeminiEventType.Thought) {
129
+ return { type: 'thinking_delta', text: event.value.description };
130
+ }
131
+ if (event.type === GeminiEventType.ToolCallRequest) {
132
+ return {
133
+ type: 'tool_pending',
134
+ id: event.value.callId,
135
+ name: event.value.name,
136
+ params: event.value.args,
137
+ };
138
+ }
139
+ if (event.type === GeminiEventType.ToolCallResponse) {
140
+ const info = event.value;
141
+ return {
142
+ type: 'tool_result',
143
+ id: info.callId,
144
+ success: !info.error,
145
+ output: getToolResultOutput(info),
146
+ errorMessage: info.error?.message,
147
+ };
148
+ }
149
+ if (event.type === GeminiEventType.ToolCallConfirmation) {
150
+ const details = event.value;
151
+ // Extract correlationId from the details object
152
+ const correlationId =
153
+ (details.details as { correlationId?: string }).correlationId ??
154
+ details.request.callId;
155
+ return {
156
+ type: 'tool_confirmation',
157
+ id: details.request.callId,
158
+ name: details.request.name,
159
+ params: details.request.args,
160
+ confirmationType: getConfirmationType(details),
161
+ question: getConfirmationQuestion(details),
162
+ preview: getConfirmationPreview(details),
163
+ canAllowAlways: true, // Will be determined by trusted folder status in future
164
+ correlationId,
165
+ };
166
+ }
167
+ if (event.type === GeminiEventType.UserCancelled) {
168
+ // This is a general cancellation, not tied to a specific tool
169
+ return { type: 'complete' };
170
+ }
171
+ if (event.type === GeminiEventType.Finished) {
172
+ return { type: 'complete' };
173
+ }
174
+ if (event.type === GeminiEventType.Error) {
175
+ return { type: 'error', message: event.value.error.message };
176
+ }
177
+ logger.warn('Unhandled event type received', { eventType: event.type });
178
+ return { type: 'unknown', raw: event };
179
+ }
180
+
181
+ export async function* transformStream(
182
+ stream: AsyncIterable<ServerGeminiStreamEvent>,
183
+ ): AsyncGenerator<AdapterEvent> {
184
+ for await (const event of stream) {
185
+ yield transformEvent(event);
186
+ }
187
+ }
188
+
189
+ export async function* sendMessageWithSession(
190
+ session: ConfigSession,
191
+ prompt: string,
192
+ signal?: AbortSignal,
193
+ ): AsyncGenerator<AdapterEvent> {
194
+ const client = session.getClient();
195
+ const promptId = `nui-prompt-${Date.now()}`;
196
+ const stream = client.sendMessageStream(
197
+ prompt,
198
+ signal ?? new AbortController().signal,
199
+ promptId,
200
+ );
201
+
202
+ for await (const event of stream) {
203
+ yield transformEvent(event);
204
+ }
205
+ }
206
+
207
+ const PROVIDER_ENTRIES: ProviderInfo[] = [
208
+ { id: 'openai', label: 'OpenAI' },
209
+ { id: 'openai-responses', label: 'OpenAI Responses' },
210
+ { id: 'openai-vercel', label: 'OpenAI (Vercel)' },
211
+ { id: 'gemini', label: 'Gemini' },
212
+ { id: 'anthropic', label: 'Anthropic' },
213
+ { id: 'synthetic', label: 'Synthetic' },
214
+ { id: 'qwen', label: 'Qwen' },
215
+ { id: 'qwen-openai', label: 'Qwen (OpenAI API)' },
216
+ ];
217
+
218
+ export function listProviders(): ProviderInfo[] {
219
+ // Deduplicate by id in case we add aliases dynamically later
220
+ const seen = new Set<string>();
221
+ return PROVIDER_ENTRIES.filter((entry) => {
222
+ if (seen.has(entry.id)) {
223
+ return false;
224
+ }
225
+ seen.add(entry.id);
226
+ return true;
227
+ });
228
+ }
229
+
230
+ export async function listModels(session: SessionConfig): Promise<ModelInfo[]> {
231
+ // Model listing not currently implemented.
232
+ // ConfigSession doesn't expose getProvider() directly.
233
+ // Future implementation should use ConfigSession.config.getProviderManager()
234
+ logger.debug('listModels called but not implemented', {
235
+ provider: session.provider,
236
+ });
237
+ return Promise.resolve([]);
238
+ }
239
+
240
+ export async function* sendMessage(
241
+ sessionConfig: SessionConfig,
242
+ prompt: string,
243
+ signal?: AbortSignal,
244
+ ): AsyncGenerator<AdapterEvent> {
245
+ const session = createConfigSession({
246
+ model: sessionConfig.model ?? 'gemini-2.5-flash',
247
+ workingDir: process.cwd(),
248
+ provider: sessionConfig.provider,
249
+ baseUrl: sessionConfig.baseUrl,
250
+ authKeyfile: sessionConfig.keyFilePath,
251
+ apiKey: sessionConfig.apiKey,
252
+ });
253
+
254
+ await session.initialize();
255
+
256
+ yield* sendMessageWithSession(session, prompt, signal);
257
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import type { SessionConfig } from './llxprtAdapter';
5
+ import { handleModelListCommand } from './llxprtCommands';
6
+
7
+ const BASE: SessionConfig = {
8
+ provider: 'openai',
9
+ baseUrl: 'https://example.test/api',
10
+ keyFilePath: path.join(os.tmpdir(), 'nui-test-key'),
11
+ model: 'dummy',
12
+ };
13
+
14
+ describe('handleModelListCommand', () => {
15
+ it('returns missing config messages when incomplete', async () => {
16
+ const result = await handleModelListCommand({ provider: 'openai' });
17
+ expect(result.handled).toBe(true);
18
+ expect(
19
+ result.messages.some((m) => m.toLowerCase().includes('base url')),
20
+ ).toBe(true);
21
+ });
22
+
23
+ it('lists models from provider', async () => {
24
+ const listModelsImpl = vi.fn().mockResolvedValue([
25
+ { id: 'm1', name: 'Model One' },
26
+ { id: 'm2', name: 'Model Two' },
27
+ ]);
28
+ const result = await handleModelListCommand(BASE, { listModelsImpl });
29
+ expect(listModelsImpl).toHaveBeenCalledOnce();
30
+ expect(result.messages[0]).toContain('Available models');
31
+ expect(result.messages.some((line) => line.includes('m1'))).toBe(true);
32
+ expect(result.messages.some((line) => line.includes('m2'))).toBe(true);
33
+ });
34
+
35
+ it('handles provider errors', async () => {
36
+ const listModelsImpl = vi.fn().mockRejectedValue(new Error('boom'));
37
+ const result = await handleModelListCommand(BASE, { listModelsImpl });
38
+ expect(result.messages[0]).toContain('Failed to list models');
39
+ });
40
+ });
@@ -0,0 +1,35 @@
1
+ import type { SessionConfig } from './llxprtAdapter';
2
+ import { listModels, type ModelInfo } from './llxprtAdapter';
3
+ import { validateSessionConfig } from './llxprtConfig';
4
+
5
+ export interface CommandOutcome {
6
+ readonly handled: boolean;
7
+ readonly messages: string[];
8
+ }
9
+
10
+ export async function handleModelListCommand(
11
+ session: SessionConfig,
12
+ deps?: { listModelsImpl?: (session: SessionConfig) => Promise<ModelInfo[]> },
13
+ ): Promise<CommandOutcome> {
14
+ const missing = validateSessionConfig(session, { requireModel: false });
15
+ if (missing.length > 0) {
16
+ return { handled: true, messages: missing };
17
+ }
18
+ const getModels = deps?.listModelsImpl ?? listModels;
19
+ try {
20
+ const models = await getModels(session);
21
+ if (models.length === 0) {
22
+ return { handled: true, messages: ['No models returned by provider.'] };
23
+ }
24
+ const lines = ['Available models:'].concat(
25
+ models.map((model) => {
26
+ const namePart = model.name ? ` (${model.name})` : '';
27
+ return `- ${model.id}${namePart}`;
28
+ }),
29
+ );
30
+ return { handled: true, messages: lines };
31
+ } catch (error) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ return { handled: true, messages: [`Failed to list models: ${message}`] };
34
+ }
35
+ }
@@ -0,0 +1,261 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import {
5
+ applyConfigCommand,
6
+ validateSessionConfig,
7
+ profileToConfigOptions,
8
+ } from './llxprtConfig';
9
+ import type { SessionConfig } from './llxprtAdapter';
10
+ import type { ProfileData } from './llxprtConfig';
11
+ import type { Profile } from '@vybestack/llxprt-code-core';
12
+
13
+ const BASE_CONFIG: SessionConfig = { provider: 'openai' };
14
+
15
+ describe('applyConfigCommand', () => {
16
+ it('sets provider when valid', async () => {
17
+ const result = await applyConfigCommand('/provider gemini', BASE_CONFIG);
18
+ expect(result.handled).toBe(true);
19
+ expect(result.nextConfig.provider).toBe('gemini');
20
+ expect(result.messages[0]).toContain('Provider set to gemini');
21
+ });
22
+
23
+ it('defers empty provider to caller', async () => {
24
+ const result = await applyConfigCommand('/provider', BASE_CONFIG);
25
+ expect(result.handled).toBe(false);
26
+ expect(result.nextConfig).toStrictEqual(BASE_CONFIG);
27
+ });
28
+
29
+ it('rejects unknown provider', async () => {
30
+ const result = await applyConfigCommand('/provider something', BASE_CONFIG);
31
+ expect(result.handled).toBe(true);
32
+ expect(result.nextConfig.provider).toBe('openai');
33
+ expect(result.messages[0]).toContain('Unknown provider');
34
+ });
35
+
36
+ it('sets base url and accepts alias', async () => {
37
+ const result = await applyConfigCommand(
38
+ '/basurl https://example.test/api',
39
+ BASE_CONFIG,
40
+ );
41
+ expect(result.handled).toBe(true);
42
+ expect(result.nextConfig.baseUrl).toBe('https://example.test/api');
43
+ });
44
+
45
+ it('sets key and clears keyfile', async () => {
46
+ const result = await applyConfigCommand('/key secret', {
47
+ ...BASE_CONFIG,
48
+ keyFilePath: path.join(os.tmpdir(), 'nui-test-key'),
49
+ });
50
+ expect(result.nextConfig.apiKey).toBe('secret');
51
+ expect(result.nextConfig.keyFilePath).toBeUndefined();
52
+ });
53
+
54
+ it('sets keyfile and clears key', async () => {
55
+ const testKeyPath = path.join(os.tmpdir(), 'nui-test-keyfile');
56
+ const result = await applyConfigCommand(`/keyfile ${testKeyPath}`, {
57
+ ...BASE_CONFIG,
58
+ apiKey: 'old',
59
+ });
60
+ expect(result.nextConfig.keyFilePath).toBe(testKeyPath);
61
+ expect(result.nextConfig.apiKey).toBeUndefined();
62
+ });
63
+
64
+ it('sets model', async () => {
65
+ const result = await applyConfigCommand('/model hf:test', BASE_CONFIG);
66
+ expect(result.nextConfig.model).toBe('hf:test');
67
+ });
68
+
69
+ it('defers empty model to caller', async () => {
70
+ const result = await applyConfigCommand('/model', BASE_CONFIG);
71
+ expect(result.handled).toBe(false);
72
+ expect(result.nextConfig).toStrictEqual(BASE_CONFIG);
73
+ });
74
+
75
+ it('ignores unknown command', async () => {
76
+ const result = await applyConfigCommand('/unknown foo', BASE_CONFIG);
77
+ expect(result.handled).toBe(false);
78
+ expect(result.nextConfig).toStrictEqual(BASE_CONFIG);
79
+ });
80
+
81
+ it('loads profile when complete with load action', async () => {
82
+ const manager = new FakeProfileManager({
83
+ synthetic: {
84
+ version: 1,
85
+ provider: 'openai',
86
+ model: 'hf:zai-org/GLM-4.6',
87
+ modelParams: { temperature: 0.7 },
88
+ ephemeralSettings: {
89
+ 'base-url': 'https://api.synthetic.new/openai/v1',
90
+ 'auth-keyfile': '/Users/example/.synthetic_key',
91
+ },
92
+ },
93
+ });
94
+
95
+ const result = await applyConfigCommand(
96
+ '/profile load synthetic',
97
+ BASE_CONFIG,
98
+ { profileManager: manager },
99
+ );
100
+ expect(result.messages[0]).toContain('Loaded profile: synthetic');
101
+ expect(result.nextConfig.provider).toBe('openai');
102
+ expect(result.nextConfig.model).toBe('hf:zai-org/GLM-4.6');
103
+ expect(result.nextConfig.baseUrl).toBe(
104
+ 'https://api.synthetic.new/openai/v1',
105
+ );
106
+ expect(result.nextConfig.keyFilePath).toBe('/Users/example/.synthetic_key');
107
+ });
108
+
109
+ it('reports error for incomplete profile', async () => {
110
+ const manager = new FakeProfileManager({
111
+ synthetic: {
112
+ version: 1,
113
+ provider: 'openai',
114
+ },
115
+ });
116
+
117
+ const result = await applyConfigCommand(
118
+ '/profile load synthetic',
119
+ BASE_CONFIG,
120
+ { profileManager: manager },
121
+ );
122
+ expect(result.messages[0]).toContain('incomplete');
123
+ expect(result.nextConfig).toStrictEqual(BASE_CONFIG);
124
+ });
125
+
126
+ it('validates missing pieces', () => {
127
+ const messages = validateSessionConfig({ provider: 'openai' });
128
+ expect(messages.some((m) => m.toLowerCase().includes('base url'))).toBe(
129
+ true,
130
+ );
131
+ expect(messages.some((m) => m.toLowerCase().includes('model'))).toBe(true);
132
+ expect(messages.some((m) => m.toLowerCase().includes('key'))).toBe(true);
133
+ });
134
+
135
+ it('can skip model requirement', () => {
136
+ const messages = validateSessionConfig(
137
+ { provider: 'openai' },
138
+ { requireModel: false },
139
+ );
140
+ expect(messages.some((m) => m.toLowerCase().includes('model'))).toBe(false);
141
+ expect(messages.some((m) => m.toLowerCase().includes('base url'))).toBe(
142
+ true,
143
+ );
144
+ });
145
+ });
146
+
147
+ class FakeProfileManager {
148
+ private readonly profiles: Record<string, unknown>;
149
+
150
+ constructor(profiles: Record<string, unknown>) {
151
+ this.profiles = profiles;
152
+ }
153
+
154
+ loadProfile(name: string): Promise<Profile> {
155
+ const profile = this.profiles[name];
156
+ if (profile === undefined) {
157
+ return Promise.reject(new Error(`Profile '${name}' not found`));
158
+ }
159
+ return Promise.resolve(profile as Profile);
160
+ }
161
+
162
+ listProfiles(): Promise<string[]> {
163
+ return Promise.resolve(Object.keys(this.profiles));
164
+ }
165
+ }
166
+
167
+ describe('profileToConfigOptions', () => {
168
+ it('should convert ProfileData to ConfigSessionOptions', () => {
169
+ const profile: ProfileData = {
170
+ provider: 'openai',
171
+ model: 'gpt-4',
172
+ baseUrl: 'https://api.example.com',
173
+ authKeyfile: '/path/to/key',
174
+ ephemeralSettings: { streaming: 'disabled' },
175
+ };
176
+
177
+ const options = profileToConfigOptions(profile, '/work/dir');
178
+
179
+ expect(options.model).toBe('gpt-4');
180
+ expect(options.provider).toBe('openai');
181
+ expect(options.baseUrl).toBe('https://api.example.com');
182
+ expect(options.authKeyfile).toBe('/path/to/key');
183
+ expect(options.workingDir).toBe('/work/dir');
184
+ });
185
+
186
+ it('should handle ephemeral settings override for baseUrl', () => {
187
+ const profile: ProfileData = {
188
+ provider: 'openai',
189
+ model: 'gpt-4',
190
+ baseUrl: 'https://default.api.com',
191
+ ephemeralSettings: { 'base-url': 'https://override.api.com' },
192
+ };
193
+
194
+ const options = profileToConfigOptions(profile, '/work');
195
+
196
+ expect(options.baseUrl).toBe('https://override.api.com');
197
+ });
198
+
199
+ it('should handle ephemeral settings override for model', () => {
200
+ const profile: ProfileData = {
201
+ provider: 'gemini',
202
+ model: 'gemini-2.5-flash',
203
+ ephemeralSettings: { model: 'gemini-2.5-pro' },
204
+ };
205
+
206
+ const options = profileToConfigOptions(profile, '/work');
207
+
208
+ expect(options.model).toBe('gemini-2.5-pro');
209
+ });
210
+
211
+ it('should handle ephemeral settings override for authKeyfile', () => {
212
+ const profile: ProfileData = {
213
+ provider: 'gemini',
214
+ model: 'gemini-2.5-flash',
215
+ authKeyfile: '/default/key',
216
+ ephemeralSettings: { 'auth-keyfile': '/override/key' },
217
+ };
218
+
219
+ const options = profileToConfigOptions(profile, '/work');
220
+
221
+ expect(options.authKeyfile).toBe('/override/key');
222
+ });
223
+
224
+ it('should handle missing optional fields', () => {
225
+ const profile: ProfileData = {
226
+ provider: 'gemini',
227
+ model: 'gemini-2.5-flash',
228
+ };
229
+
230
+ const options = profileToConfigOptions(profile, '/work');
231
+
232
+ expect(options.model).toBe('gemini-2.5-flash');
233
+ expect(options.provider).toBe('gemini');
234
+ expect(options.baseUrl).toBeUndefined();
235
+ expect(options.authKeyfile).toBeUndefined();
236
+ });
237
+
238
+ it('should handle apiKey from ephemeral settings', () => {
239
+ const profile: ProfileData = {
240
+ provider: 'openai',
241
+ model: 'gpt-4',
242
+ ephemeralSettings: { 'auth-key': 'sk-test-key-12345' },
243
+ };
244
+
245
+ const options = profileToConfigOptions(profile, '/work');
246
+
247
+ expect(options.apiKey).toBe('sk-test-key-12345');
248
+ });
249
+
250
+ it('should use authKeyfile from profile when not in ephemeral settings', () => {
251
+ const profile: ProfileData = {
252
+ provider: 'gemini',
253
+ model: 'gemini-2.5-flash',
254
+ authKeyfile: '/profile/key',
255
+ };
256
+
257
+ const options = profileToConfigOptions(profile, '/work');
258
+
259
+ expect(options.authKeyfile).toBe('/profile/key');
260
+ });
261
+ });