@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,418 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ProfileManager } from '@vybestack/llxprt-code-core';
|
|
4
|
+
import type { ProviderKey, SessionConfig } from './llxprtAdapter';
|
|
5
|
+
import type { ConfigSessionOptions } from './configSession';
|
|
6
|
+
|
|
7
|
+
export interface ConfigCommandResult {
|
|
8
|
+
readonly handled: boolean;
|
|
9
|
+
readonly nextConfig: SessionConfig;
|
|
10
|
+
readonly messages: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ConfigCommandResultWithSession extends ConfigCommandResult {
|
|
14
|
+
readonly sessionOptions?: ConfigSessionOptions;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ApplyOptions {
|
|
18
|
+
readonly profileDir?: string;
|
|
19
|
+
readonly profileManager?: Pick<
|
|
20
|
+
ProfileManager,
|
|
21
|
+
'loadProfile' | 'listProfiles'
|
|
22
|
+
>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SYNTHETIC_PROFILE_DEFAULT = path.join(
|
|
26
|
+
os.homedir(),
|
|
27
|
+
'.llxprt/profiles/synthetic.json',
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export async function applyConfigCommand(
|
|
31
|
+
rawInput: string,
|
|
32
|
+
current: SessionConfig,
|
|
33
|
+
options?: ApplyOptions,
|
|
34
|
+
): Promise<ConfigCommandResult> {
|
|
35
|
+
const trimmed = rawInput.trim();
|
|
36
|
+
if (!trimmed.startsWith('/')) {
|
|
37
|
+
return Promise.resolve({
|
|
38
|
+
handled: false,
|
|
39
|
+
nextConfig: current,
|
|
40
|
+
messages: [],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const body = trimmed.slice(1).trim();
|
|
45
|
+
if (!body) {
|
|
46
|
+
return Promise.resolve({
|
|
47
|
+
handled: false,
|
|
48
|
+
nextConfig: current,
|
|
49
|
+
messages: [],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tokens = body.split(/\s+/).filter((token) => token.length > 0);
|
|
54
|
+
const [rawCommand, ...rest] = tokens;
|
|
55
|
+
const command = rawCommand.toLowerCase();
|
|
56
|
+
const argument = rest.join(' ').trim();
|
|
57
|
+
|
|
58
|
+
if (command === 'provider') {
|
|
59
|
+
if (!argument) {
|
|
60
|
+
return Promise.resolve({
|
|
61
|
+
handled: false,
|
|
62
|
+
nextConfig: current,
|
|
63
|
+
messages: [],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return Promise.resolve(applyProvider(argument, current));
|
|
67
|
+
}
|
|
68
|
+
if (command === 'baseurl' || command === 'base-url' || command === 'basurl') {
|
|
69
|
+
return Promise.resolve(applyBaseUrl(argument, current));
|
|
70
|
+
}
|
|
71
|
+
if (command === 'keyfile') {
|
|
72
|
+
return Promise.resolve(applyKeyFile(argument, current));
|
|
73
|
+
}
|
|
74
|
+
if (command === 'key') {
|
|
75
|
+
return Promise.resolve(applyKey(argument, current));
|
|
76
|
+
}
|
|
77
|
+
if (command === 'model') {
|
|
78
|
+
if (!argument) {
|
|
79
|
+
return Promise.resolve({
|
|
80
|
+
handled: false,
|
|
81
|
+
nextConfig: current,
|
|
82
|
+
messages: [],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return Promise.resolve(applyModel(argument, current));
|
|
86
|
+
}
|
|
87
|
+
if (command === 'profile') {
|
|
88
|
+
return applyProfile(rest, current, options);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Promise.resolve({ handled: false, nextConfig: current, messages: [] });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function applyProvider(
|
|
95
|
+
argument: string,
|
|
96
|
+
current: SessionConfig,
|
|
97
|
+
): ConfigCommandResult {
|
|
98
|
+
if (!argument) {
|
|
99
|
+
return {
|
|
100
|
+
handled: true,
|
|
101
|
+
nextConfig: current,
|
|
102
|
+
messages: [
|
|
103
|
+
'Provider is required. Usage: /provider <openai|gemini|anthropic>',
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const provider = normalizeProvider(argument);
|
|
108
|
+
if (!provider) {
|
|
109
|
+
return {
|
|
110
|
+
handled: true,
|
|
111
|
+
nextConfig: current,
|
|
112
|
+
messages: [`Unknown provider: ${argument}`],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
handled: true,
|
|
117
|
+
nextConfig: { ...current, provider },
|
|
118
|
+
messages: [`Provider set to ${provider}`],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function applyBaseUrl(
|
|
123
|
+
argument: string,
|
|
124
|
+
current: SessionConfig,
|
|
125
|
+
): ConfigCommandResult {
|
|
126
|
+
if (!argument) {
|
|
127
|
+
return {
|
|
128
|
+
handled: true,
|
|
129
|
+
nextConfig: current,
|
|
130
|
+
messages: ['Base URL is required. Usage: /baseurl <url>'],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
handled: true,
|
|
135
|
+
nextConfig: { ...current, baseUrl: argument },
|
|
136
|
+
messages: [`Base URL set to ${argument}`],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function applyKeyFile(
|
|
141
|
+
argument: string,
|
|
142
|
+
current: SessionConfig,
|
|
143
|
+
): ConfigCommandResult {
|
|
144
|
+
if (!argument) {
|
|
145
|
+
return {
|
|
146
|
+
handled: true,
|
|
147
|
+
nextConfig: current,
|
|
148
|
+
messages: ['Keyfile path is required. Usage: /keyfile <path>'],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
handled: true,
|
|
153
|
+
nextConfig: { ...current, keyFilePath: argument, apiKey: undefined },
|
|
154
|
+
messages: ['Keyfile configured'],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function applyKey(
|
|
159
|
+
argument: string,
|
|
160
|
+
current: SessionConfig,
|
|
161
|
+
): ConfigCommandResult {
|
|
162
|
+
if (!argument) {
|
|
163
|
+
return {
|
|
164
|
+
handled: true,
|
|
165
|
+
nextConfig: current,
|
|
166
|
+
messages: ['API key is required. Usage: /key <token>'],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
handled: true,
|
|
171
|
+
nextConfig: { ...current, apiKey: argument, keyFilePath: undefined },
|
|
172
|
+
messages: ['API key configured'],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function applyModel(
|
|
177
|
+
argument: string,
|
|
178
|
+
current: SessionConfig,
|
|
179
|
+
): ConfigCommandResult {
|
|
180
|
+
if (!argument) {
|
|
181
|
+
return {
|
|
182
|
+
handled: true,
|
|
183
|
+
nextConfig: current,
|
|
184
|
+
messages: ['Model is required. Usage: /model <id>'],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
handled: true,
|
|
189
|
+
nextConfig: { ...current, model: argument },
|
|
190
|
+
messages: [`Model set to ${argument}`],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface ParsedProfileArgs {
|
|
195
|
+
readonly action: string;
|
|
196
|
+
readonly name: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
interface ProfileValidationError {
|
|
200
|
+
readonly error: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type ProfileArgResult = ParsedProfileArgs | ProfileValidationError;
|
|
204
|
+
|
|
205
|
+
function parseProfileArgs(args: string[]): ProfileArgResult {
|
|
206
|
+
if (args.length === 0) {
|
|
207
|
+
return { error: 'Profile name is required. Usage: /profile load <name>' };
|
|
208
|
+
}
|
|
209
|
+
const [action, name] =
|
|
210
|
+
args.length === 1 ? ['load', args[0]] : [args[0], args[1]];
|
|
211
|
+
if (action.toLowerCase() !== 'load') {
|
|
212
|
+
return { error: 'Usage: /profile load <name>' };
|
|
213
|
+
}
|
|
214
|
+
if (!name) {
|
|
215
|
+
return { error: 'Profile name is required. Usage: /profile load <name>' };
|
|
216
|
+
}
|
|
217
|
+
return { action, name };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface ProfileData {
|
|
221
|
+
readonly provider?: string;
|
|
222
|
+
readonly baseUrl?: string;
|
|
223
|
+
readonly model?: string;
|
|
224
|
+
readonly authKeyfile?: string;
|
|
225
|
+
readonly ephemeralSettings?: Record<string, unknown>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function mapProfileToSessionConfig(
|
|
229
|
+
profile: ProfileData,
|
|
230
|
+
): Partial<SessionConfig> | null {
|
|
231
|
+
const ephemeral = profile.ephemeralSettings ?? {};
|
|
232
|
+
const provider = normalizeProvider(profile.provider);
|
|
233
|
+
const baseUrl = (ephemeral['base-url'] ??
|
|
234
|
+
ephemeral.baseUrl ??
|
|
235
|
+
profile.baseUrl) as string | undefined;
|
|
236
|
+
const keyFilePath = (ephemeral['auth-keyfile'] ??
|
|
237
|
+
ephemeral.authKeyfile ??
|
|
238
|
+
profile.authKeyfile) as string | undefined;
|
|
239
|
+
const model = (ephemeral.model ?? profile.model) as string | undefined;
|
|
240
|
+
|
|
241
|
+
if (!provider || !baseUrl || !keyFilePath || !model) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Pass through all ephemeral settings to the session config
|
|
246
|
+
return {
|
|
247
|
+
provider,
|
|
248
|
+
baseUrl,
|
|
249
|
+
keyFilePath,
|
|
250
|
+
model,
|
|
251
|
+
apiKey: undefined,
|
|
252
|
+
ephemeralSettings: ephemeral,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function validateProfileConfig(
|
|
257
|
+
config: Partial<SessionConfig> | null,
|
|
258
|
+
profileName: string,
|
|
259
|
+
): string | null {
|
|
260
|
+
if (config === null) {
|
|
261
|
+
return `Profile "${profileName}" is incomplete; need provider, base-url, auth-keyfile, and model.`;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function applyProfile(
|
|
267
|
+
args: string[],
|
|
268
|
+
current: SessionConfig,
|
|
269
|
+
options?: ApplyOptions,
|
|
270
|
+
): Promise<ConfigCommandResult> {
|
|
271
|
+
const parsed = parseProfileArgs(args);
|
|
272
|
+
if ('error' in parsed) {
|
|
273
|
+
return { handled: true, nextConfig: current, messages: [parsed.error] };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const profileDir =
|
|
277
|
+
options?.profileDir ?? path.dirname(SYNTHETIC_PROFILE_DEFAULT);
|
|
278
|
+
const manager = options?.profileManager ?? new ProfileManager(profileDir);
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const profile = await manager.loadProfile(parsed.name);
|
|
282
|
+
const config = mapProfileToSessionConfig(profile as unknown as ProfileData);
|
|
283
|
+
const error = validateProfileConfig(config, parsed.name);
|
|
284
|
+
|
|
285
|
+
if (error !== null) {
|
|
286
|
+
return { handled: true, nextConfig: current, messages: [error] };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
handled: true,
|
|
291
|
+
nextConfig: { ...current, ...config },
|
|
292
|
+
messages: [`Loaded profile: ${parsed.name}`],
|
|
293
|
+
};
|
|
294
|
+
} catch (error) {
|
|
295
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
296
|
+
return {
|
|
297
|
+
handled: true,
|
|
298
|
+
nextConfig: current,
|
|
299
|
+
messages: [`Failed to load profile "${parsed.name}": ${message}`],
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function normalizeProvider(input: string | undefined): ProviderKey | null {
|
|
305
|
+
if (!input) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
const lowered = input.trim().toLowerCase();
|
|
309
|
+
if (lowered === 'openai' || lowered === 'gemini' || lowered === 'anthropic') {
|
|
310
|
+
return lowered;
|
|
311
|
+
}
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function validateSessionConfig(
|
|
316
|
+
config: SessionConfig,
|
|
317
|
+
options?: { requireModel?: boolean },
|
|
318
|
+
): string[] {
|
|
319
|
+
const messages: string[] = [];
|
|
320
|
+
if (!config.baseUrl?.trim()) {
|
|
321
|
+
messages.push('Base URL not set. Use /baseurl <url>.');
|
|
322
|
+
}
|
|
323
|
+
if (options?.requireModel !== false) {
|
|
324
|
+
if (!config.model?.trim()) {
|
|
325
|
+
messages.push('Model not set. Use /model <id>.');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const hasKey = Boolean(config.apiKey?.trim() ?? config.keyFilePath?.trim());
|
|
329
|
+
if (!hasKey) {
|
|
330
|
+
messages.push(
|
|
331
|
+
'API key or keyfile not set. Use /key <token> or /keyfile <path>.',
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return messages;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function listAvailableProfiles(
|
|
338
|
+
options?: ApplyOptions,
|
|
339
|
+
): Promise<string[]> {
|
|
340
|
+
const profileDir =
|
|
341
|
+
options?.profileDir ?? path.dirname(SYNTHETIC_PROFILE_DEFAULT);
|
|
342
|
+
const manager = options?.profileManager ?? new ProfileManager(profileDir);
|
|
343
|
+
try {
|
|
344
|
+
return await manager.listProfiles();
|
|
345
|
+
} catch {
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function profileToConfigOptions(
|
|
351
|
+
profile: ProfileData,
|
|
352
|
+
workingDir: string,
|
|
353
|
+
): ConfigSessionOptions {
|
|
354
|
+
const ephemeral = profile.ephemeralSettings ?? {};
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
model: ((ephemeral.model ?? profile.model) as string) || 'gemini-2.5-flash',
|
|
358
|
+
provider: profile.provider,
|
|
359
|
+
workingDir,
|
|
360
|
+
baseUrl: (ephemeral['base-url'] ?? ephemeral.baseUrl ?? profile.baseUrl) as
|
|
361
|
+
| string
|
|
362
|
+
| undefined,
|
|
363
|
+
authKeyfile: (ephemeral['auth-keyfile'] ??
|
|
364
|
+
ephemeral.authKeyfile ??
|
|
365
|
+
profile.authKeyfile) as string | undefined,
|
|
366
|
+
apiKey: ephemeral['auth-key'] as string | undefined,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
interface ApplyWithSessionOptions extends ApplyOptions {
|
|
371
|
+
readonly workingDir: string;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function applyProfileWithSession(
|
|
375
|
+
rawInput: string,
|
|
376
|
+
current: SessionConfig,
|
|
377
|
+
options: ApplyWithSessionOptions,
|
|
378
|
+
): Promise<ConfigCommandResultWithSession> {
|
|
379
|
+
const result = await applyConfigCommand(rawInput, current, options);
|
|
380
|
+
|
|
381
|
+
// Check if this was a profile load command
|
|
382
|
+
const trimmed = rawInput.trim();
|
|
383
|
+
if (!trimmed.startsWith('/')) {
|
|
384
|
+
return { ...result, sessionOptions: undefined };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const body = trimmed.slice(1).trim();
|
|
388
|
+
const tokens = body.split(/\s+/).filter((token) => token.length > 0);
|
|
389
|
+
const command = tokens.at(0)?.toLowerCase() ?? '';
|
|
390
|
+
|
|
391
|
+
// Only generate session options for profile commands
|
|
392
|
+
if (command !== 'profile') {
|
|
393
|
+
return { ...result, sessionOptions: undefined };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// If profile load failed or config is incomplete, don't return options
|
|
397
|
+
if (
|
|
398
|
+
!result.handled ||
|
|
399
|
+
!result.nextConfig.model ||
|
|
400
|
+
!result.nextConfig.baseUrl
|
|
401
|
+
) {
|
|
402
|
+
return { ...result, sessionOptions: undefined };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Convert the new config to session options
|
|
406
|
+
const sessionOptions = profileToConfigOptions(
|
|
407
|
+
{
|
|
408
|
+
provider: result.nextConfig.provider,
|
|
409
|
+
model: result.nextConfig.model,
|
|
410
|
+
baseUrl: result.nextConfig.baseUrl,
|
|
411
|
+
authKeyfile: result.nextConfig.keyFilePath,
|
|
412
|
+
ephemeralSettings: result.nextConfig.ephemeralSettings,
|
|
413
|
+
},
|
|
414
|
+
options.workingDir,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
return { ...result, sessionOptions };
|
|
418
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createMockTheme } from '../../test/mockTheme';
|
|
3
|
+
import { loadThemes } from './theme';
|
|
4
|
+
import type { ThemeColors } from './theme';
|
|
5
|
+
|
|
6
|
+
describe('ThemeColors message properties', () => {
|
|
7
|
+
it('should require message.userBorder property', () => {
|
|
8
|
+
const mockTheme = createMockTheme();
|
|
9
|
+
expect(mockTheme.colors.message.userBorder).toBeDefined();
|
|
10
|
+
expect(typeof mockTheme.colors.message.userBorder).toBe('string');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should require message.systemBorder property', () => {
|
|
14
|
+
const mockTheme = createMockTheme();
|
|
15
|
+
expect(mockTheme.colors.message.systemBorder).toBeDefined();
|
|
16
|
+
expect(typeof mockTheme.colors.message.systemBorder).toBe('string');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should require message.systemText property', () => {
|
|
20
|
+
const mockTheme = createMockTheme();
|
|
21
|
+
expect(mockTheme.colors.message.systemText).toBeDefined();
|
|
22
|
+
expect(typeof mockTheme.colors.message.systemText).toBe('string');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should accept optional message.groupSpacing property', () => {
|
|
26
|
+
const mockTheme = createMockTheme();
|
|
27
|
+
// groupSpacing is optional, so undefined is valid
|
|
28
|
+
const groupSpacing = (
|
|
29
|
+
mockTheme.colors.message as ThemeColors['message'] & {
|
|
30
|
+
groupSpacing?: number;
|
|
31
|
+
}
|
|
32
|
+
).groupSpacing;
|
|
33
|
+
// Either undefined or a number is valid
|
|
34
|
+
expect(groupSpacing === undefined || typeof groupSpacing === 'number').toBe(
|
|
35
|
+
true,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('loadThemes', () => {
|
|
41
|
+
it('should load themes with message color properties', () => {
|
|
42
|
+
const themes = loadThemes();
|
|
43
|
+
// Verify themes can be loaded
|
|
44
|
+
expect(themes.length).toBeGreaterThan(0);
|
|
45
|
+
// Verify first theme has message properties (all themes should have them)
|
|
46
|
+
expect(themes[0].colors.message).toBeDefined();
|
|
47
|
+
expect(themes[0].colors.message.userBorder).toBeDefined();
|
|
48
|
+
expect(themes[0].colors.message.systemBorder).toBeDefined();
|
|
49
|
+
expect(themes[0].colors.message.systemText).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { getLogger } from '../../lib/logger';
|
|
5
|
+
|
|
6
|
+
export type ThemeKind = 'light' | 'dark' | 'ansi' | 'custom';
|
|
7
|
+
|
|
8
|
+
export interface ThemeColors {
|
|
9
|
+
readonly background: string;
|
|
10
|
+
readonly panel: {
|
|
11
|
+
readonly bg: string;
|
|
12
|
+
readonly border: string;
|
|
13
|
+
readonly headerBg?: string;
|
|
14
|
+
readonly headerFg?: string;
|
|
15
|
+
};
|
|
16
|
+
readonly text: {
|
|
17
|
+
readonly primary: string;
|
|
18
|
+
readonly muted: string;
|
|
19
|
+
readonly user: string;
|
|
20
|
+
readonly responder: string;
|
|
21
|
+
readonly thinking: string;
|
|
22
|
+
readonly tool: string;
|
|
23
|
+
};
|
|
24
|
+
readonly input: {
|
|
25
|
+
readonly bg: string;
|
|
26
|
+
readonly fg: string;
|
|
27
|
+
readonly placeholder: string;
|
|
28
|
+
readonly border: string;
|
|
29
|
+
};
|
|
30
|
+
readonly status: {
|
|
31
|
+
readonly fg: string;
|
|
32
|
+
readonly muted?: string;
|
|
33
|
+
};
|
|
34
|
+
readonly accent: {
|
|
35
|
+
readonly primary: string;
|
|
36
|
+
readonly secondary?: string;
|
|
37
|
+
readonly warning?: string;
|
|
38
|
+
readonly error?: string;
|
|
39
|
+
readonly success?: string;
|
|
40
|
+
};
|
|
41
|
+
readonly selection: {
|
|
42
|
+
readonly fg: string;
|
|
43
|
+
readonly bg: string;
|
|
44
|
+
};
|
|
45
|
+
readonly diff: {
|
|
46
|
+
readonly addedBg: string;
|
|
47
|
+
readonly addedFg: string;
|
|
48
|
+
readonly removedBg: string;
|
|
49
|
+
readonly removedFg: string;
|
|
50
|
+
};
|
|
51
|
+
readonly scrollbar?: {
|
|
52
|
+
readonly thumb: string;
|
|
53
|
+
readonly track: string;
|
|
54
|
+
};
|
|
55
|
+
readonly message: {
|
|
56
|
+
readonly userBorder: string;
|
|
57
|
+
readonly systemBorder: string;
|
|
58
|
+
readonly systemText: string;
|
|
59
|
+
readonly systemBg?: string;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ThemeDefinition {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly slug: string;
|
|
66
|
+
readonly kind: ThemeKind;
|
|
67
|
+
readonly colors: ThemeColors;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Get the directory of this source file, then navigate to themes directory
|
|
71
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
72
|
+
const __dirname = path.dirname(__filename);
|
|
73
|
+
const THEMES_DIR = path.resolve(__dirname, '../../../themes');
|
|
74
|
+
export const DEFAULT_THEME_SLUG = 'green-screen';
|
|
75
|
+
|
|
76
|
+
const logger = getLogger('nui:theme');
|
|
77
|
+
|
|
78
|
+
export function loadThemes(): ThemeDefinition[] {
|
|
79
|
+
try {
|
|
80
|
+
const files = readdirSync(THEMES_DIR).filter((file) =>
|
|
81
|
+
file.endsWith('.json'),
|
|
82
|
+
);
|
|
83
|
+
const themes: ThemeDefinition[] = files.map((file) => {
|
|
84
|
+
const fullPath = path.join(THEMES_DIR, file);
|
|
85
|
+
const raw = JSON.parse(readFileSync(fullPath, 'utf8')) as ThemeDefinition;
|
|
86
|
+
return { ...raw, slug: raw.slug || path.basename(file, '.json') };
|
|
87
|
+
});
|
|
88
|
+
return themes;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
logger.error('Failed to load themes:', error);
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function findTheme(
|
|
96
|
+
themes: ThemeDefinition[],
|
|
97
|
+
key: string,
|
|
98
|
+
): ThemeDefinition | undefined {
|
|
99
|
+
const normalized = key.trim().toLowerCase();
|
|
100
|
+
return themes.find(
|
|
101
|
+
(theme) =>
|
|
102
|
+
theme.slug.toLowerCase() === normalized ||
|
|
103
|
+
theme.name.toLowerCase() === normalized,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useMemo, useState, useCallback } from 'react';
|
|
2
|
+
import type { ThemeDefinition } from './theme';
|
|
3
|
+
import { DEFAULT_THEME_SLUG, findTheme, loadThemes } from './theme';
|
|
4
|
+
|
|
5
|
+
export interface ThemeState {
|
|
6
|
+
readonly themes: ThemeDefinition[];
|
|
7
|
+
readonly theme: ThemeDefinition;
|
|
8
|
+
readonly setThemeBySlug: (slug: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useThemeManager(): ThemeState {
|
|
12
|
+
const themes = useMemo(() => loadThemes(), []);
|
|
13
|
+
const fallback = useMemo(() => selectInitialTheme(themes), [themes]);
|
|
14
|
+
const [theme, setTheme] = useState<ThemeDefinition>(fallback);
|
|
15
|
+
|
|
16
|
+
const setThemeBySlug = useCallback(
|
|
17
|
+
(slug: string) => {
|
|
18
|
+
const next = findTheme(themes, slug);
|
|
19
|
+
if (next) {
|
|
20
|
+
setTheme(next);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
[themes],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return { themes, theme, setThemeBySlug };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function selectInitialTheme(themes: ThemeDefinition[]): ThemeDefinition {
|
|
30
|
+
if (themes.length === 0) {
|
|
31
|
+
return {
|
|
32
|
+
name: 'Fallback',
|
|
33
|
+
slug: 'fallback',
|
|
34
|
+
kind: 'dark',
|
|
35
|
+
colors: {
|
|
36
|
+
background: '#0f172a',
|
|
37
|
+
panel: {
|
|
38
|
+
bg: '#0f172a',
|
|
39
|
+
border: '#475569',
|
|
40
|
+
headerBg: '#0f172a',
|
|
41
|
+
headerFg: '#e5e7eb',
|
|
42
|
+
},
|
|
43
|
+
text: {
|
|
44
|
+
primary: '#e5e7eb',
|
|
45
|
+
muted: '#94a3b8',
|
|
46
|
+
user: '#7dd3fc',
|
|
47
|
+
responder: '#facc15',
|
|
48
|
+
thinking: '#94a3b8',
|
|
49
|
+
tool: '#c084fc',
|
|
50
|
+
},
|
|
51
|
+
input: {
|
|
52
|
+
bg: '#0f172a',
|
|
53
|
+
fg: '#e5e7eb',
|
|
54
|
+
placeholder: '#94a3b8',
|
|
55
|
+
border: '#475569',
|
|
56
|
+
},
|
|
57
|
+
status: { fg: '#a3e635', muted: '#94a3b8' },
|
|
58
|
+
accent: {
|
|
59
|
+
primary: '#38bdf8',
|
|
60
|
+
secondary: '#a78bfa',
|
|
61
|
+
warning: '#facc15',
|
|
62
|
+
error: '#ef4444',
|
|
63
|
+
success: '#22c55e',
|
|
64
|
+
},
|
|
65
|
+
selection: { fg: '#0f172a', bg: '#38bdf8' },
|
|
66
|
+
diff: {
|
|
67
|
+
addedBg: '#166534',
|
|
68
|
+
addedFg: '#e5e7eb',
|
|
69
|
+
removedBg: '#7f1d1d',
|
|
70
|
+
removedFg: '#e5e7eb',
|
|
71
|
+
},
|
|
72
|
+
scrollbar: { thumb: '#38bdf8', track: '#475569' },
|
|
73
|
+
message: {
|
|
74
|
+
userBorder: '#7dd3fc',
|
|
75
|
+
systemBorder: '#facc15',
|
|
76
|
+
systemText: '#facc15',
|
|
77
|
+
systemBg: '#0f172a',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const preferred = findTheme(themes, DEFAULT_THEME_SLUG);
|
|
83
|
+
return preferred ?? themes[0];
|
|
84
|
+
}
|