@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,140 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { RadioSelectOption, RadioSelectProps } from './RadioSelect';
|
|
3
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
4
|
+
|
|
5
|
+
describe('RadioSelect', () => {
|
|
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
|
+
describe('RadioSelectOption type', () => {
|
|
55
|
+
it('accepts required properties', () => {
|
|
56
|
+
const option: RadioSelectOption<string> = {
|
|
57
|
+
label: 'Option 1',
|
|
58
|
+
value: 'opt1',
|
|
59
|
+
key: 'opt1',
|
|
60
|
+
};
|
|
61
|
+
expect(option.label).toBe('Option 1');
|
|
62
|
+
expect(option.value).toBe('opt1');
|
|
63
|
+
expect(option.key).toBe('opt1');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('works with different value types', () => {
|
|
67
|
+
const stringOption: RadioSelectOption<string> = {
|
|
68
|
+
label: 'String Option',
|
|
69
|
+
value: 'string_value',
|
|
70
|
+
key: 'str',
|
|
71
|
+
};
|
|
72
|
+
expect(stringOption.value).toBe('string_value');
|
|
73
|
+
|
|
74
|
+
const numberOption: RadioSelectOption<number> = {
|
|
75
|
+
label: 'Number Option',
|
|
76
|
+
value: 42,
|
|
77
|
+
key: 'num',
|
|
78
|
+
};
|
|
79
|
+
expect(numberOption.value).toBe(42);
|
|
80
|
+
|
|
81
|
+
type CustomType = 'allow_once' | 'allow_always' | 'cancel';
|
|
82
|
+
const unionOption: RadioSelectOption<CustomType> = {
|
|
83
|
+
label: 'Union Option',
|
|
84
|
+
value: 'allow_once',
|
|
85
|
+
key: 'union',
|
|
86
|
+
};
|
|
87
|
+
expect(unionOption.value).toBe('allow_once');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('RadioSelectProps type', () => {
|
|
92
|
+
it('accepts required props', () => {
|
|
93
|
+
const options: RadioSelectOption<string>[] = [
|
|
94
|
+
{ label: 'Option 1', value: 'opt1', key: 'opt1' },
|
|
95
|
+
{ label: 'Option 2', value: 'opt2', key: 'opt2' },
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const props: RadioSelectProps<string> = {
|
|
99
|
+
options,
|
|
100
|
+
onSelect: () => {},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
expect(props.options).toHaveLength(2);
|
|
104
|
+
expect(typeof props.onSelect).toBe('function');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('accepts optional theme prop', () => {
|
|
108
|
+
const props: RadioSelectProps<string> = {
|
|
109
|
+
options: [{ label: 'Test', value: 'test', key: 'test' }],
|
|
110
|
+
onSelect: () => {},
|
|
111
|
+
theme: mockTheme,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
expect(props.theme).toBe(mockTheme);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('accepts optional isFocused prop', () => {
|
|
118
|
+
const props: RadioSelectProps<string> = {
|
|
119
|
+
options: [{ label: 'Test', value: 'test', key: 'test' }],
|
|
120
|
+
onSelect: () => {},
|
|
121
|
+
isFocused: false,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
expect(props.isFocused).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('accepts optional initialIndex prop', () => {
|
|
128
|
+
const props: RadioSelectProps<string> = {
|
|
129
|
+
options: [
|
|
130
|
+
{ label: 'Option 1', value: 'opt1', key: 'opt1' },
|
|
131
|
+
{ label: 'Option 2', value: 'opt2', key: 'opt2' },
|
|
132
|
+
],
|
|
133
|
+
onSelect: () => {},
|
|
134
|
+
initialIndex: 1,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
expect(props.initialIndex).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useKeyboard } from '@vybestack/opentui-react';
|
|
3
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
4
|
+
import { useListNavigation } from '../../hooks/useListNavigation';
|
|
5
|
+
|
|
6
|
+
export interface RadioSelectOption<T> {
|
|
7
|
+
readonly label: string;
|
|
8
|
+
readonly value: T;
|
|
9
|
+
readonly key: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RadioSelectProps<T> {
|
|
13
|
+
readonly options: RadioSelectOption<T>[];
|
|
14
|
+
readonly onSelect: (value: T) => void;
|
|
15
|
+
readonly theme?: ThemeDefinition;
|
|
16
|
+
readonly isFocused?: boolean;
|
|
17
|
+
readonly initialIndex?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function RadioSelect<T>(props: RadioSelectProps<T>): React.ReactNode {
|
|
21
|
+
const {
|
|
22
|
+
options,
|
|
23
|
+
onSelect,
|
|
24
|
+
theme,
|
|
25
|
+
isFocused = true,
|
|
26
|
+
initialIndex = 0,
|
|
27
|
+
} = props;
|
|
28
|
+
const { selectedIndex, moveSelection, setSelectedIndex } = useListNavigation(
|
|
29
|
+
options.length,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Initialize to initialIndex on first render
|
|
33
|
+
if (
|
|
34
|
+
selectedIndex === 0 &&
|
|
35
|
+
initialIndex !== 0 &&
|
|
36
|
+
initialIndex < options.length
|
|
37
|
+
) {
|
|
38
|
+
setSelectedIndex(initialIndex);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
useKeyboard((key) => {
|
|
42
|
+
if (!isFocused) return;
|
|
43
|
+
|
|
44
|
+
if (key.eventType === 'press') {
|
|
45
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
46
|
+
key.preventDefault();
|
|
47
|
+
moveSelection(-1);
|
|
48
|
+
} else if (key.name === 'down' || key.name === 'j') {
|
|
49
|
+
key.preventDefault();
|
|
50
|
+
moveSelection(1);
|
|
51
|
+
} else if (key.name === 'return') {
|
|
52
|
+
key.preventDefault();
|
|
53
|
+
const selected = options.at(selectedIndex);
|
|
54
|
+
if (selected != null) {
|
|
55
|
+
onSelect(selected.value);
|
|
56
|
+
}
|
|
57
|
+
} else if (key.name >= '1' && key.name <= '9') {
|
|
58
|
+
const index = parseInt(key.name, 10) - 1;
|
|
59
|
+
if (index < options.length) {
|
|
60
|
+
key.preventDefault();
|
|
61
|
+
const selected = options.at(index);
|
|
62
|
+
if (selected != null) {
|
|
63
|
+
onSelect(selected.value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<box flexDirection="column" style={{ gap: 0 }}>
|
|
72
|
+
{options.map((option, index): React.ReactNode => {
|
|
73
|
+
const isSelected = index === selectedIndex;
|
|
74
|
+
const bullet = isSelected ? '●' : '○';
|
|
75
|
+
const number = index + 1;
|
|
76
|
+
const fg = isSelected
|
|
77
|
+
? theme?.colors.accent.primary
|
|
78
|
+
: theme?.colors.text.primary;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<text key={option.key} fg={fg}>
|
|
82
|
+
{`${number}. ${bullet} ${option.label}`}
|
|
83
|
+
</text>
|
|
84
|
+
);
|
|
85
|
+
})}
|
|
86
|
+
</box>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { SelectableListItemProps } from './SelectableList';
|
|
3
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
4
|
+
|
|
5
|
+
describe('SelectableListItem', () => {
|
|
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 without optional fields', () => {
|
|
55
|
+
const props: SelectableListItemProps = {
|
|
56
|
+
label: 'Test Item',
|
|
57
|
+
isSelected: true,
|
|
58
|
+
theme: mockTheme,
|
|
59
|
+
};
|
|
60
|
+
expect(props.label).toBe('Test Item');
|
|
61
|
+
expect(props.isSelected).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('accepts optional isActive prop', () => {
|
|
65
|
+
const props: SelectableListItemProps = {
|
|
66
|
+
label: 'Test Item',
|
|
67
|
+
isSelected: false,
|
|
68
|
+
isActive: true,
|
|
69
|
+
theme: mockTheme,
|
|
70
|
+
};
|
|
71
|
+
expect(props.isActive).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('accepts optional activeTag prop', () => {
|
|
75
|
+
const props: SelectableListItemProps = {
|
|
76
|
+
label: 'Test Item',
|
|
77
|
+
isSelected: false,
|
|
78
|
+
activeTag: ' (active)',
|
|
79
|
+
theme: mockTheme,
|
|
80
|
+
};
|
|
81
|
+
expect(props.activeTag).toBe(' (active)');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
3
|
+
|
|
4
|
+
export interface SelectableListItemProps {
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly isSelected: boolean;
|
|
7
|
+
readonly isActive?: boolean;
|
|
8
|
+
readonly activeTag?: string;
|
|
9
|
+
readonly theme?: ThemeDefinition;
|
|
10
|
+
readonly width?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SelectableListItem(
|
|
14
|
+
props: SelectableListItemProps,
|
|
15
|
+
): React.ReactNode {
|
|
16
|
+
const bullet = props.isSelected ? '●' : '○';
|
|
17
|
+
const activeTag =
|
|
18
|
+
props.isActive === true && props.activeTag ? props.activeTag : '';
|
|
19
|
+
const labelText = `${bullet} ${props.label}${activeTag}`;
|
|
20
|
+
const finalText =
|
|
21
|
+
props.width != null ? labelText.padEnd(props.width, ' ') : labelText;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<text
|
|
25
|
+
fg={
|
|
26
|
+
props.isSelected
|
|
27
|
+
? props.theme?.colors.accent.primary
|
|
28
|
+
: props.theme?.colors.text.primary
|
|
29
|
+
}
|
|
30
|
+
style={{ paddingLeft: 1, paddingRight: 1 }}
|
|
31
|
+
>
|
|
32
|
+
{finalText}
|
|
33
|
+
</text>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
3
|
+
import type { StreamState } from '../../hooks/useChatStore';
|
|
4
|
+
|
|
5
|
+
export interface StatusBarProps {
|
|
6
|
+
readonly statusLabel: string;
|
|
7
|
+
readonly promptCount: number;
|
|
8
|
+
readonly responderWordCount: number;
|
|
9
|
+
readonly streamState: StreamState;
|
|
10
|
+
readonly theme: ThemeDefinition;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function buildStatusLabel(
|
|
14
|
+
streamState: StreamState,
|
|
15
|
+
autoFollow: boolean,
|
|
16
|
+
): string {
|
|
17
|
+
const streamingPart = streamState === 'busy' ? 'busy' : 'idle';
|
|
18
|
+
const scrollPart = autoFollow ? 'follow' : 'scroll lock';
|
|
19
|
+
return `${streamingPart} | ${scrollPart}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function StatusBar(props: StatusBarProps): React.ReactNode {
|
|
23
|
+
return (
|
|
24
|
+
<box
|
|
25
|
+
style={{
|
|
26
|
+
minHeight: 1,
|
|
27
|
+
maxHeight: 3,
|
|
28
|
+
paddingLeft: 1,
|
|
29
|
+
paddingRight: 1,
|
|
30
|
+
paddingTop: 0,
|
|
31
|
+
paddingBottom: 1,
|
|
32
|
+
flexDirection: 'row',
|
|
33
|
+
justifyContent: 'space-between',
|
|
34
|
+
backgroundColor: props.theme.colors.panel.bg,
|
|
35
|
+
border: ['top'],
|
|
36
|
+
borderColor: props.theme.colors.panel.border,
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<text fg={props.theme.colors.status.fg}>{props.statusLabel}</text>
|
|
40
|
+
<text fg={props.theme.colors.status.fg}>
|
|
41
|
+
{`prompts: ${props.promptCount} | words: ${props.responderWordCount} | ${props.streamState}`}
|
|
42
|
+
</text>
|
|
43
|
+
</box>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { MAX_SUGGESTION_COUNT } from '../../features/completion';
|
|
3
|
+
import type { CompletionSuggestion } from '../../features/completion';
|
|
4
|
+
import type { ThemeDefinition } from '../../features/theme';
|
|
5
|
+
|
|
6
|
+
export interface SuggestionPanelProps {
|
|
7
|
+
readonly suggestions: CompletionSuggestion[];
|
|
8
|
+
readonly selectedIndex: number;
|
|
9
|
+
readonly theme: ThemeDefinition;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function renderSuggestionRow(
|
|
13
|
+
item: CompletionSuggestion,
|
|
14
|
+
globalIndex: number,
|
|
15
|
+
selectedIndex: number,
|
|
16
|
+
maxLabel: number,
|
|
17
|
+
theme: ThemeDefinition,
|
|
18
|
+
): React.ReactNode {
|
|
19
|
+
const isSelected = globalIndex === selectedIndex;
|
|
20
|
+
const prefix =
|
|
21
|
+
item.mode === 'slash' && item.displayPrefix !== false ? '/' : '';
|
|
22
|
+
const label = `${prefix}${item.value}`.padEnd(maxLabel + 1, ' ');
|
|
23
|
+
const description = item.description ? ` ${item.description}` : '';
|
|
24
|
+
const rowText = `${label}${description}`;
|
|
25
|
+
|
|
26
|
+
// Use explicit colors to avoid rendering issues with selection
|
|
27
|
+
const bgColor = isSelected
|
|
28
|
+
? theme.colors.selection.bg
|
|
29
|
+
: theme.colors.panel.bg;
|
|
30
|
+
const fgColor = isSelected
|
|
31
|
+
? theme.colors.selection.fg
|
|
32
|
+
: theme.colors.text.primary;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<text
|
|
36
|
+
key={`suggestion-${globalIndex}`}
|
|
37
|
+
bg={bgColor}
|
|
38
|
+
fg={fgColor}
|
|
39
|
+
style={{ paddingLeft: 1, paddingRight: 1 }}
|
|
40
|
+
>
|
|
41
|
+
{rowText}
|
|
42
|
+
</text>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function SuggestionPanel(
|
|
47
|
+
props: SuggestionPanelProps,
|
|
48
|
+
): React.ReactNode | null {
|
|
49
|
+
if (props.suggestions.length === 0) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pageSize = MAX_SUGGESTION_COUNT;
|
|
54
|
+
const totalPages = Math.max(
|
|
55
|
+
1,
|
|
56
|
+
Math.ceil(props.suggestions.length / pageSize),
|
|
57
|
+
);
|
|
58
|
+
const pageIndex = Math.floor(props.selectedIndex / pageSize);
|
|
59
|
+
const pageStart = pageIndex * pageSize;
|
|
60
|
+
const pageItems = props.suggestions.slice(pageStart, pageStart + pageSize);
|
|
61
|
+
const maxLabel = pageItems.reduce(
|
|
62
|
+
(max, item) =>
|
|
63
|
+
Math.max(max, item.value.length + (item.mode === 'slash' ? 1 : 0)),
|
|
64
|
+
0,
|
|
65
|
+
);
|
|
66
|
+
const indicatorNeeded = props.suggestions.length > pageSize;
|
|
67
|
+
const height = pageItems.length + (indicatorNeeded ? 1 : 0);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<box
|
|
71
|
+
style={{
|
|
72
|
+
height,
|
|
73
|
+
minHeight: height,
|
|
74
|
+
maxHeight: height,
|
|
75
|
+
paddingLeft: 0,
|
|
76
|
+
paddingRight: 0,
|
|
77
|
+
paddingTop: 0,
|
|
78
|
+
paddingBottom: 0,
|
|
79
|
+
flexDirection: 'column',
|
|
80
|
+
backgroundColor: props.theme.colors.panel.bg,
|
|
81
|
+
marginTop: 0,
|
|
82
|
+
marginBottom: 1,
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
{pageItems.map((item, index) =>
|
|
86
|
+
renderSuggestionRow(
|
|
87
|
+
item,
|
|
88
|
+
pageStart + index,
|
|
89
|
+
props.selectedIndex,
|
|
90
|
+
maxLabel,
|
|
91
|
+
props.theme,
|
|
92
|
+
),
|
|
93
|
+
)}
|
|
94
|
+
{indicatorNeeded ? (
|
|
95
|
+
<text
|
|
96
|
+
fg={props.theme.colors.text.muted}
|
|
97
|
+
style={{ paddingLeft: 1 }}
|
|
98
|
+
>{`▼ page ${pageIndex + 1}/${totalPages} ▲`}</text>
|
|
99
|
+
) : null}
|
|
100
|
+
</box>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ModelMessageProps } from './types';
|
|
3
|
+
|
|
4
|
+
export function ModelMessage(
|
|
5
|
+
props: Readonly<ModelMessageProps>,
|
|
6
|
+
): React.ReactNode {
|
|
7
|
+
return (
|
|
8
|
+
<text key={props.id} fg={props.theme.colors.text.responder}>
|
|
9
|
+
{props.text}
|
|
10
|
+
</text>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
ModelMessage.displayName = 'ModelMessage';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { SystemMessageProps } from './types';
|
|
3
|
+
import { EmptyBorder } from './types';
|
|
4
|
+
|
|
5
|
+
export function SystemMessage(
|
|
6
|
+
props: Readonly<SystemMessageProps>,
|
|
7
|
+
): React.ReactNode {
|
|
8
|
+
const bgColor = props.theme.colors.message.systemBg;
|
|
9
|
+
return (
|
|
10
|
+
<box
|
|
11
|
+
key={props.id}
|
|
12
|
+
border={['left']}
|
|
13
|
+
borderColor={props.theme.colors.message.systemBorder}
|
|
14
|
+
customBorderChars={{
|
|
15
|
+
...EmptyBorder,
|
|
16
|
+
vertical: '│',
|
|
17
|
+
bottomLeft: '╵',
|
|
18
|
+
topLeft: '╷',
|
|
19
|
+
}}
|
|
20
|
+
style={{ paddingLeft: 1, marginBottom: 1, backgroundColor: bgColor }}
|
|
21
|
+
>
|
|
22
|
+
<text fg={props.theme.colors.message.systemText} bg={bgColor}>
|
|
23
|
+
{props.text}
|
|
24
|
+
</text>
|
|
25
|
+
</box>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
SystemMessage.displayName = 'SystemMessage';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ThinkingMessageProps } from './types';
|
|
3
|
+
|
|
4
|
+
export function ThinkingMessage(
|
|
5
|
+
props: Readonly<ThinkingMessageProps>,
|
|
6
|
+
): React.ReactNode {
|
|
7
|
+
return (
|
|
8
|
+
<text key={props.id} fg={props.theme.colors.text.thinking}>
|
|
9
|
+
<i>{props.text}</i>
|
|
10
|
+
</text>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
ThinkingMessage.displayName = 'ThinkingMessage';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { UserMessageProps } from './types';
|
|
3
|
+
import { EmptyBorder } from './types';
|
|
4
|
+
|
|
5
|
+
export function UserMessage(
|
|
6
|
+
props: Readonly<UserMessageProps>,
|
|
7
|
+
): React.ReactNode {
|
|
8
|
+
return (
|
|
9
|
+
<box
|
|
10
|
+
key={props.id}
|
|
11
|
+
border={['left']}
|
|
12
|
+
borderColor={props.theme.colors.message.userBorder}
|
|
13
|
+
customBorderChars={{
|
|
14
|
+
...EmptyBorder,
|
|
15
|
+
vertical: '┃',
|
|
16
|
+
bottomLeft: '╹',
|
|
17
|
+
topLeft: '╻',
|
|
18
|
+
}}
|
|
19
|
+
style={{ paddingLeft: 1, marginBottom: 1 }}
|
|
20
|
+
>
|
|
21
|
+
<text fg={props.theme.colors.text.user}>{props.text}</text>
|
|
22
|
+
</box>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
UserMessage.displayName = 'UserMessage';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { UserMessage } from './UserMessage';
|
|
2
|
+
export { SystemMessage } from './SystemMessage';
|
|
3
|
+
export { ModelMessage } from './ModelMessage';
|
|
4
|
+
export { ThinkingMessage } from './ThinkingMessage';
|
|
5
|
+
export { renderMessage, getMessageRenderer, roleColor } from './renderMessage';
|
|
6
|
+
export { EmptyBorder } from './types';
|
|
7
|
+
export type {
|
|
8
|
+
MessageRole,
|
|
9
|
+
MessageProps,
|
|
10
|
+
UserMessageProps,
|
|
11
|
+
SystemMessageProps,
|
|
12
|
+
ModelMessageProps,
|
|
13
|
+
ThinkingMessageProps,
|
|
14
|
+
MessageComponent,
|
|
15
|
+
} from './types';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createMockTheme } from '../../../test/mockTheme';
|
|
3
|
+
import { getMessageRenderer, roleColor } from './renderMessage';
|
|
4
|
+
|
|
5
|
+
describe('getMessageRenderer', () => {
|
|
6
|
+
it('should return UserMessage renderer for user role', () => {
|
|
7
|
+
const renderer = getMessageRenderer('user');
|
|
8
|
+
expect(renderer.displayName ?? renderer.name).toBe('UserMessage');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should return ModelMessage renderer for model role', () => {
|
|
12
|
+
const renderer = getMessageRenderer('model');
|
|
13
|
+
expect(renderer.displayName ?? renderer.name).toBe('ModelMessage');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should return SystemMessage renderer for system role', () => {
|
|
17
|
+
const renderer = getMessageRenderer('system');
|
|
18
|
+
expect(renderer.displayName ?? renderer.name).toBe('SystemMessage');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should return ThinkingMessage renderer for thinking role', () => {
|
|
22
|
+
const renderer = getMessageRenderer('thinking');
|
|
23
|
+
expect(renderer.displayName ?? renderer.name).toBe('ThinkingMessage');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('roleColor', () => {
|
|
28
|
+
const mockTheme = createMockTheme();
|
|
29
|
+
|
|
30
|
+
it('should return user text color for user role', () => {
|
|
31
|
+
expect(roleColor('user', mockTheme)).toBe(mockTheme.colors.text.user);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return responder text color for model role', () => {
|
|
35
|
+
expect(roleColor('model', mockTheme)).toBe(mockTheme.colors.text.responder);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should return systemText color for system role', () => {
|
|
39
|
+
expect(roleColor('system', mockTheme)).toBe(
|
|
40
|
+
mockTheme.colors.message.systemText,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return thinking text color for thinking role', () => {
|
|
45
|
+
expect(roleColor('thinking', mockTheme)).toBe(
|
|
46
|
+
mockTheme.colors.text.thinking,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ThemeDefinition } from '../../../features/theme';
|
|
3
|
+
import type { MessageRole, MessageComponent } from './types';
|
|
4
|
+
import { UserMessage } from './UserMessage';
|
|
5
|
+
import { SystemMessage } from './SystemMessage';
|
|
6
|
+
import { ModelMessage } from './ModelMessage';
|
|
7
|
+
import { ThinkingMessage } from './ThinkingMessage';
|
|
8
|
+
|
|
9
|
+
export function getMessageRenderer(role: MessageRole): MessageComponent {
|
|
10
|
+
switch (role) {
|
|
11
|
+
case 'user':
|
|
12
|
+
return UserMessage;
|
|
13
|
+
case 'system':
|
|
14
|
+
return SystemMessage;
|
|
15
|
+
case 'model':
|
|
16
|
+
return ModelMessage;
|
|
17
|
+
case 'thinking':
|
|
18
|
+
return ThinkingMessage;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function roleColor(role: MessageRole, theme: ThemeDefinition): string {
|
|
23
|
+
switch (role) {
|
|
24
|
+
case 'user':
|
|
25
|
+
return theme.colors.text.user;
|
|
26
|
+
case 'model':
|
|
27
|
+
return theme.colors.text.responder;
|
|
28
|
+
case 'system':
|
|
29
|
+
return theme.colors.message.systemText;
|
|
30
|
+
case 'thinking':
|
|
31
|
+
return theme.colors.text.thinking;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function renderMessage(
|
|
36
|
+
role: MessageRole,
|
|
37
|
+
id: string,
|
|
38
|
+
text: string,
|
|
39
|
+
theme: ThemeDefinition,
|
|
40
|
+
): React.ReactNode {
|
|
41
|
+
const MessageComponent = getMessageRenderer(role);
|
|
42
|
+
return <MessageComponent id={id} text={text} theme={theme} />;
|
|
43
|
+
}
|