@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,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { MessageRole } from './types';
|
|
3
|
+
|
|
4
|
+
describe('MessageRole type', () => {
|
|
5
|
+
it('should include user role', () => {
|
|
6
|
+
const role: MessageRole = 'user';
|
|
7
|
+
expect(role).toBe('user');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('should include model role', () => {
|
|
11
|
+
const role: MessageRole = 'model';
|
|
12
|
+
expect(role).toBe('model');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should include system role', () => {
|
|
16
|
+
const role: MessageRole = 'system';
|
|
17
|
+
expect(role).toBe('system');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should include thinking role', () => {
|
|
21
|
+
const role: MessageRole = 'thinking';
|
|
22
|
+
expect(role).toBe('thinking');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { ThemeDefinition } from '../../../features/theme';
|
|
3
|
+
|
|
4
|
+
export type MessageRole = 'user' | 'model' | 'system' | 'thinking';
|
|
5
|
+
|
|
6
|
+
export interface MessageProps {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly text: string;
|
|
9
|
+
readonly theme: ThemeDefinition;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type UserMessageProps = MessageProps;
|
|
13
|
+
|
|
14
|
+
export type SystemMessageProps = MessageProps;
|
|
15
|
+
|
|
16
|
+
export type ModelMessageProps = MessageProps;
|
|
17
|
+
|
|
18
|
+
export type ThinkingMessageProps = MessageProps;
|
|
19
|
+
|
|
20
|
+
export type MessageComponent = ((props: MessageProps) => React.ReactNode) & {
|
|
21
|
+
displayName?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const EmptyBorder = {
|
|
25
|
+
topLeft: ' ',
|
|
26
|
+
topRight: ' ',
|
|
27
|
+
bottomLeft: ' ',
|
|
28
|
+
bottomRight: ' ',
|
|
29
|
+
horizontal: ' ',
|
|
30
|
+
vertical: ' ',
|
|
31
|
+
topT: ' ',
|
|
32
|
+
bottomT: ' ',
|
|
33
|
+
leftT: ' ',
|
|
34
|
+
rightT: ' ',
|
|
35
|
+
cross: ' ',
|
|
36
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useKeyboard } from '@vybestack/opentui-react';
|
|
2
|
+
import React, { useCallback, useState } from 'react';
|
|
3
|
+
import { useListNavigation } from '../../hooks/useListNavigation';
|
|
4
|
+
import { ModalShell } from './ModalShell';
|
|
5
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
6
|
+
import { SelectableListItem } from '../components/SelectableList';
|
|
7
|
+
|
|
8
|
+
export interface AuthOption {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly enabled: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function AuthModal(props: {
|
|
15
|
+
readonly options: AuthOption[];
|
|
16
|
+
readonly onClose: () => void;
|
|
17
|
+
readonly onSave: (next: AuthOption[]) => void;
|
|
18
|
+
readonly theme?: ThemeDefinition;
|
|
19
|
+
}): React.ReactNode {
|
|
20
|
+
const [options, setOptions] = useState<AuthOption[]>(props.options);
|
|
21
|
+
const { selectedIndex, moveSelection } = useListNavigation(options.length);
|
|
22
|
+
|
|
23
|
+
const closeWithSave = useCallback((): void => {
|
|
24
|
+
props.onSave(options);
|
|
25
|
+
props.onClose();
|
|
26
|
+
}, [options, props]);
|
|
27
|
+
|
|
28
|
+
useKeyboard((key) => {
|
|
29
|
+
if (key.eventType !== 'press') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (key.name === 'escape') {
|
|
33
|
+
closeWithSave();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (key.name === 'up') {
|
|
37
|
+
key.preventDefault();
|
|
38
|
+
moveSelection(-1);
|
|
39
|
+
} else if (key.name === 'down') {
|
|
40
|
+
key.preventDefault();
|
|
41
|
+
moveSelection(1);
|
|
42
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
43
|
+
key.preventDefault();
|
|
44
|
+
const current = options.at(selectedIndex);
|
|
45
|
+
if (current == null) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (current.id === 'close') {
|
|
49
|
+
closeWithSave();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setOptions((prev) =>
|
|
53
|
+
prev.map((opt, optIndex) =>
|
|
54
|
+
optIndex === selectedIndex ? { ...opt, enabled: !opt.enabled } : opt,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<ModalShell
|
|
62
|
+
title="OAuth Authentication"
|
|
63
|
+
onClose={closeWithSave}
|
|
64
|
+
theme={props.theme}
|
|
65
|
+
>
|
|
66
|
+
<text fg={props.theme?.colors.text.primary}>
|
|
67
|
+
Select an OAuth provider to authenticate:
|
|
68
|
+
</text>
|
|
69
|
+
<text fg={props.theme?.colors.text.muted}>
|
|
70
|
+
Note: You can also use API keys via /key, /keyfile, --key, --keyfile, or
|
|
71
|
+
environment variables
|
|
72
|
+
</text>
|
|
73
|
+
<box flexDirection="column" style={{ gap: 0 }}>
|
|
74
|
+
{renderAuthOptions(options, selectedIndex, props.theme)}
|
|
75
|
+
</box>
|
|
76
|
+
<text fg={props.theme?.colors.text.muted}>
|
|
77
|
+
(Use Enter to select, ESC to close)
|
|
78
|
+
</text>
|
|
79
|
+
<text fg={props.theme?.colors.text.primary}>
|
|
80
|
+
Terms of Services and Privacy Notice for Gemini CLI
|
|
81
|
+
</text>
|
|
82
|
+
<text fg={props.theme?.colors.text.muted}>
|
|
83
|
+
https://github.com/acoliver/llxprt-code/blob/main/docs/tos-privacy.md
|
|
84
|
+
</text>
|
|
85
|
+
</ModalShell>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderAuthOptions(
|
|
90
|
+
options: AuthOption[],
|
|
91
|
+
selectedIndex: number,
|
|
92
|
+
theme?: ThemeDefinition,
|
|
93
|
+
): React.ReactNode[] {
|
|
94
|
+
return options.map((opt, optIndex): React.ReactNode => {
|
|
95
|
+
const isSelected = optIndex === selectedIndex;
|
|
96
|
+
const label = `${optIndex + 1}. ${opt.label} [${opt.enabled ? 'ON' : 'OFF'}]`;
|
|
97
|
+
return (
|
|
98
|
+
<SelectableListItem
|
|
99
|
+
key={opt.id}
|
|
100
|
+
label={label}
|
|
101
|
+
isSelected={isSelected}
|
|
102
|
+
theme={theme}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useKeyboard } from '@vybestack/opentui-react';
|
|
3
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
4
|
+
|
|
5
|
+
export interface ModalShellProps {
|
|
6
|
+
readonly title: string;
|
|
7
|
+
readonly subtitle?: string;
|
|
8
|
+
readonly width?: number | `${number}%`;
|
|
9
|
+
readonly onClose: () => void;
|
|
10
|
+
readonly children: React.ReactNode;
|
|
11
|
+
readonly footer?: React.ReactNode;
|
|
12
|
+
readonly theme?: ThemeDefinition;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ModalShell(props: ModalShellProps): React.ReactNode {
|
|
16
|
+
useKeyboard((key) => {
|
|
17
|
+
if (key.name === 'escape') {
|
|
18
|
+
key.preventDefault();
|
|
19
|
+
props.onClose();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<box
|
|
25
|
+
style={{
|
|
26
|
+
position: 'absolute',
|
|
27
|
+
top: 0,
|
|
28
|
+
left: 0,
|
|
29
|
+
width: '100%',
|
|
30
|
+
height: '100%',
|
|
31
|
+
padding: 1,
|
|
32
|
+
alignItems: 'center',
|
|
33
|
+
justifyContent: 'center',
|
|
34
|
+
backgroundColor: undefined,
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<box
|
|
38
|
+
border
|
|
39
|
+
style={{
|
|
40
|
+
width: props.width ?? '95%',
|
|
41
|
+
maxWidth: props.width ?? '95%',
|
|
42
|
+
padding: 1,
|
|
43
|
+
borderColor: props.theme?.colors.panel.border,
|
|
44
|
+
backgroundColor: props.theme?.colors.panel.bg,
|
|
45
|
+
flexDirection: 'column',
|
|
46
|
+
gap: 1,
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<text fg={props.theme?.colors.text.primary}>{props.title}</text>
|
|
50
|
+
{props.subtitle ? (
|
|
51
|
+
<text fg={props.theme?.colors.text.muted}>{props.subtitle}</text>
|
|
52
|
+
) : null}
|
|
53
|
+
<box flexDirection="column" style={{ gap: 1, flexGrow: 1 }}>
|
|
54
|
+
{props.children}
|
|
55
|
+
</box>
|
|
56
|
+
{props.footer ?? null}
|
|
57
|
+
</box>
|
|
58
|
+
</box>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { TextareaRenderable } from '@vybestack/opentui-core';
|
|
2
|
+
import { useKeyboard } from '@vybestack/opentui-react';
|
|
3
|
+
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { useFilteredList } from '../../hooks/useListNavigation';
|
|
5
|
+
import { type SearchItem } from './types';
|
|
6
|
+
import { ModalShell } from './ModalShell';
|
|
7
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
8
|
+
import { FilterInput } from '../components/FilterInput';
|
|
9
|
+
import { SelectableListItem } from '../components/SelectableList';
|
|
10
|
+
|
|
11
|
+
const GRID_COLUMNS = 3;
|
|
12
|
+
const SEARCH_PAGE_SIZE = GRID_COLUMNS * 6;
|
|
13
|
+
|
|
14
|
+
export interface SearchSelectProps {
|
|
15
|
+
readonly title: string;
|
|
16
|
+
readonly noun: string;
|
|
17
|
+
readonly items: SearchItem[];
|
|
18
|
+
readonly alphabetical?: boolean;
|
|
19
|
+
readonly footerHint?: string;
|
|
20
|
+
readonly onClose: () => void;
|
|
21
|
+
readonly onSelect: (item: SearchItem) => void;
|
|
22
|
+
readonly theme?: ThemeDefinition;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function SearchSelectModal(props: SearchSelectProps): React.ReactNode {
|
|
26
|
+
const searchRef = useRef<TextareaRenderable | null>(null);
|
|
27
|
+
const [query, setQuery] = useState('');
|
|
28
|
+
|
|
29
|
+
const filterFn = useCallback(
|
|
30
|
+
(item: SearchItem, searchQuery: string): boolean => {
|
|
31
|
+
const normalized = searchQuery.trim().toLowerCase();
|
|
32
|
+
return item.label.toLowerCase().includes(normalized);
|
|
33
|
+
},
|
|
34
|
+
[],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const { filteredItems, selectedIndex, moveSelection } = useFilteredList(
|
|
38
|
+
props.items,
|
|
39
|
+
query,
|
|
40
|
+
filterFn,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const sortedFiltered = useMemo(() => {
|
|
44
|
+
if (props.alphabetical === true) {
|
|
45
|
+
return [...filteredItems].sort((a, b) => a.label.localeCompare(b.label));
|
|
46
|
+
}
|
|
47
|
+
return filteredItems;
|
|
48
|
+
}, [filteredItems, props.alphabetical]);
|
|
49
|
+
|
|
50
|
+
const { pageStart, visible, startDisplay, endDisplay } = getPagination(
|
|
51
|
+
sortedFiltered,
|
|
52
|
+
selectedIndex,
|
|
53
|
+
);
|
|
54
|
+
const current = sortedFiltered[selectedIndex];
|
|
55
|
+
|
|
56
|
+
const handleQueryChange = useCallback((newQuery: string) => {
|
|
57
|
+
setQuery(newQuery);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
useSearchSelectKeys(
|
|
61
|
+
sortedFiltered,
|
|
62
|
+
selectedIndex,
|
|
63
|
+
moveSelection,
|
|
64
|
+
current,
|
|
65
|
+
props.onSelect,
|
|
66
|
+
props.onClose,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<ModalShell
|
|
71
|
+
title={props.title}
|
|
72
|
+
onClose={props.onClose}
|
|
73
|
+
theme={props.theme}
|
|
74
|
+
footer={
|
|
75
|
+
props.footerHint ? (
|
|
76
|
+
<text fg={props.theme?.colors.text.muted}>{props.footerHint}</text>
|
|
77
|
+
) : undefined
|
|
78
|
+
}
|
|
79
|
+
>
|
|
80
|
+
<text
|
|
81
|
+
fg={props.theme?.colors.text.primary}
|
|
82
|
+
>{`Found ${sortedFiltered.length} of ${props.items.length} ${props.noun}`}</text>
|
|
83
|
+
<box flexDirection="row" style={{ gap: 1, alignItems: 'center' }}>
|
|
84
|
+
<text
|
|
85
|
+
fg={props.theme?.colors.text.primary}
|
|
86
|
+
>{`${props.alphabetical === true ? 'Search' : 'Filter'}:`}</text>
|
|
87
|
+
<FilterInput
|
|
88
|
+
textareaRef={searchRef}
|
|
89
|
+
placeholder="type to filter"
|
|
90
|
+
theme={props.theme}
|
|
91
|
+
onQueryChange={handleQueryChange}
|
|
92
|
+
/>
|
|
93
|
+
</box>
|
|
94
|
+
<text
|
|
95
|
+
fg={props.theme?.colors.text.primary}
|
|
96
|
+
>{`Showing ${startDisplay}-${endDisplay} of ${sortedFiltered.length} rows`}</text>
|
|
97
|
+
<SearchGrid
|
|
98
|
+
items={visible}
|
|
99
|
+
pageStart={pageStart}
|
|
100
|
+
selectedIndex={selectedIndex}
|
|
101
|
+
theme={props.theme}
|
|
102
|
+
/>
|
|
103
|
+
</ModalShell>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function useSearchSelectKeys(
|
|
108
|
+
filtered: SearchItem[],
|
|
109
|
+
selectedIndex: number,
|
|
110
|
+
moveSelection: (delta: number) => void,
|
|
111
|
+
current: SearchItem | undefined,
|
|
112
|
+
onSelect: (item: SearchItem) => void,
|
|
113
|
+
onClose: () => void,
|
|
114
|
+
): void {
|
|
115
|
+
useKeyboard((key) => {
|
|
116
|
+
if (key.eventType !== 'press') {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (key.name === 'escape') {
|
|
120
|
+
onClose();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (filtered.length === 0) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const handlers: Record<string, () => void> = {
|
|
127
|
+
tab: () => moveSelection(key.shift ? -1 : 1),
|
|
128
|
+
return: () => {
|
|
129
|
+
if (current != null) {
|
|
130
|
+
onSelect(current);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
enter: () => {
|
|
134
|
+
if (current != null) {
|
|
135
|
+
onSelect(current);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
up: () => moveSelection(-GRID_COLUMNS),
|
|
139
|
+
down: () => moveSelection(GRID_COLUMNS),
|
|
140
|
+
left: () => moveSelection(-1),
|
|
141
|
+
right: () => moveSelection(1),
|
|
142
|
+
};
|
|
143
|
+
if (key.name in handlers) {
|
|
144
|
+
key.preventDefault();
|
|
145
|
+
handlers[key.name]();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function SearchGrid(props: {
|
|
151
|
+
readonly items: SearchItem[];
|
|
152
|
+
readonly pageStart: number;
|
|
153
|
+
readonly selectedIndex: number;
|
|
154
|
+
readonly theme?: ThemeDefinition;
|
|
155
|
+
}): React.ReactNode {
|
|
156
|
+
return (
|
|
157
|
+
<box flexDirection="column" style={{ gap: 0 }}>
|
|
158
|
+
{renderSearchGrid(
|
|
159
|
+
props.items,
|
|
160
|
+
props.pageStart,
|
|
161
|
+
props.selectedIndex,
|
|
162
|
+
props.theme,
|
|
163
|
+
)}
|
|
164
|
+
</box>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderSearchGrid(
|
|
169
|
+
items: SearchItem[],
|
|
170
|
+
pageStart: number,
|
|
171
|
+
selectedIndex: number,
|
|
172
|
+
theme?: ThemeDefinition,
|
|
173
|
+
): React.ReactNode[] {
|
|
174
|
+
const rows = chunkItems(items, GRID_COLUMNS);
|
|
175
|
+
const columnWidths = Array.from({ length: GRID_COLUMNS }, (_, col) =>
|
|
176
|
+
Math.max(0, ...rows.map((row) => (row.at(col)?.label.length ?? 0) + 2)),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return rows.map((row, rowIndex) => (
|
|
180
|
+
<box key={`row-${rowIndex}`} flexDirection="row" style={{ gap: 2 }}>
|
|
181
|
+
{row.map((item, index) =>
|
|
182
|
+
renderSearchItem(
|
|
183
|
+
item,
|
|
184
|
+
pageStart + rowIndex * GRID_COLUMNS + index,
|
|
185
|
+
selectedIndex,
|
|
186
|
+
columnWidths[index] ?? item.label.length + 2,
|
|
187
|
+
theme,
|
|
188
|
+
),
|
|
189
|
+
)}
|
|
190
|
+
</box>
|
|
191
|
+
));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderSearchItem(
|
|
195
|
+
item: SearchItem,
|
|
196
|
+
absoluteIndex: number,
|
|
197
|
+
selectedIndex: number,
|
|
198
|
+
width: number,
|
|
199
|
+
theme?: ThemeDefinition,
|
|
200
|
+
): React.ReactNode {
|
|
201
|
+
const isSelected = absoluteIndex === selectedIndex;
|
|
202
|
+
return (
|
|
203
|
+
<SelectableListItem
|
|
204
|
+
key={item.id}
|
|
205
|
+
label={item.label}
|
|
206
|
+
isSelected={isSelected}
|
|
207
|
+
width={width + 2}
|
|
208
|
+
theme={theme}
|
|
209
|
+
/>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getPagination(
|
|
214
|
+
filtered: SearchItem[],
|
|
215
|
+
selectedIndex: number,
|
|
216
|
+
): {
|
|
217
|
+
pageStart: number;
|
|
218
|
+
visible: SearchItem[];
|
|
219
|
+
startDisplay: number;
|
|
220
|
+
endDisplay: number;
|
|
221
|
+
} {
|
|
222
|
+
const pageStart =
|
|
223
|
+
Math.floor(selectedIndex / SEARCH_PAGE_SIZE) * SEARCH_PAGE_SIZE;
|
|
224
|
+
const visible = filtered.slice(pageStart, pageStart + SEARCH_PAGE_SIZE);
|
|
225
|
+
const startDisplay = filtered.length === 0 ? 0 : pageStart + 1;
|
|
226
|
+
const endDisplay = Math.min(pageStart + visible.length, filtered.length);
|
|
227
|
+
return { pageStart, visible, startDisplay, endDisplay };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function chunkItems(list: SearchItem[], columns: number): SearchItem[][] {
|
|
231
|
+
const rows: SearchItem[][] = [];
|
|
232
|
+
for (let index = 0; index < list.length; index += columns) {
|
|
233
|
+
rows.push(list.slice(index, index + columns));
|
|
234
|
+
}
|
|
235
|
+
return rows;
|
|
236
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useKeyboard } from '@vybestack/opentui-react';
|
|
2
|
+
import React, {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import type { TextareaRenderable } from '@vybestack/opentui-core';
|
|
10
|
+
import { useListNavigation } from '../../hooks/useListNavigation';
|
|
11
|
+
import { ModalShell } from './ModalShell';
|
|
12
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
13
|
+
import { FilterInput } from '../components/FilterInput';
|
|
14
|
+
import { SelectableListItem } from '../components/SelectableList';
|
|
15
|
+
|
|
16
|
+
export interface ThemeModalProps {
|
|
17
|
+
readonly themes: ThemeDefinition[];
|
|
18
|
+
readonly current: ThemeDefinition;
|
|
19
|
+
readonly onClose: () => void;
|
|
20
|
+
readonly onSelect: (theme: ThemeDefinition) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ThemeModal(props: ThemeModalProps): React.ReactNode {
|
|
24
|
+
const searchRef = useRef<TextareaRenderable | null>(null);
|
|
25
|
+
const [query, setQuery] = useState('');
|
|
26
|
+
|
|
27
|
+
const filtered = useMemo(() => {
|
|
28
|
+
if (!query.trim()) {
|
|
29
|
+
return props.themes;
|
|
30
|
+
}
|
|
31
|
+
const normalized = query.trim().toLowerCase();
|
|
32
|
+
return props.themes.filter(
|
|
33
|
+
(theme) =>
|
|
34
|
+
theme.name.toLowerCase().includes(normalized) ||
|
|
35
|
+
theme.slug.toLowerCase().includes(normalized) ||
|
|
36
|
+
theme.kind.toLowerCase().includes(normalized),
|
|
37
|
+
);
|
|
38
|
+
}, [props.themes, query]);
|
|
39
|
+
|
|
40
|
+
const { selectedIndex, setSelectedIndex, moveSelection } = useListNavigation(
|
|
41
|
+
filtered.length,
|
|
42
|
+
);
|
|
43
|
+
const selected =
|
|
44
|
+
filtered.at(selectedIndex) ?? filtered.at(0) ?? props.current;
|
|
45
|
+
|
|
46
|
+
const handleQueryChange = useCallback((newQuery: string) => {
|
|
47
|
+
setQuery(newQuery);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
setSelectedIndex(0);
|
|
52
|
+
}, [query, setSelectedIndex]);
|
|
53
|
+
|
|
54
|
+
useKeyboard((key) => {
|
|
55
|
+
if (key.eventType !== 'press') {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (key.name === 'escape') {
|
|
59
|
+
props.onClose();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (filtered.length === 0) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (key.name === 'down') {
|
|
66
|
+
key.preventDefault();
|
|
67
|
+
moveSelection(1);
|
|
68
|
+
} else if (key.name === 'up') {
|
|
69
|
+
key.preventDefault();
|
|
70
|
+
moveSelection(-1);
|
|
71
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
72
|
+
key.preventDefault();
|
|
73
|
+
const currentSelection = filtered.at(selectedIndex) ?? filtered.at(0);
|
|
74
|
+
if (currentSelection != null) {
|
|
75
|
+
props.onSelect(currentSelection);
|
|
76
|
+
}
|
|
77
|
+
props.onClose();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const countLabel = `Found ${filtered.length} of ${props.themes.length} themes`;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<ModalShell
|
|
85
|
+
title="Select Theme"
|
|
86
|
+
onClose={props.onClose}
|
|
87
|
+
theme={props.current}
|
|
88
|
+
>
|
|
89
|
+
<text fg={props.current.colors.text.primary}>{countLabel}</text>
|
|
90
|
+
<box flexDirection="row" style={{ gap: 1, alignItems: 'center' }}>
|
|
91
|
+
<text fg={props.current.colors.text.primary}>Filter:</text>
|
|
92
|
+
<FilterInput
|
|
93
|
+
textareaRef={searchRef}
|
|
94
|
+
placeholder="type to filter"
|
|
95
|
+
theme={props.current}
|
|
96
|
+
onQueryChange={handleQueryChange}
|
|
97
|
+
/>
|
|
98
|
+
</box>
|
|
99
|
+
<box flexDirection="row" style={{ gap: 1, height: 14 }}>
|
|
100
|
+
<ThemeList
|
|
101
|
+
themes={filtered}
|
|
102
|
+
selectedIndex={selectedIndex}
|
|
103
|
+
activeSlug={props.current.slug}
|
|
104
|
+
displayTheme={props.current}
|
|
105
|
+
/>
|
|
106
|
+
<ThemePreview theme={selected} />
|
|
107
|
+
</box>
|
|
108
|
+
</ModalShell>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ThemeList(props: {
|
|
113
|
+
readonly themes: ThemeDefinition[];
|
|
114
|
+
readonly selectedIndex: number;
|
|
115
|
+
readonly activeSlug: string;
|
|
116
|
+
readonly displayTheme: ThemeDefinition;
|
|
117
|
+
}): React.ReactNode {
|
|
118
|
+
return (
|
|
119
|
+
<scrollbox
|
|
120
|
+
style={{
|
|
121
|
+
width: '45%',
|
|
122
|
+
border: true,
|
|
123
|
+
borderColor: props.displayTheme.colors.panel.border,
|
|
124
|
+
paddingLeft: 1,
|
|
125
|
+
paddingRight: 1,
|
|
126
|
+
}}
|
|
127
|
+
scrollY
|
|
128
|
+
>
|
|
129
|
+
<box flexDirection="column" style={{ gap: 0 }}>
|
|
130
|
+
{props.themes.map((theme, index) =>
|
|
131
|
+
renderThemeRow(
|
|
132
|
+
theme,
|
|
133
|
+
index,
|
|
134
|
+
props.selectedIndex,
|
|
135
|
+
props.activeSlug,
|
|
136
|
+
props.displayTheme,
|
|
137
|
+
),
|
|
138
|
+
)}
|
|
139
|
+
</box>
|
|
140
|
+
</scrollbox>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderThemeRow(
|
|
145
|
+
theme: ThemeDefinition,
|
|
146
|
+
index: number,
|
|
147
|
+
selectedIndex: number,
|
|
148
|
+
activeSlug: string,
|
|
149
|
+
displayTheme: ThemeDefinition,
|
|
150
|
+
): React.ReactNode {
|
|
151
|
+
const isSelected = index === selectedIndex;
|
|
152
|
+
const isActive = theme.slug === activeSlug;
|
|
153
|
+
return (
|
|
154
|
+
<SelectableListItem
|
|
155
|
+
key={theme.slug}
|
|
156
|
+
label={theme.name}
|
|
157
|
+
isSelected={isSelected}
|
|
158
|
+
isActive={isActive}
|
|
159
|
+
activeTag=" (active)"
|
|
160
|
+
theme={displayTheme}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ThemePreview({
|
|
166
|
+
theme,
|
|
167
|
+
}: {
|
|
168
|
+
readonly theme: ThemeDefinition;
|
|
169
|
+
}): React.ReactNode {
|
|
170
|
+
return (
|
|
171
|
+
<box
|
|
172
|
+
border
|
|
173
|
+
style={{
|
|
174
|
+
flexGrow: 1,
|
|
175
|
+
padding: 1,
|
|
176
|
+
borderColor: theme.colors.panel.border,
|
|
177
|
+
backgroundColor: theme.colors.panel.bg,
|
|
178
|
+
gap: 0,
|
|
179
|
+
flexDirection: 'column',
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
<text
|
|
183
|
+
fg={theme.colors.panel.headerFg ?? theme.colors.text.primary}
|
|
184
|
+
bg={theme.colors.panel.headerBg ?? theme.colors.panel.bg}
|
|
185
|
+
>
|
|
186
|
+
{`${theme.name} (${theme.kind})`}
|
|
187
|
+
</text>
|
|
188
|
+
<text fg={theme.colors.text.user}>[user] Hello world</text>
|
|
189
|
+
<text fg={theme.colors.text.responder}>
|
|
190
|
+
[responder] A thoughtful reply
|
|
191
|
+
</text>
|
|
192
|
+
<text fg={theme.colors.text.thinking}>
|
|
193
|
+
[thinking] Considering options...
|
|
194
|
+
</text>
|
|
195
|
+
<text fg={theme.colors.text.tool}>[tool] SearchInFile src/app.tsx</text>
|
|
196
|
+
<text fg={theme.colors.diff.addedFg} bg={theme.colors.diff.addedBg}>
|
|
197
|
+
+ diff added line
|
|
198
|
+
</text>
|
|
199
|
+
<text fg={theme.colors.diff.removedFg} bg={theme.colors.diff.removedBg}>
|
|
200
|
+
- diff removed line
|
|
201
|
+
</text>
|
|
202
|
+
</box>
|
|
203
|
+
);
|
|
204
|
+
}
|