@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,217 @@
|
|
|
1
|
+
import { secureRandomBetween } from '../../lib/random';
|
|
2
|
+
|
|
3
|
+
export interface ToolCallBlock {
|
|
4
|
+
readonly lines: string[];
|
|
5
|
+
readonly isBatch: boolean;
|
|
6
|
+
readonly scrollable?: boolean;
|
|
7
|
+
readonly maxHeight?: number;
|
|
8
|
+
readonly streaming?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ShellPlan {
|
|
12
|
+
readonly command: string;
|
|
13
|
+
readonly output: string[];
|
|
14
|
+
readonly maxHeight: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const OPENERS = [
|
|
18
|
+
'Camus shrugs at the sky,',
|
|
19
|
+
'Nietzsche laughs in the dark,',
|
|
20
|
+
'The void hums quietly while',
|
|
21
|
+
'A hedonist clinks a glass because',
|
|
22
|
+
'Sisyphus pauses mid-push as',
|
|
23
|
+
'Dionysus sings over static and',
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
const DRIVERS = [
|
|
27
|
+
'meaning is negotiated then forgotten,',
|
|
28
|
+
'willpower tastes like rusted metal,',
|
|
29
|
+
'pleasure is an act of rebellion,',
|
|
30
|
+
'every rule is a rumor,',
|
|
31
|
+
'the abyss wants a conversation,',
|
|
32
|
+
'time is a joke with a long punchline,',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
const SPINS = [
|
|
36
|
+
'so I dance anyway.',
|
|
37
|
+
'yet we still buy coffee at dawn.',
|
|
38
|
+
'and the night market keeps buzzing.',
|
|
39
|
+
'because absurd joy is cheaper than despair.',
|
|
40
|
+
'while the sea keeps no memory.',
|
|
41
|
+
'so breath becomes a quiet manifesto.',
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export function buildResponderLine(): string {
|
|
45
|
+
return `${pick(OPENERS)} ${pick(DRIVERS)} ${pick(SPINS)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const THOUGHTS = [
|
|
49
|
+
'I should tell the user about the abyss.',
|
|
50
|
+
'Perhaps hedonism is the answer.',
|
|
51
|
+
'Maybe the code hides a better metaphor.',
|
|
52
|
+
"I'd like to learn more about the codebase; maybe I'll send a tool call.",
|
|
53
|
+
'Is meaning just another branch to merge?',
|
|
54
|
+
'Should I warn them the void has opinions?',
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
export function buildThinkingLine(): string {
|
|
58
|
+
return pick(THOUGHTS);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function countWords(text: string): number {
|
|
62
|
+
const matches = text.trim().match(/\S+/g);
|
|
63
|
+
return matches ? matches.length : 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pick<T>(items: readonly T[]): T {
|
|
67
|
+
return items[secureRandomBetween(0, items.length - 1)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type ToolKind = 'ReadFile' | 'Glob' | 'SearchInFile';
|
|
71
|
+
|
|
72
|
+
const SAMPLE_FILES = [
|
|
73
|
+
'src/app.tsx',
|
|
74
|
+
'src/modalShell.tsx',
|
|
75
|
+
'src/searchSelectModal.tsx',
|
|
76
|
+
'src/history.ts',
|
|
77
|
+
'scripts/check-limits.ts',
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const SAMPLE_PATTERNS = [
|
|
81
|
+
'useState',
|
|
82
|
+
'Modal',
|
|
83
|
+
'stream',
|
|
84
|
+
'return',
|
|
85
|
+
'export function',
|
|
86
|
+
'const ',
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export function maybeBuildToolCalls(): ToolCallBlock | null {
|
|
90
|
+
if (secureRandomBetween(0, 6) !== 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const parallel = secureRandomBetween(0, 1) === 1;
|
|
94
|
+
const count = secureRandomBetween(1, 5);
|
|
95
|
+
const calls = Array.from({ length: count }, () =>
|
|
96
|
+
buildToolCallLines(randomToolKind()),
|
|
97
|
+
).flat();
|
|
98
|
+
if (parallel) {
|
|
99
|
+
return {
|
|
100
|
+
lines: [
|
|
101
|
+
`[tool batch] ${count} calls`,
|
|
102
|
+
...calls.map((line) => ` ${line}`),
|
|
103
|
+
],
|
|
104
|
+
isBatch: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { lines: calls, isBatch: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildToolCallLines(kind: ToolKind): string[] {
|
|
111
|
+
if (kind === 'ReadFile') {
|
|
112
|
+
const file = pick(SAMPLE_FILES);
|
|
113
|
+
const start = secureRandomBetween(3, 40);
|
|
114
|
+
const end = start + secureRandomBetween(2, 6);
|
|
115
|
+
return [
|
|
116
|
+
formatToolHeader(`ReadFile ${file} ${start}-${end}`),
|
|
117
|
+
` ${start}: // simulated code line`,
|
|
118
|
+
` ${start + 1}: // more simulated code`,
|
|
119
|
+
` ${end}: // eof snippet`,
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
if (kind === 'Glob') {
|
|
123
|
+
const pattern = pick(['./*.ts', './src/*.tsx', './**/*.ts']);
|
|
124
|
+
return [
|
|
125
|
+
formatToolHeader(`Glob ${pattern}`),
|
|
126
|
+
` -> ${pick(SAMPLE_FILES)}`,
|
|
127
|
+
` -> ${pick(SAMPLE_FILES)}`,
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
const file = pick(SAMPLE_FILES);
|
|
131
|
+
const pattern = pick(SAMPLE_PATTERNS);
|
|
132
|
+
const first = secureRandomBetween(5, 60);
|
|
133
|
+
return [
|
|
134
|
+
formatToolHeader(`SearchInFile ${file} "${pattern}"`),
|
|
135
|
+
` ${first}: match: ${pattern}()`,
|
|
136
|
+
` ${first + secureRandomBetween(1, 10)}: match: ${pattern} // more`,
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatToolHeader(description: string): string {
|
|
141
|
+
return `[tool] ${description}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function randomToolKind(): ToolKind {
|
|
145
|
+
const kinds: ToolKind[] = ['ReadFile', 'Glob', 'SearchInFile'];
|
|
146
|
+
return kinds[secureRandomBetween(0, kinds.length - 1)];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const SHELL_COMMANDS = [
|
|
150
|
+
'npm run test',
|
|
151
|
+
'find . -name "*.ts"',
|
|
152
|
+
'git status --short',
|
|
153
|
+
'ls -la',
|
|
154
|
+
'npm run lint',
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
export function maybeBuildShellPlan(): ShellPlan | null {
|
|
158
|
+
if (secureRandomBetween(0, 5) !== 0) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const command = pick(SHELL_COMMANDS);
|
|
162
|
+
const total = secureRandomBetween(24, 80);
|
|
163
|
+
const output: string[] = [];
|
|
164
|
+
for (let index = 0; index < total; index += 1) {
|
|
165
|
+
if (command.startsWith('find')) {
|
|
166
|
+
output.push(
|
|
167
|
+
`./src/${pick(['app.tsx', 'history.ts', 'modalShell.tsx', 'responder.ts'])}:${secureRandomBetween(1, 200)}`,
|
|
168
|
+
);
|
|
169
|
+
} else if (command.startsWith('npm run test')) {
|
|
170
|
+
output.push(randomTestLine(index));
|
|
171
|
+
} else if (command === 'npm run lint') {
|
|
172
|
+
output.push(randomLintLine(index));
|
|
173
|
+
} else if (command === 'git status --short') {
|
|
174
|
+
output.push(
|
|
175
|
+
`${pick(['M', 'A', '??'])} ${pick(['src/app.tsx', 'src/responder.ts', 'src/modalShell.tsx'])}`,
|
|
176
|
+
);
|
|
177
|
+
} else {
|
|
178
|
+
output.push(randomLsLine(index));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { command, output, maxHeight: 20 };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function randomTestLine(index: number): string {
|
|
185
|
+
const parts = [
|
|
186
|
+
` PASS src/${pick(['app.test.ts', 'history.test.ts', 'suggestions.test.ts'])}`,
|
|
187
|
+
` ✓ scenario ${index + 1} ${pick(['(2 ms)', '(4 ms)', '(1 ms)'])}`,
|
|
188
|
+
` ✓ renders tool block ${index % 5} ${pick(['(snapshot)', '(dom)', '(cli)'])}`,
|
|
189
|
+
` ✓ streaming chunk ${index}`,
|
|
190
|
+
];
|
|
191
|
+
return pick(parts);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function randomLintLine(index: number): string {
|
|
195
|
+
return [
|
|
196
|
+
`src/${pick(['app.tsx', 'responder.ts', 'modalShell.tsx'])}:${secureRandomBetween(10, 200)}:${secureRandomBetween(2, 80)} warning ${pick(
|
|
197
|
+
[
|
|
198
|
+
'Unexpected console statement',
|
|
199
|
+
'Trailing spaces not allowed',
|
|
200
|
+
'Function has a complexity of 18',
|
|
201
|
+
],
|
|
202
|
+
)}`,
|
|
203
|
+
`✖ ${index + 1} problem (0 errors, ${secureRandomBetween(1, 2)} warnings)`,
|
|
204
|
+
][secureRandomBetween(0, 1)];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function randomLsLine(index: number): string {
|
|
208
|
+
const size = secureRandomBetween(1, 4096);
|
|
209
|
+
const name = pick([
|
|
210
|
+
'src',
|
|
211
|
+
'scripts',
|
|
212
|
+
'node_modules',
|
|
213
|
+
'README.md',
|
|
214
|
+
`file-${index}.ts`,
|
|
215
|
+
]);
|
|
216
|
+
return `-rw-r--r-- 1 user staff ${size.toString().padStart(6, ' ')} Dec 4 12:${(index % 60).toString().padStart(2, '0')} ${name}`;
|
|
217
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { TextareaRenderable } from '@vybestack/opentui-core';
|
|
2
|
+
import { useCallback, useState, useRef, type RefObject } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
extractMentionQuery,
|
|
5
|
+
findMentionRange,
|
|
6
|
+
getSuggestions,
|
|
7
|
+
MAX_SUGGESTION_COUNT,
|
|
8
|
+
} from './suggestions';
|
|
9
|
+
import { extractSlashContext, getSlashSuggestions } from './slash';
|
|
10
|
+
|
|
11
|
+
type CompletionMode = 'none' | 'mention' | 'slash';
|
|
12
|
+
|
|
13
|
+
export interface CompletionSuggestion {
|
|
14
|
+
value: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
insertText: string;
|
|
17
|
+
mode: Exclude<CompletionMode, 'none'>;
|
|
18
|
+
displayPrefix?: boolean;
|
|
19
|
+
hasChildren?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SLASH_SUGGESTION_LIMIT = 50;
|
|
23
|
+
|
|
24
|
+
export function useCompletionManager(
|
|
25
|
+
textareaRef: RefObject<TextareaRenderable | null>,
|
|
26
|
+
) {
|
|
27
|
+
const [suggestions, setSuggestions] = useState<CompletionSuggestion[]>([]);
|
|
28
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
29
|
+
const slashContext = useRef<{ start: number; end: number } | null>(null);
|
|
30
|
+
const reset = useCallback(() => {
|
|
31
|
+
slashContext.current = null;
|
|
32
|
+
setSuggestions([]);
|
|
33
|
+
setSelectedIndex(0);
|
|
34
|
+
}, []);
|
|
35
|
+
const refresh = useCallback(() => {
|
|
36
|
+
const editor = textareaRef.current;
|
|
37
|
+
if (editor == null) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const text = editor.plainText;
|
|
41
|
+
const cursor = editor.cursorOffset;
|
|
42
|
+
const slash = extractSlashContext(text, cursor);
|
|
43
|
+
if (slash) {
|
|
44
|
+
slashContext.current = { start: slash.start, end: slash.end };
|
|
45
|
+
setSuggestions(buildSlashCompletions(slash.parts));
|
|
46
|
+
setSelectedIndex(0);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const mention = extractMentionQuery(text, cursor);
|
|
50
|
+
if (mention !== null) {
|
|
51
|
+
slashContext.current = null;
|
|
52
|
+
setSuggestions(buildMentionCompletions(mention));
|
|
53
|
+
setSelectedIndex(0);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
reset();
|
|
57
|
+
}, [reset, textareaRef]);
|
|
58
|
+
const moveSelection = useCallback(
|
|
59
|
+
(delta: number) => {
|
|
60
|
+
if (suggestions.length === 0) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setSelectedIndex((prev) => {
|
|
64
|
+
const next = prev + delta;
|
|
65
|
+
if (next < 0) {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
if (next >= suggestions.length) {
|
|
69
|
+
return suggestions.length - 1;
|
|
70
|
+
}
|
|
71
|
+
return next;
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
[suggestions.length],
|
|
75
|
+
);
|
|
76
|
+
const applySelection = useCallback(() => {
|
|
77
|
+
const editor = textareaRef.current;
|
|
78
|
+
if (editor == null) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const current = suggestions.at(selectedIndex);
|
|
82
|
+
if (current == null) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (current.mode === 'mention') {
|
|
86
|
+
applyMentionCompletion(editor, current);
|
|
87
|
+
reset();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// current.mode === "slash" at this point
|
|
91
|
+
if (slashContext.current == null) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
applySlashCompletion(editor, slashContext.current, current);
|
|
95
|
+
slashContext.current = null;
|
|
96
|
+
if (current.hasChildren ?? false) {
|
|
97
|
+
refresh();
|
|
98
|
+
} else {
|
|
99
|
+
reset();
|
|
100
|
+
}
|
|
101
|
+
}, [refresh, reset, selectedIndex, suggestions, textareaRef]);
|
|
102
|
+
return {
|
|
103
|
+
suggestions,
|
|
104
|
+
selectedIndex,
|
|
105
|
+
refresh,
|
|
106
|
+
clear: reset,
|
|
107
|
+
moveSelection,
|
|
108
|
+
applySelection,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildSlashCompletions(parts: string[]): CompletionSuggestion[] {
|
|
113
|
+
const isRoot = parts.length <= 1;
|
|
114
|
+
return getSlashSuggestions(parts, SLASH_SUGGESTION_LIMIT).map(
|
|
115
|
+
(suggestion) => ({
|
|
116
|
+
value: suggestion.value,
|
|
117
|
+
description: suggestion.description,
|
|
118
|
+
insertText: suggestion.fullPath,
|
|
119
|
+
mode: 'slash' as const,
|
|
120
|
+
displayPrefix: isRoot,
|
|
121
|
+
hasChildren: suggestion.hasChildren,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildMentionCompletions(query: string): CompletionSuggestion[] {
|
|
127
|
+
return getSuggestions(query, MAX_SUGGESTION_COUNT).map((value) => ({
|
|
128
|
+
value,
|
|
129
|
+
insertText: value,
|
|
130
|
+
mode: 'mention' as const,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function applyMentionCompletion(
|
|
135
|
+
editor: TextareaRenderable,
|
|
136
|
+
suggestion: CompletionSuggestion,
|
|
137
|
+
): void {
|
|
138
|
+
const range = findMentionRange(editor.plainText, editor.cursorOffset);
|
|
139
|
+
if (!range) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const before = editor.plainText.slice(0, range.start);
|
|
143
|
+
const after = editor.plainText.slice(range.end);
|
|
144
|
+
const completion = `${suggestion.insertText} `;
|
|
145
|
+
const nextText = `${before}${completion}${after}`;
|
|
146
|
+
editor.setText(nextText);
|
|
147
|
+
editor.cursorOffset = (before + completion).length;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function applySlashCompletion(
|
|
151
|
+
editor: TextareaRenderable,
|
|
152
|
+
context: { start: number; end: number },
|
|
153
|
+
suggestion: CompletionSuggestion,
|
|
154
|
+
): void {
|
|
155
|
+
const before = editor.plainText.slice(0, context.start);
|
|
156
|
+
const after = editor.plainText.slice(context.end);
|
|
157
|
+
const completion = `${suggestion.insertText} `;
|
|
158
|
+
const nextText = `${before}${completion}${after}`;
|
|
159
|
+
editor.setText(nextText);
|
|
160
|
+
editor.cursorOffset = (before + completion).length;
|
|
161
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
extractSlashContext,
|
|
4
|
+
getSlashSuggestions,
|
|
5
|
+
setProfileSuggestions,
|
|
6
|
+
} from './slash';
|
|
7
|
+
|
|
8
|
+
describe('slash suggestions', () => {
|
|
9
|
+
it('lists root commands on bare slash', () => {
|
|
10
|
+
const suggestions = getSlashSuggestions([], 5);
|
|
11
|
+
expect(suggestions.length).toBeGreaterThan(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('prioritizes prefix match', () => {
|
|
15
|
+
const suggestions = getSlashSuggestions(['st'], 5);
|
|
16
|
+
expect(suggestions[0]?.value).toBe('stats');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns children for stats', () => {
|
|
20
|
+
const suggestions = getSlashSuggestions(['stats', 'm'], 5);
|
|
21
|
+
expect(suggestions.map((s) => s.value)).toContain('model');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('builds full path for stats child', () => {
|
|
25
|
+
const suggestion = getSlashSuggestions(['stats', 'mo'], 5).find(
|
|
26
|
+
(s) => s.value === 'model',
|
|
27
|
+
);
|
|
28
|
+
expect(suggestion?.fullPath).toBe('/stats model');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns children for set', () => {
|
|
32
|
+
const suggestions = getSlashSuggestions(['set', 'emo'], 5);
|
|
33
|
+
expect(suggestions[0]?.value).toBe('emojifilter');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('includes new config commands', () => {
|
|
37
|
+
const root = getSlashSuggestions(['b'], 10).map((s) => s.value);
|
|
38
|
+
expect(root).toContain('baseurl');
|
|
39
|
+
expect(getSlashSuggestions(['k'], 10).some((s) => s.value === 'key')).toBe(
|
|
40
|
+
true,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns profile load suggestions', () => {
|
|
45
|
+
setProfileSuggestions(['synthetic', 'custom']);
|
|
46
|
+
const level1 = getSlashSuggestions(['profile', 'l'], 5);
|
|
47
|
+
expect(level1.some((s) => s.value === 'load')).toBe(true);
|
|
48
|
+
const level2 = getSlashSuggestions(['profile', 'load', 's'], 5);
|
|
49
|
+
expect(level2.some((s) => s.value === 'synthetic')).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns grandchildren for set emojifilter', () => {
|
|
53
|
+
const suggestions = getSlashSuggestions(['set', 'emojifilter', 'a'], 5);
|
|
54
|
+
expect(suggestions.some((s) => s.value === 'auto')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('builds full path for deep set option', () => {
|
|
58
|
+
const suggestion = getSlashSuggestions(['set', 'emojifilter', 'a'], 5).find(
|
|
59
|
+
(s) => s.value === 'auto',
|
|
60
|
+
);
|
|
61
|
+
expect(suggestion?.fullPath).toBe('/set emojifilter auto');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns empty after completing leaf with trailing space', () => {
|
|
65
|
+
const suggestions = getSlashSuggestions(
|
|
66
|
+
['set', 'emojifilter', 'auto', ''],
|
|
67
|
+
5,
|
|
68
|
+
);
|
|
69
|
+
expect(suggestions).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('extractSlashContext', () => {
|
|
74
|
+
it('extracts at start of line', () => {
|
|
75
|
+
const ctx = extractSlashContext('/st', 3);
|
|
76
|
+
expect(ctx?.parts).toStrictEqual(['st']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns null when not preceded by space boundary', () => {
|
|
80
|
+
expect(extractSlashContext('test/st', 6)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
});
|