@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,74 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { SessionConfig } from '../../features/config';
|
|
3
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
4
|
+
import type { SearchItem } from '../modals/types';
|
|
5
|
+
import { ModelCommand } from '../../commands/ModelCommand';
|
|
6
|
+
import { ProviderCommand } from '../../commands/ProviderCommand';
|
|
7
|
+
import { ThemeCommand } from '../../commands/ThemeCommand';
|
|
8
|
+
import { AuthCommand } from '../../commands/AuthCommand';
|
|
9
|
+
|
|
10
|
+
interface CommandComponentsProps {
|
|
11
|
+
readonly fetchModelItems: () => Promise<{
|
|
12
|
+
items: SearchItem[];
|
|
13
|
+
messages?: string[];
|
|
14
|
+
}>;
|
|
15
|
+
readonly fetchProviderItems: () => Promise<{
|
|
16
|
+
items: SearchItem[];
|
|
17
|
+
messages?: string[];
|
|
18
|
+
}>;
|
|
19
|
+
readonly sessionConfig: SessionConfig;
|
|
20
|
+
readonly setSessionConfig: (config: SessionConfig) => void;
|
|
21
|
+
readonly appendMessage: (
|
|
22
|
+
role: 'user' | 'model' | 'system',
|
|
23
|
+
text: string,
|
|
24
|
+
) => string;
|
|
25
|
+
readonly themes: ThemeDefinition[];
|
|
26
|
+
readonly currentTheme: ThemeDefinition;
|
|
27
|
+
readonly onThemeSelect: (theme: ThemeDefinition) => void;
|
|
28
|
+
readonly focusInput: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function CommandComponents({
|
|
32
|
+
fetchModelItems,
|
|
33
|
+
fetchProviderItems,
|
|
34
|
+
sessionConfig,
|
|
35
|
+
setSessionConfig,
|
|
36
|
+
appendMessage,
|
|
37
|
+
themes,
|
|
38
|
+
currentTheme,
|
|
39
|
+
onThemeSelect,
|
|
40
|
+
focusInput,
|
|
41
|
+
}: CommandComponentsProps): React.ReactNode {
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<ModelCommand
|
|
45
|
+
fetchModelItems={fetchModelItems}
|
|
46
|
+
sessionConfig={sessionConfig}
|
|
47
|
+
setSessionConfig={setSessionConfig}
|
|
48
|
+
appendMessage={appendMessage}
|
|
49
|
+
theme={currentTheme}
|
|
50
|
+
focusInput={focusInput}
|
|
51
|
+
/>
|
|
52
|
+
<ProviderCommand
|
|
53
|
+
fetchProviderItems={fetchProviderItems}
|
|
54
|
+
sessionConfig={sessionConfig}
|
|
55
|
+
setSessionConfig={setSessionConfig}
|
|
56
|
+
appendMessage={appendMessage}
|
|
57
|
+
theme={currentTheme}
|
|
58
|
+
focusInput={focusInput}
|
|
59
|
+
/>
|
|
60
|
+
<ThemeCommand
|
|
61
|
+
themes={themes}
|
|
62
|
+
currentTheme={currentTheme}
|
|
63
|
+
onThemeSelect={onThemeSelect}
|
|
64
|
+
appendMessage={appendMessage}
|
|
65
|
+
focusInput={focusInput}
|
|
66
|
+
/>
|
|
67
|
+
<AuthCommand
|
|
68
|
+
appendMessage={appendMessage}
|
|
69
|
+
theme={currentTheme}
|
|
70
|
+
focusInput={focusInput}
|
|
71
|
+
/>
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
4
|
+
|
|
5
|
+
interface DiffLine {
|
|
6
|
+
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
|
7
|
+
oldLine?: number;
|
|
8
|
+
newLine?: number;
|
|
9
|
+
content: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ParsedDiffState {
|
|
13
|
+
currentOldLine: number;
|
|
14
|
+
currentNewLine: number;
|
|
15
|
+
inHunk: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Precompiled regex - simpler pattern that's not vulnerable to backtracking
|
|
19
|
+
const HUNK_HEADER_REGEX = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
20
|
+
|
|
21
|
+
function isGitHeaderLine(line: string): boolean {
|
|
22
|
+
return (
|
|
23
|
+
line.startsWith('--- ') ||
|
|
24
|
+
line.startsWith('+++ ') ||
|
|
25
|
+
line.startsWith('diff --git') ||
|
|
26
|
+
line.startsWith('index ') ||
|
|
27
|
+
line.startsWith('similarity index') ||
|
|
28
|
+
line.startsWith('rename from') ||
|
|
29
|
+
line.startsWith('rename to') ||
|
|
30
|
+
line.startsWith('new file mode') ||
|
|
31
|
+
line.startsWith('deleted file mode')
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseHunkHeader(
|
|
36
|
+
line: string,
|
|
37
|
+
): { oldStart: number; newStart: number } | null {
|
|
38
|
+
const execResult = HUNK_HEADER_REGEX.exec(line);
|
|
39
|
+
if (!execResult) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const oldMatch = execResult[1];
|
|
43
|
+
const newMatch = execResult[2];
|
|
44
|
+
return {
|
|
45
|
+
oldStart: parseInt(oldMatch, 10) - 1,
|
|
46
|
+
newStart: parseInt(newMatch, 10) - 1,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseDiffLine(
|
|
51
|
+
line: string,
|
|
52
|
+
state: ParsedDiffState,
|
|
53
|
+
): { diffLine: DiffLine | null; newState: ParsedDiffState } {
|
|
54
|
+
const hunk = parseHunkHeader(line);
|
|
55
|
+
if (hunk) {
|
|
56
|
+
return {
|
|
57
|
+
diffLine: { type: 'hunk', content: line },
|
|
58
|
+
newState: {
|
|
59
|
+
currentOldLine: hunk.oldStart,
|
|
60
|
+
currentNewLine: hunk.newStart,
|
|
61
|
+
inHunk: true,
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!state.inHunk || isGitHeaderLine(line)) {
|
|
67
|
+
return { diffLine: null, newState: state };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (line.startsWith('+')) {
|
|
71
|
+
const newLine = state.currentNewLine + 1;
|
|
72
|
+
return {
|
|
73
|
+
diffLine: { type: 'add', newLine, content: line.substring(1) },
|
|
74
|
+
newState: { ...state, currentNewLine: newLine },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (line.startsWith('-')) {
|
|
79
|
+
const oldLine = state.currentOldLine + 1;
|
|
80
|
+
return {
|
|
81
|
+
diffLine: { type: 'del', oldLine, content: line.substring(1) },
|
|
82
|
+
newState: { ...state, currentOldLine: oldLine },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (line.startsWith(' ')) {
|
|
87
|
+
const oldLine = state.currentOldLine + 1;
|
|
88
|
+
const newLine = state.currentNewLine + 1;
|
|
89
|
+
return {
|
|
90
|
+
diffLine: {
|
|
91
|
+
type: 'context',
|
|
92
|
+
oldLine,
|
|
93
|
+
newLine,
|
|
94
|
+
content: line.substring(1),
|
|
95
|
+
},
|
|
96
|
+
newState: { ...state, currentOldLine: oldLine, currentNewLine: newLine },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (line.startsWith('\\')) {
|
|
101
|
+
return { diffLine: { type: 'other', content: line }, newState: state };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { diffLine: null, newState: state };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseDiff(diffContent: string): DiffLine[] {
|
|
108
|
+
const lines = diffContent.split('\n');
|
|
109
|
+
const result: DiffLine[] = [];
|
|
110
|
+
let state: ParsedDiffState = {
|
|
111
|
+
currentOldLine: 0,
|
|
112
|
+
currentNewLine: 0,
|
|
113
|
+
inHunk: false,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (const line of lines) {
|
|
117
|
+
const { diffLine, newState } = parseDiffLine(line, state);
|
|
118
|
+
state = newState;
|
|
119
|
+
if (diffLine) {
|
|
120
|
+
result.push(diffLine);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface DiffViewerProps {
|
|
127
|
+
readonly diffContent: string;
|
|
128
|
+
readonly filename?: string;
|
|
129
|
+
readonly maxHeight?: number;
|
|
130
|
+
readonly theme?: ThemeDefinition;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface DiffColors {
|
|
134
|
+
addedBg: string;
|
|
135
|
+
addedFg: string;
|
|
136
|
+
removedBg: string;
|
|
137
|
+
removedFg: string;
|
|
138
|
+
contextFg: string;
|
|
139
|
+
gutterFg: string;
|
|
140
|
+
borderColor: string;
|
|
141
|
+
primaryFg: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getDefaultColors(): DiffColors {
|
|
145
|
+
return {
|
|
146
|
+
addedBg: '#1a3318',
|
|
147
|
+
addedFg: '#00ff00',
|
|
148
|
+
removedBg: '#3a1a1a',
|
|
149
|
+
removedFg: '#ff6b6b',
|
|
150
|
+
contextFg: '#888888',
|
|
151
|
+
gutterFg: '#666666',
|
|
152
|
+
borderColor: '#444444',
|
|
153
|
+
primaryFg: '#ffffff',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getColorsFromTheme(theme: ThemeDefinition | undefined): DiffColors {
|
|
158
|
+
if (!theme) {
|
|
159
|
+
return getDefaultColors();
|
|
160
|
+
}
|
|
161
|
+
// Theme properties are guaranteed to exist per ThemeColors interface
|
|
162
|
+
return {
|
|
163
|
+
addedBg: theme.colors.diff.addedBg,
|
|
164
|
+
addedFg: theme.colors.diff.addedFg,
|
|
165
|
+
removedBg: theme.colors.diff.removedBg,
|
|
166
|
+
removedFg: theme.colors.diff.removedFg,
|
|
167
|
+
contextFg: theme.colors.text.muted,
|
|
168
|
+
gutterFg: theme.colors.text.muted,
|
|
169
|
+
borderColor: theme.colors.panel.border,
|
|
170
|
+
primaryFg: theme.colors.text.primary,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function getLinePrefix(lineType: DiffLine['type']): string {
|
|
175
|
+
switch (lineType) {
|
|
176
|
+
case 'add':
|
|
177
|
+
return '+';
|
|
178
|
+
case 'del':
|
|
179
|
+
return '-';
|
|
180
|
+
case 'context':
|
|
181
|
+
case 'hunk':
|
|
182
|
+
case 'other':
|
|
183
|
+
return ' ';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface DiffLineProps {
|
|
188
|
+
readonly line: DiffLine;
|
|
189
|
+
readonly index: number;
|
|
190
|
+
readonly gutterWidth: number;
|
|
191
|
+
readonly colors: DiffColors;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function DiffLineRow({
|
|
195
|
+
line,
|
|
196
|
+
index,
|
|
197
|
+
gutterWidth,
|
|
198
|
+
colors,
|
|
199
|
+
}: DiffLineProps): React.ReactNode {
|
|
200
|
+
const lineNum = line.type === 'del' ? line.oldLine : line.newLine;
|
|
201
|
+
const lineNumStr = (lineNum ?? '').toString().padStart(gutterWidth);
|
|
202
|
+
const prefix = getLinePrefix(line.type);
|
|
203
|
+
|
|
204
|
+
let lineFg = colors.contextFg;
|
|
205
|
+
let lineBg: string | undefined;
|
|
206
|
+
if (line.type === 'add') {
|
|
207
|
+
lineFg = colors.addedFg;
|
|
208
|
+
lineBg = colors.addedBg;
|
|
209
|
+
} else if (line.type === 'del') {
|
|
210
|
+
lineFg = colors.removedFg;
|
|
211
|
+
lineBg = colors.removedBg;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const textProps =
|
|
215
|
+
lineBg !== undefined ? { fg: lineFg, bg: lineBg } : { fg: lineFg };
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<box
|
|
219
|
+
key={`diff-line-${index}`}
|
|
220
|
+
flexDirection="row"
|
|
221
|
+
style={{ width: '100%' }}
|
|
222
|
+
>
|
|
223
|
+
<text fg={colors.gutterFg}>{lineNumStr} </text>
|
|
224
|
+
<text {...textProps}>
|
|
225
|
+
{prefix} {line.content}
|
|
226
|
+
</text>
|
|
227
|
+
</box>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function DiffViewer(props: DiffViewerProps): React.ReactNode {
|
|
232
|
+
const { diffContent, filename, maxHeight = 15, theme } = props;
|
|
233
|
+
|
|
234
|
+
const parsedLines = useMemo(() => parseDiff(diffContent), [diffContent]);
|
|
235
|
+
const colors = useMemo(() => getColorsFromTheme(theme), [theme]);
|
|
236
|
+
|
|
237
|
+
const displayableLines = useMemo(
|
|
238
|
+
() => parsedLines.filter((l) => l.type !== 'hunk' && l.type !== 'other'),
|
|
239
|
+
[parsedLines],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const gutterWidth = useMemo(() => {
|
|
243
|
+
const maxLineNumber = Math.max(
|
|
244
|
+
0,
|
|
245
|
+
...displayableLines.map((l) => l.oldLine ?? 0),
|
|
246
|
+
...displayableLines.map((l) => l.newLine ?? 0),
|
|
247
|
+
);
|
|
248
|
+
return Math.max(3, maxLineNumber.toString().length);
|
|
249
|
+
}, [displayableLines]);
|
|
250
|
+
|
|
251
|
+
if (!diffContent || diffContent.trim() === '') {
|
|
252
|
+
return (
|
|
253
|
+
<box border style={{ padding: 1, borderColor: colors.borderColor }}>
|
|
254
|
+
<text fg={colors.contextFg}>No diff content.</text>
|
|
255
|
+
</box>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (displayableLines.length === 0) {
|
|
260
|
+
return (
|
|
261
|
+
<box border style={{ padding: 1, borderColor: colors.borderColor }}>
|
|
262
|
+
<text fg={colors.contextFg}>No changes detected.</text>
|
|
263
|
+
</box>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const needsScroll = displayableLines.length > maxHeight;
|
|
268
|
+
|
|
269
|
+
const content = (
|
|
270
|
+
<box flexDirection="column" style={{ gap: 0, width: '100%' }}>
|
|
271
|
+
{filename && (
|
|
272
|
+
<text key="filename" fg={colors.primaryFg}>
|
|
273
|
+
<b>{filename}</b>
|
|
274
|
+
</text>
|
|
275
|
+
)}
|
|
276
|
+
{displayableLines.map((line, index) => (
|
|
277
|
+
<DiffLineRow
|
|
278
|
+
key={`line-${index}`}
|
|
279
|
+
line={line}
|
|
280
|
+
index={index}
|
|
281
|
+
gutterWidth={gutterWidth}
|
|
282
|
+
colors={colors}
|
|
283
|
+
/>
|
|
284
|
+
))}
|
|
285
|
+
</box>
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
if (needsScroll) {
|
|
289
|
+
return (
|
|
290
|
+
<scrollbox
|
|
291
|
+
style={{
|
|
292
|
+
height: maxHeight,
|
|
293
|
+
maxHeight,
|
|
294
|
+
borderColor: colors.borderColor,
|
|
295
|
+
overflow: 'hidden',
|
|
296
|
+
}}
|
|
297
|
+
scrollY
|
|
298
|
+
scrollX={false}
|
|
299
|
+
>
|
|
300
|
+
{content}
|
|
301
|
+
</scrollbox>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return content;
|
|
306
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { FilterInputProps } from './FilterInput';
|
|
3
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
4
|
+
|
|
5
|
+
describe('FilterInput', () => {
|
|
6
|
+
const mockTheme: ThemeDefinition = {
|
|
7
|
+
slug: 'test',
|
|
8
|
+
name: 'Test Theme',
|
|
9
|
+
kind: 'dark',
|
|
10
|
+
colors: {
|
|
11
|
+
background: '#000000',
|
|
12
|
+
text: {
|
|
13
|
+
primary: '#ffffff',
|
|
14
|
+
muted: '#888888',
|
|
15
|
+
user: '#00ff00',
|
|
16
|
+
responder: '#0088ff',
|
|
17
|
+
thinking: '#ff8800',
|
|
18
|
+
tool: '#ff00ff',
|
|
19
|
+
},
|
|
20
|
+
input: {
|
|
21
|
+
fg: '#ffffff',
|
|
22
|
+
bg: '#000000',
|
|
23
|
+
border: '#333333',
|
|
24
|
+
placeholder: '#666666',
|
|
25
|
+
},
|
|
26
|
+
panel: {
|
|
27
|
+
bg: '#111111',
|
|
28
|
+
border: '#333333',
|
|
29
|
+
},
|
|
30
|
+
status: {
|
|
31
|
+
fg: '#ffffff',
|
|
32
|
+
},
|
|
33
|
+
accent: {
|
|
34
|
+
primary: '#00ffff',
|
|
35
|
+
},
|
|
36
|
+
selection: {
|
|
37
|
+
fg: '#000000',
|
|
38
|
+
bg: '#ffffff',
|
|
39
|
+
},
|
|
40
|
+
diff: {
|
|
41
|
+
addedBg: '#003300',
|
|
42
|
+
addedFg: '#00ff00',
|
|
43
|
+
removedBg: '#330000',
|
|
44
|
+
removedFg: '#ff0000',
|
|
45
|
+
},
|
|
46
|
+
message: {
|
|
47
|
+
userBorder: '#00ff00',
|
|
48
|
+
systemBorder: '#888888',
|
|
49
|
+
systemText: '#888888',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
it('accepts required props', () => {
|
|
55
|
+
const onQueryChange = vi.fn();
|
|
56
|
+
const props: Omit<FilterInputProps, 'textareaRef'> = {
|
|
57
|
+
placeholder: 'type to filter',
|
|
58
|
+
theme: mockTheme,
|
|
59
|
+
onQueryChange,
|
|
60
|
+
};
|
|
61
|
+
expect(props.placeholder).toBe('type to filter');
|
|
62
|
+
expect(props.onQueryChange).toBe(onQueryChange);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('requires onQueryChange callback', () => {
|
|
66
|
+
const onQueryChange = vi.fn();
|
|
67
|
+
expect(onQueryChange).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { TextareaRenderable } from '@vybestack/opentui-core';
|
|
2
|
+
import { parseColor, stringToStyledText } from '@vybestack/opentui-core';
|
|
3
|
+
import React, { useCallback, useEffect, useMemo, type RefObject } from 'react';
|
|
4
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
5
|
+
|
|
6
|
+
export interface FilterInputProps {
|
|
7
|
+
readonly textareaRef: RefObject<TextareaRenderable | null>;
|
|
8
|
+
readonly placeholder: string;
|
|
9
|
+
readonly theme?: ThemeDefinition;
|
|
10
|
+
readonly onQueryChange: (query: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function FilterInput(props: FilterInputProps): React.ReactNode {
|
|
14
|
+
const placeholderText = useMemo(() => {
|
|
15
|
+
const base = stringToStyledText(props.placeholder);
|
|
16
|
+
const fg = parseColor(
|
|
17
|
+
props.theme?.colors.input.placeholder ??
|
|
18
|
+
props.theme?.colors.text.muted ??
|
|
19
|
+
'#888888',
|
|
20
|
+
);
|
|
21
|
+
return { ...base, chunks: base.chunks.map((chunk) => ({ ...chunk, fg })) };
|
|
22
|
+
}, [
|
|
23
|
+
props.placeholder,
|
|
24
|
+
props.theme?.colors.input.placeholder,
|
|
25
|
+
props.theme?.colors.text.muted,
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const handleSubmit = useCallback(() => undefined, []);
|
|
29
|
+
|
|
30
|
+
const handleContentChange = useCallback(() => {
|
|
31
|
+
props.onQueryChange(props.textareaRef.current?.plainText ?? '');
|
|
32
|
+
}, [props]);
|
|
33
|
+
|
|
34
|
+
const handleCursorChange = useCallback(() => {
|
|
35
|
+
props.onQueryChange(props.textareaRef.current?.plainText ?? '');
|
|
36
|
+
}, [props]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
props.textareaRef.current?.focus();
|
|
40
|
+
}, [props.textareaRef]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<textarea
|
|
44
|
+
ref={props.textareaRef}
|
|
45
|
+
placeholder={placeholderText}
|
|
46
|
+
keyBindings={[{ name: 'return', action: 'submit' }]}
|
|
47
|
+
onSubmit={handleSubmit}
|
|
48
|
+
onContentChange={handleContentChange}
|
|
49
|
+
onCursorChange={handleCursorChange}
|
|
50
|
+
style={{
|
|
51
|
+
height: 1,
|
|
52
|
+
width: '90%',
|
|
53
|
+
minHeight: 1,
|
|
54
|
+
maxHeight: 1,
|
|
55
|
+
}}
|
|
56
|
+
textColor={props.theme?.colors.input.fg}
|
|
57
|
+
focusedTextColor={props.theme?.colors.input.fg}
|
|
58
|
+
backgroundColor={props.theme?.colors.input.bg}
|
|
59
|
+
focusedBackgroundColor={props.theme?.colors.input.bg}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { useRenderer } from '@vybestack/opentui-react';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
8
|
+
import { getLogger } from '../../lib/logger';
|
|
9
|
+
|
|
10
|
+
const logger = getLogger('nui:headerbar');
|
|
11
|
+
|
|
12
|
+
// Get the directory of this source file, then navigate to the logo
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = path.dirname(__filename);
|
|
15
|
+
const LOGO_PATH = path.resolve(__dirname, '../../../llxprt.png');
|
|
16
|
+
|
|
17
|
+
logger.debug('HeaderBar module loaded', {
|
|
18
|
+
__filename,
|
|
19
|
+
__dirname,
|
|
20
|
+
LOGO_PATH,
|
|
21
|
+
logoExists: existsSync(LOGO_PATH),
|
|
22
|
+
});
|
|
23
|
+
const LOGO_PX_WIDTH = 150;
|
|
24
|
+
const LOGO_PX_HEIGHT = 90;
|
|
25
|
+
|
|
26
|
+
interface HeaderBarProps {
|
|
27
|
+
readonly text: string;
|
|
28
|
+
readonly theme: ThemeDefinition;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function HeaderBar({ text, theme }: HeaderBarProps): React.ReactNode {
|
|
32
|
+
const renderer = useRenderer();
|
|
33
|
+
|
|
34
|
+
const caps = renderer.capabilities as {
|
|
35
|
+
pixelResolution?: { width: number; height: number };
|
|
36
|
+
} | null;
|
|
37
|
+
const resolution = caps?.pixelResolution ?? renderer.resolution ?? null;
|
|
38
|
+
const cellMetrics = renderer.getCellMetrics() ?? null;
|
|
39
|
+
|
|
40
|
+
// Log graphics support and resolution detection
|
|
41
|
+
logger.debug('HeaderBar render', {
|
|
42
|
+
graphicsSupport: renderer.graphicsSupport,
|
|
43
|
+
termProgram: process.env.TERM_PROGRAM,
|
|
44
|
+
term: process.env.TERM,
|
|
45
|
+
resolution,
|
|
46
|
+
cellMetrics,
|
|
47
|
+
rendererResolution: renderer.resolution,
|
|
48
|
+
terminalWidth: renderer.terminalWidth,
|
|
49
|
+
terminalHeight: renderer.terminalHeight,
|
|
50
|
+
});
|
|
51
|
+
const [, setTick] = useState(0);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const refresh = () => setTick((t) => t + 1);
|
|
55
|
+
renderer.on('capabilities', refresh);
|
|
56
|
+
renderer.on('pixelResolution', refresh);
|
|
57
|
+
renderer.on('resize', refresh);
|
|
58
|
+
return () => {
|
|
59
|
+
renderer.off('capabilities', refresh);
|
|
60
|
+
renderer.off('pixelResolution', refresh);
|
|
61
|
+
renderer.off('resize', refresh);
|
|
62
|
+
};
|
|
63
|
+
}, [renderer]);
|
|
64
|
+
|
|
65
|
+
const pxPerCellX =
|
|
66
|
+
resolution && renderer.terminalWidth > 0
|
|
67
|
+
? resolution.width / renderer.terminalWidth
|
|
68
|
+
: null;
|
|
69
|
+
const pxPerCellY =
|
|
70
|
+
resolution && renderer.terminalHeight > 0
|
|
71
|
+
? resolution.height / renderer.terminalHeight
|
|
72
|
+
: null;
|
|
73
|
+
const desiredCellHeight = 2;
|
|
74
|
+
const scaleFactor = 0.9; // modest shrink to keep it inside the border
|
|
75
|
+
const fallbackPxPerCellX = 9;
|
|
76
|
+
const fallbackPxPerCellY = 20;
|
|
77
|
+
const scaledPixelHeight = Math.round(
|
|
78
|
+
pxPerCellY != null
|
|
79
|
+
? Math.min(
|
|
80
|
+
LOGO_PX_HEIGHT * scaleFactor,
|
|
81
|
+
pxPerCellY * desiredCellHeight * scaleFactor,
|
|
82
|
+
)
|
|
83
|
+
: LOGO_PX_HEIGHT * scaleFactor,
|
|
84
|
+
);
|
|
85
|
+
const scaledPixelWidth = Math.max(
|
|
86
|
+
1,
|
|
87
|
+
Math.round((scaledPixelHeight * LOGO_PX_WIDTH) / LOGO_PX_HEIGHT),
|
|
88
|
+
);
|
|
89
|
+
const effPxPerCellX = pxPerCellX ?? fallbackPxPerCellX;
|
|
90
|
+
const effPxPerCellY = pxPerCellY ?? fallbackPxPerCellY;
|
|
91
|
+
const logoWidthCells = Math.max(
|
|
92
|
+
1,
|
|
93
|
+
Math.ceil(scaledPixelWidth / effPxPerCellX),
|
|
94
|
+
);
|
|
95
|
+
const logoHeightCells = Math.max(
|
|
96
|
+
1,
|
|
97
|
+
Math.ceil(scaledPixelHeight / effPxPerCellY),
|
|
98
|
+
);
|
|
99
|
+
const headerHeight = Math.max(logoHeightCells + 1, 3);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<box
|
|
103
|
+
style={{
|
|
104
|
+
border: true,
|
|
105
|
+
height: headerHeight,
|
|
106
|
+
minHeight: headerHeight,
|
|
107
|
+
maxHeight: headerHeight,
|
|
108
|
+
paddingTop: 0,
|
|
109
|
+
paddingBottom: 0,
|
|
110
|
+
paddingLeft: 0,
|
|
111
|
+
paddingRight: 0,
|
|
112
|
+
borderColor: theme.colors.panel.border,
|
|
113
|
+
backgroundColor: theme.colors.panel.headerBg ?? theme.colors.panel.bg,
|
|
114
|
+
alignItems: 'center',
|
|
115
|
+
flexDirection: 'row',
|
|
116
|
+
gap: 0,
|
|
117
|
+
justifyContent: 'flex-start',
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
<image
|
|
121
|
+
src={LOGO_PATH}
|
|
122
|
+
alt="LLxprt Code"
|
|
123
|
+
width={logoWidthCells}
|
|
124
|
+
height={logoHeightCells}
|
|
125
|
+
pixelWidth={scaledPixelWidth}
|
|
126
|
+
pixelHeight={scaledPixelHeight}
|
|
127
|
+
style={{ marginRight: 1 }}
|
|
128
|
+
/>
|
|
129
|
+
<text
|
|
130
|
+
fg={theme.colors.panel.headerFg ?? theme.colors.text.primary}
|
|
131
|
+
style={{ marginLeft: 1, alignSelf: 'center' }}
|
|
132
|
+
>
|
|
133
|
+
{text}
|
|
134
|
+
</text>
|
|
135
|
+
</box>
|
|
136
|
+
);
|
|
137
|
+
}
|