@adecore/agents-react 0.37.3 → 0.39.0
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/dist/agents/AgentModelLabel.d.ts +10 -0
- package/dist/agents/AgentModelLabel.js +97 -0
- package/dist/chat/logic/visual-height.d.ts +1 -0
- package/dist/chat/logic/visual-height.js +1 -1
- package/dist/chat/ui/Composer.js +385 -360
- package/dist/chat/ui/HostMenuItems.d.ts +4 -0
- package/dist/chat/ui/HostMenuItems.js +15 -0
- package/dist/chat/ui/Pickers.d.ts +2 -1
- package/dist/chat/ui/Pickers.js +126 -123
- package/dist/chat/ui/PromptEditor.d.ts +19 -0
- package/dist/chat/ui/PromptEditor.js +115 -0
- package/dist/chat/ui/Timeline.d.ts +2 -1
- package/dist/chat/ui/Timeline.js +13 -1
- package/dist/chat/ui/VisualFrame.d.ts +8 -0
- package/dist/chat/ui/VisualFrame.js +217 -115
- package/dist/chat/ui/composer/ComposerControls.d.ts +3 -0
- package/dist/chat/ui/composer/ComposerControls.js +235 -38
- package/dist/chat/ui/composer/key-handler.d.ts +1 -1
- package/dist/chat/ui/composer/key-handler.js +3 -11
- package/dist/chat/ui/composer/prompt-picks.d.ts +4 -0
- package/dist/chat/ui/composer/prompt-picks.js +14 -0
- package/dist/chat/ui/composer/use-composer-menus.d.ts +4 -49
- package/dist/chat/ui/composer/use-composer-menus.js +65 -519
- package/dist/chat/ui/composer/use-prompt-menus.d.ts +73 -0
- package/dist/chat/ui/composer/use-prompt-menus.js +536 -0
- package/dist/chat/ui/index.d.ts +4 -0
- package/dist/chat/ui/index.js +3 -0
- package/dist/chat/ui/rows/GeneratedImageRow.js +8 -4
- package/dist/chat/ui/rows/VisualRow.js +184 -142
- package/dist/chat/ui/rows/row-rhythm.js +6 -4
- package/dist/host-hooks.d.ts +2 -1
- package/dist/host-hooks.js +6 -0
- package/dist/host.d.ts +27 -1
- package/dist/index.d.ts +1 -1
- package/dist/locales/en/agent-chat.d.ts +1 -0
- package/dist/locales/en/agent-chat.js +1 -0
- package/dist/locales/nl/agent-chat.d.ts +1 -0
- package/dist/locales/nl/agent-chat.js +1 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/theme.css +3 -0
- package/package.json +8 -8
- package/src/agents/AgentModelLabel.tsx +40 -0
- package/src/chat/logic/visual-height.ts +1 -1
- package/src/chat/ui/Composer.tsx +12 -3
- package/src/chat/ui/HostMenuItems.tsx +11 -0
- package/src/chat/ui/Pickers.tsx +4 -1
- package/src/chat/ui/PromptEditor.tsx +130 -0
- package/src/chat/ui/Timeline.tsx +22 -1
- package/src/chat/ui/VisualFrame.tsx +68 -21
- package/src/chat/ui/composer/ComposerControls.tsx +65 -15
- package/src/chat/ui/composer/key-handler.ts +3 -9
- package/src/chat/ui/composer/prompt-picks.ts +17 -0
- package/src/chat/ui/composer/use-composer-menus.ts +18 -275
- package/src/chat/ui/composer/use-prompt-menus.ts +341 -0
- package/src/chat/ui/index.ts +4 -0
- package/src/chat/ui/rows/GeneratedImageRow.tsx +9 -4
- package/src/chat/ui/rows/VisualRow.tsx +5 -1
- package/src/chat/ui/rows/row-rhythm.ts +6 -4
- package/src/host-hooks.ts +9 -1
- package/src/host.ts +45 -1
- package/src/index.ts +3 -0
- package/src/locales/en/agent-chat.ts +1 -0
- package/src/locales/nl/agent-chat.ts +1 -0
- package/src/providers/index.ts +1 -0
- package/src/theme.css +3 -0
|
@@ -1,48 +1,17 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useState, type RefObject } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
|
-
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
|
|
4
|
-
import type { EditorState } from '@codemirror/state';
|
|
5
3
|
import type { ChatInfo, ChatSkill, ProviderCapabilities } from '@adecore/agent-contracts';
|
|
6
|
-
import { useChatHost } from '../../../host';
|
|
7
4
|
import { useChatScope } from '../../../scope';
|
|
8
|
-
import { referenceSuggestions, referenceTokens, type Referable } from '../../chat-references';
|
|
9
|
-
import { usableSlashCommands } from '../../guards';
|
|
10
|
-
import {
|
|
11
|
-
chipRanges,
|
|
12
|
-
findMentionQuery,
|
|
13
|
-
findReferenceQuery,
|
|
14
|
-
findSkillQuery,
|
|
15
|
-
insertMention,
|
|
16
|
-
insertReference,
|
|
17
|
-
insertSkill,
|
|
18
|
-
mentionPick,
|
|
19
|
-
NO_MENTION_SEARCH,
|
|
20
|
-
queryOutsideChips,
|
|
21
|
-
type MentionQuery,
|
|
22
|
-
type MentionSearch,
|
|
23
|
-
type TextRange
|
|
24
|
-
} from '../../mentions';
|
|
25
5
|
import type { ComposerInputHandle } from '../ComposerInput';
|
|
26
6
|
import type { ComposerDraft } from './use-composer-draft';
|
|
27
|
-
import {
|
|
28
|
-
import { clampMenuIndex, composerMenuAria } from './menu-aria';
|
|
7
|
+
import { usePromptMenus, type PromptMenus } from './use-prompt-menus';
|
|
29
8
|
import { useHostReferables } from '../../../host-hooks';
|
|
30
9
|
|
|
31
|
-
|
|
32
|
-
// What fits above the composer without turning the picker into a file tree.
|
|
33
|
-
const MENTION_RESULTS = 8;
|
|
34
|
-
const REFERENCE_RESULTS = 8;
|
|
10
|
+
export type { ComposerCommand } from './use-prompt-menus';
|
|
35
11
|
|
|
36
12
|
// Commands the composer handles itself; the CLI's own ones are sent through as text.
|
|
37
13
|
export const LOCAL_COMMANDS = ['model', 'compact', 'clear'];
|
|
38
14
|
|
|
39
|
-
export interface ComposerCommand {
|
|
40
|
-
name: string;
|
|
41
|
-
hint: string;
|
|
42
|
-
local: boolean;
|
|
43
|
-
skill: boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
15
|
interface MenusInput {
|
|
47
16
|
chatId: string;
|
|
48
17
|
info: ChatInfo;
|
|
@@ -52,62 +21,12 @@ interface MenusInput {
|
|
|
52
21
|
inputRef: RefObject<ComposerInputHandle | null>;
|
|
53
22
|
}
|
|
54
23
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/*
|
|
60
|
-
* The pickers over the box: `/` for a command, `$` for a skill, `@` for a file and `#` for a view,
|
|
61
|
-
* a node or another chat. One is open at a time, and the arrow keys and the editor's ARIA attributes
|
|
62
|
-
* follow that one.
|
|
63
|
-
*/
|
|
64
|
-
export function useComposerMenus({ chatId, info, capabilities, providerName, draft: { draft, setDraft, setText }, inputRef }: MenusInput) {
|
|
24
|
+
/* The pickers over a chat's composer, fed by what the chat's CLI has: its skills, its commands and its folder. */
|
|
25
|
+
export function useComposerMenus({ chatId, info, capabilities, providerName, draft, inputRef }: MenusInput): PromptMenus {
|
|
65
26
|
const { t } = useTranslation('agent-chat');
|
|
66
27
|
const scope = useChatScope();
|
|
67
|
-
const host = useChatHost();
|
|
68
|
-
const { searchFiles } = host;
|
|
69
28
|
const referables = useHostReferables(scope.id, chatId);
|
|
70
|
-
const [menuIndex, setMenuIndex] = useState(0);
|
|
71
|
-
const [mention, setMention] = useState<MentionQuery | null>(null);
|
|
72
|
-
const [referenceQuery, setReferenceQuery] = useState<MentionQuery | null>(null);
|
|
73
|
-
const [skillQuery, setSkillQuery] = useState<MentionQuery | null>(null);
|
|
74
29
|
const [skills, setSkills] = useState<ChatSkill[]>([]);
|
|
75
|
-
const [searched, setSearched] = useState<MentionSearch>(NO_MENTION_SEARCH);
|
|
76
|
-
const text = draft.text;
|
|
77
|
-
const goals = capabilities?.goals;
|
|
78
|
-
|
|
79
|
-
// Every keystroke inside an `@word` searches again; a slow answer must not overwrite a newer one.
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
if (mention === null) {
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
let stale = false;
|
|
85
|
-
const search = (): void => {
|
|
86
|
-
if (searchFiles === null) {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
searchFiles(scope.id, info.cwd, mention.query, MENTION_RESULTS)
|
|
90
|
-
.then((files) => {
|
|
91
|
-
if (!stale) {
|
|
92
|
-
setSearched({ query: mention.query, files });
|
|
93
|
-
setMenuIndex(0);
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
.catch(() => undefined);
|
|
97
|
-
};
|
|
98
|
-
// A bare `@` is not a keystroke in a query. It opens the list of what is there, at once.
|
|
99
|
-
if (mention.query === '') {
|
|
100
|
-
search();
|
|
101
|
-
return () => {
|
|
102
|
-
stale = true;
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
const timer = setTimeout(search, SEARCH_DEBOUNCE_MS);
|
|
106
|
-
return () => {
|
|
107
|
-
stale = true;
|
|
108
|
-
clearTimeout(timer);
|
|
109
|
-
};
|
|
110
|
-
}, [scope, info.cwd, mention, searchFiles]);
|
|
111
30
|
|
|
112
31
|
/*
|
|
113
32
|
* The host's skill list, once per chat, again once the CLI announced its own after the first message,
|
|
@@ -128,196 +47,20 @@ export function useComposerMenus({ chatId, info, capabilities, providerName, dra
|
|
|
128
47
|
};
|
|
129
48
|
}, [scope, chatId, info.skills, info.running]);
|
|
130
49
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return [];
|
|
135
|
-
}
|
|
136
|
-
const known = new Set(skills.map((skill) => skill.name));
|
|
137
|
-
const own = LOCAL_COMMANDS.map((name) => ({ name, hint: t(`composer.commands.${name}`), local: true, skill: false }));
|
|
138
|
-
// Every CLI with goals reads `/goal`, also one that lists no commands of its own.
|
|
139
|
-
const goal = goals === undefined ? [] : [{ name: 'goal', hint: t('composer.commands.goal'), local: false, skill: false }];
|
|
140
|
-
const cli = usableSlashCommands(info.slashCommands)
|
|
141
|
-
.filter((name) => !LOCAL_COMMANDS.includes(name) && (goals === undefined || name !== 'goal'))
|
|
142
|
-
.map((name) => ({
|
|
143
|
-
name,
|
|
144
|
-
hint: known.has(name) ? t('composer.commands.skill') : t('composer.commands.cli', { cli: providerName ?? t('composer.commands.anyCli') }),
|
|
145
|
-
local: false,
|
|
146
|
-
skill: known.has(name)
|
|
147
|
-
}));
|
|
148
|
-
return [...own, ...goal, ...cli].filter((command) => command.name.startsWith(commandQuery)).slice(0, 8);
|
|
149
|
-
}, [commandQuery, goals, info.slashCommands, providerName, skills, t]);
|
|
150
|
-
|
|
151
|
-
const skillMatches = useMemo(() => {
|
|
152
|
-
if (skillQuery === null) {
|
|
153
|
-
return [];
|
|
154
|
-
}
|
|
155
|
-
const query = skillQuery.query.toLowerCase();
|
|
156
|
-
return skills.filter((skill) => skill.name.toLowerCase().includes(query)).slice(0, 8);
|
|
157
|
-
}, [skillQuery, skills]);
|
|
158
|
-
|
|
159
|
-
const commandMenuOpen = commandQuery !== null && commands.length > 0;
|
|
160
|
-
const skillMenuOpen = !commandMenuOpen && skillQuery !== null && skillMatches.length > 0;
|
|
161
|
-
const mentionMenuOpen = !commandMenuOpen && !skillMenuOpen && mention !== null;
|
|
162
|
-
// Results belong to the query that asked for them; a closed picker shows none while the next answer is on its way.
|
|
163
|
-
const files = mention === null ? [] : searched.files;
|
|
164
|
-
const mentionCount = files.length;
|
|
165
|
-
// A host with nothing to point at keeps `#fff` out of the way rather than saying nothing matches.
|
|
166
|
-
const offersReferences = referables.items.some((item) => !referables.chatsOnly || item.kind === 'chat');
|
|
167
|
-
const referenceMenuOpen = !commandMenuOpen && !skillMenuOpen && !mentionMenuOpen && referenceQuery !== null && offersReferences;
|
|
168
|
-
const referenceMatches = referenceMenuOpen
|
|
169
|
-
? referenceSuggestions(referables, referenceQuery.query, [chatId, ...draft.chats, ...draft.references], REFERENCE_RESULTS)
|
|
170
|
-
: [];
|
|
171
|
-
// What the editor draws as chips and the send checks are still in the text, under the name each was picked with.
|
|
172
|
-
const referenceChips = referenceTokens([...draft.chats, ...draft.references], referables.items, draft.referenceNames).map((token) => ({
|
|
173
|
-
...token,
|
|
174
|
-
kind: referables.items.find((item) => item.id === token.id)?.kind ?? null
|
|
175
|
-
}));
|
|
176
|
-
// One menu is open at a time, so they share an id.
|
|
177
|
-
const listId = useId();
|
|
178
|
-
const menuOpen = commandMenuOpen || skillMenuOpen || mentionMenuOpen || referenceMenuOpen;
|
|
179
|
-
const menuCount = commandMenuOpen ? commands.length : skillMenuOpen ? skillMatches.length : mentionMenuOpen ? mentionCount : referenceMatches.length;
|
|
180
|
-
// The list can shrink under a stale index; every reader takes the one that still points at an option.
|
|
181
|
-
const activeIndex = clampMenuIndex(menuIndex, menuCount);
|
|
182
|
-
const inputAria = composerMenuAria({ label: t('composer.inputLabel'), listId, open: menuOpen, activeIndex, count: menuCount });
|
|
183
|
-
|
|
184
|
-
/* Which picker the caret opens: `@` for a file, `$` for a skill, `#` for a reference, or none. In code a sigil is just text. */
|
|
185
|
-
const trackTriggers = (value: string, selection: TextRange, state: EditorState): void => {
|
|
186
|
-
const empty = selection.from === selection.to;
|
|
187
|
-
const tree = empty ? (ensureSyntaxTree(state, selection.from, 50) ?? syntaxTree(state)) : null;
|
|
188
|
-
const caret = tree !== null && !inCode(tree, value, selection.from) ? selection.from : null;
|
|
189
|
-
const chips = caret === null ? [] : chipRanges(value, draft.mentions, draft.skills, [], referenceChips);
|
|
190
|
-
const skill = caret === null ? null : queryOutsideChips(findSkillQuery(value, caret), chips);
|
|
191
|
-
setSkillQuery((current) => (sameQuery(current, skill) ? current : skill));
|
|
50
|
+
return usePromptMenus({
|
|
51
|
+
draft,
|
|
52
|
+
inputRef,
|
|
192
53
|
// A CLI that does not expand `@path` gets the text as it is, so the picker stays out of the way.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
// The inserted text reaches the editor with the render it causes, so the caret waits a frame for it.
|
|
200
|
-
const moveCaret = (caret: number): void => {
|
|
201
|
-
requestAnimationFrame(() => inputRef.current?.setCaret(caret));
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
const close = (): void => {
|
|
205
|
-
setMention(null);
|
|
206
|
-
setSkillQuery(null);
|
|
207
|
-
setReferenceQuery(null);
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const chooseMention = (path: string): void => {
|
|
211
|
-
if (!mention) {
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
const result = insertMention(text, mention, path);
|
|
215
|
-
setText(result.text, draft.mentions.includes(path) ? draft.mentions : [...draft.mentions, path]);
|
|
216
|
-
setMention(null);
|
|
217
|
-
moveCaret(result.caret);
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const chooseMentionAt = (index: number): void => {
|
|
221
|
-
const path = mention === null ? null : mentionPick(index, searched, mention.query);
|
|
222
|
-
if (path !== null) {
|
|
223
|
-
chooseMention(path);
|
|
224
|
-
}
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
/*
|
|
228
|
-
* A picked view, node or chat becomes a `#name` chip in the text. The draft keeps the name it was
|
|
229
|
-
* spelled with, since a rename would otherwise unmake the chip. A chat goes apart, as `chats`.
|
|
230
|
-
*/
|
|
231
|
-
const chooseReference = (item: Referable): void => {
|
|
232
|
-
if (!referenceQuery) {
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
const result = insertReference(text, referenceQuery, item.name);
|
|
236
|
-
const chat = item.kind === 'chat';
|
|
237
|
-
const add = (ids: string[]): string[] => (ids.includes(item.id) ? ids : [...ids, item.id]);
|
|
238
|
-
setDraft((current) => ({
|
|
239
|
-
...current,
|
|
240
|
-
text: result.text,
|
|
241
|
-
chats: chat ? add(current.chats) : current.chats,
|
|
242
|
-
references: chat ? current.references : add(current.references),
|
|
243
|
-
referenceNames: { ...current.referenceNames, [item.id]: item.name }
|
|
244
|
-
}));
|
|
245
|
-
setReferenceQuery(null);
|
|
246
|
-
moveCaret(result.caret);
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const chooseReferenceAt = (index: number): void => {
|
|
250
|
-
const item = referenceMatches[index] ?? referenceMatches[0];
|
|
251
|
-
if (item !== undefined) {
|
|
252
|
-
chooseReference(item);
|
|
253
|
-
}
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
/*
|
|
257
|
-
* A picked skill becomes a `$name` chip in the text and a name on the send, which is what
|
|
258
|
-
* dispatches it. The `/` menu lands here too, so both spellings end up on the same path.
|
|
259
|
-
*/
|
|
260
|
-
const chooseSkill = (name: string): void => {
|
|
261
|
-
const result = skillQuery ? insertSkill(text, skillQuery, name) : { text: `$${name} `, caret: name.length + 2 };
|
|
262
|
-
setText(result.text, draft.mentions, draft.skills.includes(name) ? draft.skills : [...draft.skills, name]);
|
|
263
|
-
setSkillQuery(null);
|
|
264
|
-
moveCaret(result.caret);
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
/* A clicked command; Tab completes one with a space after it instead. */
|
|
268
|
-
const pickCommand = (command: ComposerCommand, index: number): void => {
|
|
269
|
-
setMenuIndex(index);
|
|
270
|
-
if (command.skill) {
|
|
271
|
-
chooseSkill(command.name);
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
setText(`/${command.name}`);
|
|
275
|
-
inputRef.current?.focus();
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
/* The arrows walk an open picker round; true when the key was one of them. */
|
|
279
|
-
const moveInMenu = (key: string, count: number): boolean => {
|
|
280
|
-
if (key === 'ArrowDown') {
|
|
281
|
-
setMenuIndex((i) => (i + 1) % count);
|
|
282
|
-
return true;
|
|
283
|
-
}
|
|
284
|
-
if (key === 'ArrowUp') {
|
|
285
|
-
setMenuIndex((i) => (i - 1 + count) % count);
|
|
286
|
-
return true;
|
|
287
|
-
}
|
|
288
|
-
return false;
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
return {
|
|
292
|
-
listId,
|
|
293
|
-
activeIndex,
|
|
294
|
-
inputAria,
|
|
295
|
-
hasSkills: skills.length > 0,
|
|
296
|
-
skillNames: skills.map((skill) => skill.name),
|
|
297
|
-
commandQuery,
|
|
298
|
-
commands,
|
|
299
|
-
commandMenuOpen,
|
|
300
|
-
skillMatches,
|
|
301
|
-
skillMenuOpen,
|
|
302
|
-
mention,
|
|
303
|
-
mentionMenuOpen,
|
|
304
|
-
mentionCount,
|
|
305
|
-
files,
|
|
306
|
-
referenceMenuOpen,
|
|
307
|
-
referenceMatches,
|
|
54
|
+
cwd: capabilities?.mentions === false ? null : info.cwd,
|
|
55
|
+
skills,
|
|
56
|
+
slashCommands: info.slashCommands,
|
|
57
|
+
localCommands: LOCAL_COMMANDS,
|
|
58
|
+
goals: capabilities?.goals !== undefined,
|
|
59
|
+
providerName,
|
|
308
60
|
referables,
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
close,
|
|
313
|
-
chooseMention,
|
|
314
|
-
chooseMentionAt,
|
|
315
|
-
chooseReference,
|
|
316
|
-
chooseReferenceAt,
|
|
317
|
-
chooseSkill,
|
|
318
|
-
pickCommand,
|
|
319
|
-
moveInMenu
|
|
320
|
-
};
|
|
61
|
+
ownId: chatId,
|
|
62
|
+
label: t('composer.inputLabel')
|
|
63
|
+
});
|
|
321
64
|
}
|
|
322
65
|
|
|
323
|
-
export type ComposerMenus =
|
|
66
|
+
export type ComposerMenus = PromptMenus;
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { useEffect, useId, useMemo, useState, type Dispatch, type RefObject, type SetStateAction } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
|
|
4
|
+
import type { EditorState } from '@codemirror/state';
|
|
5
|
+
import type { ChatSkill } from '@adecore/agent-contracts';
|
|
6
|
+
import { useChatHost } from '../../../host';
|
|
7
|
+
import { useChatScope } from '../../../scope';
|
|
8
|
+
import { referenceSuggestions, referenceTokens, type Referable, type Referables } from '../../chat-references';
|
|
9
|
+
import type { ChatDraft } from '../../drafts';
|
|
10
|
+
import { usableSlashCommands } from '../../guards';
|
|
11
|
+
import {
|
|
12
|
+
chipRanges,
|
|
13
|
+
findMentionQuery,
|
|
14
|
+
findReferenceQuery,
|
|
15
|
+
findSkillQuery,
|
|
16
|
+
insertMention,
|
|
17
|
+
insertReference,
|
|
18
|
+
insertSkill,
|
|
19
|
+
mentionPick,
|
|
20
|
+
NO_MENTION_SEARCH,
|
|
21
|
+
queryOutsideChips,
|
|
22
|
+
type MentionQuery,
|
|
23
|
+
type MentionSearch,
|
|
24
|
+
type TextRange
|
|
25
|
+
} from '../../mentions';
|
|
26
|
+
import type { ComposerInputHandle } from '../ComposerInput';
|
|
27
|
+
import { inCode } from './keys';
|
|
28
|
+
import { clampMenuIndex, composerMenuAria } from './menu-aria';
|
|
29
|
+
|
|
30
|
+
const SEARCH_DEBOUNCE_MS = 80;
|
|
31
|
+
// What fits above the box without turning the picker into a file tree.
|
|
32
|
+
const MENTION_RESULTS = 8;
|
|
33
|
+
const REFERENCE_RESULTS = 8;
|
|
34
|
+
|
|
35
|
+
/* The text of a prompt and what the person picked in it with `@`, `$` and `#`. */
|
|
36
|
+
export type PromptDraft = Pick<ChatDraft, 'text' | 'mentions' | 'skills' | 'chats' | 'references' | 'referenceNames'>;
|
|
37
|
+
|
|
38
|
+
export interface PromptDraftBox<D extends PromptDraft> {
|
|
39
|
+
draft: D;
|
|
40
|
+
setDraft: Dispatch<SetStateAction<D>>;
|
|
41
|
+
/* The mentions and skills default to the draft's own as last rendered. */
|
|
42
|
+
setText(next: string, mentions?: string[], skills?: string[]): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ComposerCommand {
|
|
46
|
+
name: string;
|
|
47
|
+
hint: string;
|
|
48
|
+
local: boolean;
|
|
49
|
+
skill: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PromptMenusInput<D extends PromptDraft> {
|
|
53
|
+
draft: PromptDraftBox<D>;
|
|
54
|
+
inputRef: RefObject<ComposerInputHandle | null>;
|
|
55
|
+
/* Where `@` searches for files; null offers none. */
|
|
56
|
+
cwd: string | null;
|
|
57
|
+
skills: readonly ChatSkill[];
|
|
58
|
+
/* What the CLI said it answers after `/`. */
|
|
59
|
+
slashCommands: readonly string[];
|
|
60
|
+
/* Commands the box runs itself, named in `composer.commands`; the rest go to the CLI as text. */
|
|
61
|
+
localCommands?: readonly string[];
|
|
62
|
+
/* The CLI reads `/goal`. */
|
|
63
|
+
goals: boolean;
|
|
64
|
+
providerName: string | undefined;
|
|
65
|
+
referables: Referables;
|
|
66
|
+
/* What `#` never offers, such as the chat the box writes in. */
|
|
67
|
+
ownId?: string;
|
|
68
|
+
/* The editor's accessible name. */
|
|
69
|
+
label: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const NO_COMMANDS: readonly string[] = [];
|
|
73
|
+
|
|
74
|
+
function sameQuery(left: MentionQuery | null, right: MentionQuery | null): boolean {
|
|
75
|
+
return left?.start === right?.start && left?.query === right?.query;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
* The pickers over a prompt: `/` for a command, `$` for a skill, `@` for a file and `#` for a view,
|
|
80
|
+
* a node or a chat. One is open at a time, and the arrow keys and the editor's ARIA attributes
|
|
81
|
+
* follow that one.
|
|
82
|
+
*/
|
|
83
|
+
export function usePromptMenus<D extends PromptDraft>({
|
|
84
|
+
draft: { draft, setDraft, setText },
|
|
85
|
+
inputRef,
|
|
86
|
+
cwd,
|
|
87
|
+
skills,
|
|
88
|
+
slashCommands,
|
|
89
|
+
localCommands = NO_COMMANDS,
|
|
90
|
+
goals,
|
|
91
|
+
providerName,
|
|
92
|
+
referables,
|
|
93
|
+
ownId,
|
|
94
|
+
label
|
|
95
|
+
}: PromptMenusInput<D>) {
|
|
96
|
+
const { t } = useTranslation('agent-chat');
|
|
97
|
+
const scope = useChatScope();
|
|
98
|
+
const { searchFiles } = useChatHost();
|
|
99
|
+
const [menuIndex, setMenuIndex] = useState(0);
|
|
100
|
+
const [mention, setMention] = useState<MentionQuery | null>(null);
|
|
101
|
+
const [referenceQuery, setReferenceQuery] = useState<MentionQuery | null>(null);
|
|
102
|
+
const [skillQuery, setSkillQuery] = useState<MentionQuery | null>(null);
|
|
103
|
+
const [searched, setSearched] = useState<MentionSearch>(NO_MENTION_SEARCH);
|
|
104
|
+
const text = draft.text;
|
|
105
|
+
|
|
106
|
+
// Every keystroke inside an `@word` searches again; a slow answer must not overwrite a newer one.
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (mention === null || cwd === null) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let stale = false;
|
|
112
|
+
const search = (): void => {
|
|
113
|
+
if (searchFiles === null) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
searchFiles(scope.id, cwd, mention.query, MENTION_RESULTS)
|
|
117
|
+
.then((files) => {
|
|
118
|
+
if (!stale) {
|
|
119
|
+
setSearched({ query: mention.query, files });
|
|
120
|
+
setMenuIndex(0);
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
.catch(() => undefined);
|
|
124
|
+
};
|
|
125
|
+
// A bare `@` is not a keystroke in a query. It opens the list of what is there, at once.
|
|
126
|
+
if (mention.query === '') {
|
|
127
|
+
search();
|
|
128
|
+
return () => {
|
|
129
|
+
stale = true;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const timer = setTimeout(search, SEARCH_DEBOUNCE_MS);
|
|
133
|
+
return () => {
|
|
134
|
+
stale = true;
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
};
|
|
137
|
+
}, [scope, cwd, mention, searchFiles]);
|
|
138
|
+
|
|
139
|
+
const commandQuery = text.startsWith('/') && !text.includes('\n') ? text.slice(1).trim().toLowerCase() : null;
|
|
140
|
+
const commands = useMemo((): ComposerCommand[] => {
|
|
141
|
+
if (commandQuery === null) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
const known = new Set(skills.map((skill) => skill.name));
|
|
145
|
+
const own = localCommands.map((name) => ({ name, hint: t(`composer.commands.${name}`), local: true, skill: false }));
|
|
146
|
+
// Every CLI with goals reads `/goal`, also one that lists no commands of its own.
|
|
147
|
+
const goal = goals ? [{ name: 'goal', hint: t('composer.commands.goal'), local: false, skill: false }] : [];
|
|
148
|
+
const cli = usableSlashCommands([...slashCommands])
|
|
149
|
+
.filter((name) => !localCommands.includes(name) && (!goals || name !== 'goal'))
|
|
150
|
+
.map((name) => ({
|
|
151
|
+
name,
|
|
152
|
+
hint: known.has(name) ? t('composer.commands.skill') : t('composer.commands.cli', { cli: providerName ?? t('composer.commands.anyCli') }),
|
|
153
|
+
local: false,
|
|
154
|
+
skill: known.has(name)
|
|
155
|
+
}));
|
|
156
|
+
return [...own, ...goal, ...cli].filter((command) => command.name.startsWith(commandQuery)).slice(0, 8);
|
|
157
|
+
}, [commandQuery, goals, localCommands, slashCommands, providerName, skills, t]);
|
|
158
|
+
|
|
159
|
+
const skillMatches = useMemo(() => {
|
|
160
|
+
if (skillQuery === null) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
const query = skillQuery.query.toLowerCase();
|
|
164
|
+
return skills.filter((skill) => skill.name.toLowerCase().includes(query)).slice(0, 8);
|
|
165
|
+
}, [skillQuery, skills]);
|
|
166
|
+
|
|
167
|
+
const commandMenuOpen = commandQuery !== null && commands.length > 0;
|
|
168
|
+
const skillMenuOpen = !commandMenuOpen && skillQuery !== null && skillMatches.length > 0;
|
|
169
|
+
const mentionMenuOpen = !commandMenuOpen && !skillMenuOpen && mention !== null;
|
|
170
|
+
// Results belong to the query that asked for them; a closed picker shows none while the next answer is on its way.
|
|
171
|
+
const files = mention === null ? [] : searched.files;
|
|
172
|
+
const mentionCount = files.length;
|
|
173
|
+
// A host with nothing to point at keeps `#fff` out of the way rather than saying nothing matches.
|
|
174
|
+
const offersReferences = referables.items.some((item) => !referables.chatsOnly || item.kind === 'chat');
|
|
175
|
+
const referenceMenuOpen = !commandMenuOpen && !skillMenuOpen && !mentionMenuOpen && referenceQuery !== null && offersReferences;
|
|
176
|
+
const excluded = ownId === undefined ? [...draft.chats, ...draft.references] : [ownId, ...draft.chats, ...draft.references];
|
|
177
|
+
const referenceMatches = referenceMenuOpen ? referenceSuggestions(referables, referenceQuery.query, excluded, REFERENCE_RESULTS) : [];
|
|
178
|
+
// What the editor draws as chips and the send checks are still in the text, under the name each was picked with.
|
|
179
|
+
const referenceChips = referenceTokens([...draft.chats, ...draft.references], referables.items, draft.referenceNames).map((token) => ({
|
|
180
|
+
...token,
|
|
181
|
+
kind: referables.items.find((item) => item.id === token.id)?.kind ?? null
|
|
182
|
+
}));
|
|
183
|
+
// One menu is open at a time, so they share an id.
|
|
184
|
+
const listId = useId();
|
|
185
|
+
const menuOpen = commandMenuOpen || skillMenuOpen || mentionMenuOpen || referenceMenuOpen;
|
|
186
|
+
const menuCount = commandMenuOpen ? commands.length : skillMenuOpen ? skillMatches.length : mentionMenuOpen ? mentionCount : referenceMatches.length;
|
|
187
|
+
// The list can shrink under a stale index; every reader takes the one that still points at an option.
|
|
188
|
+
const activeIndex = clampMenuIndex(menuIndex, menuCount);
|
|
189
|
+
const inputAria = composerMenuAria({ label, listId, open: menuOpen, activeIndex, count: menuCount });
|
|
190
|
+
|
|
191
|
+
/* Which picker the caret opens: `@` for a file, `$` for a skill, `#` for a reference, or none. In code a sigil is just text. */
|
|
192
|
+
const trackTriggers = (value: string, selection: TextRange, state: EditorState): void => {
|
|
193
|
+
const empty = selection.from === selection.to;
|
|
194
|
+
const tree = empty ? (ensureSyntaxTree(state, selection.from, 50) ?? syntaxTree(state)) : null;
|
|
195
|
+
const caret = tree !== null && !inCode(tree, value, selection.from) ? selection.from : null;
|
|
196
|
+
const chips = caret === null ? [] : chipRanges(value, draft.mentions, draft.skills, [], referenceChips);
|
|
197
|
+
const skill = caret === null ? null : queryOutsideChips(findSkillQuery(value, caret), chips);
|
|
198
|
+
setSkillQuery((current) => (sameQuery(current, skill) ? current : skill));
|
|
199
|
+
// A CLI that does not expand `@path` gets the text as it is, so the picker stays out of the way.
|
|
200
|
+
const next = caret === null || cwd === null ? null : queryOutsideChips(findMentionQuery(value, caret), chips);
|
|
201
|
+
setMention((current) => (sameQuery(current, next) ? current : next));
|
|
202
|
+
const reference = caret === null ? null : queryOutsideChips(findReferenceQuery(value, caret), chips);
|
|
203
|
+
setReferenceQuery((current) => (sameQuery(current, reference) ? current : reference));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// The inserted text reaches the editor with the render it causes, so the caret waits a frame for it.
|
|
207
|
+
const moveCaret = (caret: number): void => {
|
|
208
|
+
requestAnimationFrame(() => inputRef.current?.setCaret(caret));
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const close = (): void => {
|
|
212
|
+
setMention(null);
|
|
213
|
+
setSkillQuery(null);
|
|
214
|
+
setReferenceQuery(null);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const chooseMention = (path: string): void => {
|
|
218
|
+
if (!mention) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const result = insertMention(text, mention, path);
|
|
222
|
+
setText(result.text, draft.mentions.includes(path) ? draft.mentions : [...draft.mentions, path]);
|
|
223
|
+
setMention(null);
|
|
224
|
+
moveCaret(result.caret);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const chooseMentionAt = (index: number): void => {
|
|
228
|
+
const path = mention === null ? null : mentionPick(index, searched, mention.query);
|
|
229
|
+
if (path !== null) {
|
|
230
|
+
chooseMention(path);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/*
|
|
235
|
+
* A picked view, node or chat becomes a `#name` chip in the text. The draft keeps the name it was
|
|
236
|
+
* spelled with, since a rename would otherwise unmake the chip. A chat goes apart, as `chats`.
|
|
237
|
+
*/
|
|
238
|
+
const chooseReference = (item: Referable): void => {
|
|
239
|
+
if (!referenceQuery) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const result = insertReference(text, referenceQuery, item.name);
|
|
243
|
+
const chat = item.kind === 'chat';
|
|
244
|
+
const add = (ids: string[]): string[] => (ids.includes(item.id) ? ids : [...ids, item.id]);
|
|
245
|
+
setDraft((current) => ({
|
|
246
|
+
...current,
|
|
247
|
+
text: result.text,
|
|
248
|
+
chats: chat ? add(current.chats) : current.chats,
|
|
249
|
+
references: chat ? current.references : add(current.references),
|
|
250
|
+
referenceNames: { ...current.referenceNames, [item.id]: item.name }
|
|
251
|
+
}));
|
|
252
|
+
setReferenceQuery(null);
|
|
253
|
+
moveCaret(result.caret);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const chooseReferenceAt = (index: number): void => {
|
|
257
|
+
const item = referenceMatches[index] ?? referenceMatches[0];
|
|
258
|
+
if (item !== undefined) {
|
|
259
|
+
chooseReference(item);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/*
|
|
264
|
+
* A picked skill becomes a `$name` chip in the text and a name on the send, which is what
|
|
265
|
+
* dispatches it. The `/` menu lands here too, so both spellings end up on the same path.
|
|
266
|
+
*/
|
|
267
|
+
const chooseSkill = (name: string): void => {
|
|
268
|
+
const result = skillQuery ? insertSkill(text, skillQuery, name) : { text: `$${name} `, caret: name.length + 2 };
|
|
269
|
+
setText(result.text, draft.mentions, draft.skills.includes(name) ? draft.skills : [...draft.skills, name]);
|
|
270
|
+
setSkillQuery(null);
|
|
271
|
+
moveCaret(result.caret);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/* A clicked command; Tab completes one with a space after it instead. */
|
|
275
|
+
const pickCommand = (command: ComposerCommand, index: number): void => {
|
|
276
|
+
setMenuIndex(index);
|
|
277
|
+
if (command.skill) {
|
|
278
|
+
chooseSkill(command.name);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
setText(`/${command.name}`);
|
|
282
|
+
inputRef.current?.focus();
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/* Tab on a command: a skill becomes its chip, any other is written out with a space for its arguments. */
|
|
286
|
+
const completeCommand = (command: ComposerCommand): void => {
|
|
287
|
+
if (command.skill) {
|
|
288
|
+
chooseSkill(command.name);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
setText(`/${command.name} `);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/* The arrows walk an open picker round; true when the key was one of them. */
|
|
295
|
+
const moveInMenu = (key: string, count: number): boolean => {
|
|
296
|
+
if (key === 'ArrowDown') {
|
|
297
|
+
setMenuIndex((i) => (i + 1) % count);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
if (key === 'ArrowUp') {
|
|
301
|
+
setMenuIndex((i) => (i - 1 + count) % count);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
listId,
|
|
309
|
+
activeIndex,
|
|
310
|
+
inputAria,
|
|
311
|
+
menuOpen,
|
|
312
|
+
hasSkills: skills.length > 0,
|
|
313
|
+
skillNames: skills.map((skill) => skill.name),
|
|
314
|
+
commandQuery,
|
|
315
|
+
commands,
|
|
316
|
+
commandMenuOpen,
|
|
317
|
+
skillMatches,
|
|
318
|
+
skillMenuOpen,
|
|
319
|
+
mention,
|
|
320
|
+
mentionMenuOpen,
|
|
321
|
+
mentionCount,
|
|
322
|
+
files,
|
|
323
|
+
referenceMenuOpen,
|
|
324
|
+
referenceMatches,
|
|
325
|
+
referables,
|
|
326
|
+
referenceChips,
|
|
327
|
+
setMenuIndex,
|
|
328
|
+
trackTriggers,
|
|
329
|
+
close,
|
|
330
|
+
chooseMention,
|
|
331
|
+
chooseMentionAt,
|
|
332
|
+
chooseReference,
|
|
333
|
+
chooseReferenceAt,
|
|
334
|
+
chooseSkill,
|
|
335
|
+
pickCommand,
|
|
336
|
+
completeCommand,
|
|
337
|
+
moveInMenu
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export type PromptMenus = ReturnType<typeof usePromptMenus>;
|
package/src/chat/ui/index.ts
CHANGED
|
@@ -14,6 +14,9 @@ export { ImageThumb, type ImageThumbProps } from './ImageView';
|
|
|
14
14
|
export { LimitDock, LimitPill } from './LimitState';
|
|
15
15
|
export { ReplyMarkdown, MessageMarkdown, Markdown } from './Markdown';
|
|
16
16
|
export { ModelPicker } from './Pickers';
|
|
17
|
+
export { PromptEditor } from './PromptEditor';
|
|
18
|
+
export { promptPicks } from './composer/prompt-picks';
|
|
19
|
+
export type { PromptDraft } from './composer/use-prompt-menus';
|
|
17
20
|
export { ResumeCompactionDock } from './ResumeCompactionDock';
|
|
18
21
|
export { SubagentTitleCrumb, SubagentBreadcrumb } from './SubagentControls';
|
|
19
22
|
export { SubagentInfo } from './SubagentInfo';
|
|
@@ -22,6 +25,7 @@ export { Timeline } from './Timeline';
|
|
|
22
25
|
export { TimelineMenuPopup } from './TimelineMenu';
|
|
23
26
|
export { default as UnifiedDiff } from './UnifiedDiff';
|
|
24
27
|
export { UploadThumb } from './UploadThumb';
|
|
28
|
+
export { VisualPageFrame, type VisualPageFrameProps } from './VisualFrame';
|
|
25
29
|
export { WelcomeGreeting } from './Welcome';
|
|
26
30
|
export { externalChange, composerEditorExtensions } from './composer/editor';
|
|
27
31
|
export {
|