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