@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,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
+ }