@adecore/agents-react 0.38.0 → 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.
Files changed (51) hide show
  1. package/dist/agents/AgentModelLabel.d.ts +10 -0
  2. package/dist/agents/AgentModelLabel.js +97 -0
  3. package/dist/chat/ui/Composer.js +385 -360
  4. package/dist/chat/ui/Pickers.d.ts +2 -1
  5. package/dist/chat/ui/Pickers.js +126 -123
  6. package/dist/chat/ui/PromptEditor.d.ts +19 -0
  7. package/dist/chat/ui/PromptEditor.js +115 -0
  8. package/dist/chat/ui/Timeline.d.ts +2 -1
  9. package/dist/chat/ui/Timeline.js +13 -1
  10. package/dist/chat/ui/composer/ComposerControls.d.ts +3 -0
  11. package/dist/chat/ui/composer/ComposerControls.js +235 -38
  12. package/dist/chat/ui/composer/key-handler.d.ts +1 -1
  13. package/dist/chat/ui/composer/key-handler.js +3 -11
  14. package/dist/chat/ui/composer/prompt-picks.d.ts +4 -0
  15. package/dist/chat/ui/composer/prompt-picks.js +14 -0
  16. package/dist/chat/ui/composer/use-composer-menus.d.ts +4 -49
  17. package/dist/chat/ui/composer/use-composer-menus.js +65 -519
  18. package/dist/chat/ui/composer/use-prompt-menus.d.ts +73 -0
  19. package/dist/chat/ui/composer/use-prompt-menus.js +536 -0
  20. package/dist/chat/ui/index.d.ts +3 -0
  21. package/dist/chat/ui/index.js +2 -0
  22. package/dist/host-hooks.d.ts +2 -1
  23. package/dist/host-hooks.js +6 -0
  24. package/dist/host.d.ts +17 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/locales/en/agent-chat.d.ts +1 -0
  27. package/dist/locales/en/agent-chat.js +1 -0
  28. package/dist/locales/nl/agent-chat.d.ts +1 -0
  29. package/dist/locales/nl/agent-chat.js +1 -0
  30. package/dist/providers/index.d.ts +1 -0
  31. package/dist/providers/index.js +1 -0
  32. package/dist/theme.css +3 -0
  33. package/package.json +8 -8
  34. package/src/agents/AgentModelLabel.tsx +40 -0
  35. package/src/chat/ui/Composer.tsx +12 -3
  36. package/src/chat/ui/Pickers.tsx +4 -1
  37. package/src/chat/ui/PromptEditor.tsx +130 -0
  38. package/src/chat/ui/Timeline.tsx +22 -1
  39. package/src/chat/ui/composer/ComposerControls.tsx +65 -15
  40. package/src/chat/ui/composer/key-handler.ts +3 -9
  41. package/src/chat/ui/composer/prompt-picks.ts +17 -0
  42. package/src/chat/ui/composer/use-composer-menus.ts +18 -275
  43. package/src/chat/ui/composer/use-prompt-menus.ts +341 -0
  44. package/src/chat/ui/index.ts +3 -0
  45. package/src/host-hooks.ts +9 -1
  46. package/src/host.ts +27 -1
  47. package/src/index.ts +2 -0
  48. package/src/locales/en/agent-chat.ts +1 -0
  49. package/src/locales/nl/agent-chat.ts +1 -0
  50. package/src/providers/index.ts +1 -0
  51. package/src/theme.css +3 -0
package/dist/theme.css CHANGED
@@ -288,6 +288,9 @@
288
288
  .composer-input .cm-editor .cm-scroller .cm-content { padding: 18px 14px 6px 20px; caret-color: var(--text); }
289
289
  .composer-input .cm-scroller .cm-content .cm-line { padding: 0; }
290
290
  .composer-input .cm-scroller .cm-content .cm-placeholder { color: var(--text-faint); }
291
+ /* The same editor as a form field (`PromptEditor`): the field's inset, and room for a few lines before it scrolls. */
292
+ .composer-input.prompt-editor .cm-editor { max-height: 240px; }
293
+ .composer-input.prompt-editor .cm-editor .cm-scroller .cm-content { min-height: 80px; padding: 6px 10px; }
291
294
  .composer-hints, .composer-hints-extra { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
292
295
  /* The joiner starts with its own comma, so it sits against the lead without the gap. */
293
296
  .composer-hints-extra { margin-left: -8px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,11 +67,11 @@
67
67
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
68
  },
69
69
  "dependencies": {
70
- "@adecore/agent-contracts": "0.38.0",
71
- "@adecore/editor": "0.38.0",
72
- "@adecore/editor-react": "0.38.0",
73
- "@adecore/intelligent-ui": "0.38.0",
74
- "@adecore/merge": "0.38.0",
70
+ "@adecore/agent-contracts": "0.39.0",
71
+ "@adecore/editor": "0.39.0",
72
+ "@adecore/editor-react": "0.39.0",
73
+ "@adecore/intelligent-ui": "0.39.0",
74
+ "@adecore/merge": "0.39.0",
75
75
  "@codemirror/commands": "^6.11.1",
76
76
  "@codemirror/language": "^6.12.4",
77
77
  "@codemirror/state": "^6.7.6",
@@ -94,14 +94,14 @@
94
94
  "zustand": "^5.0.15"
95
95
  },
96
96
  "peerDependencies": {
97
- "@adecore/ui": "^0.38.0",
97
+ "@adecore/ui": "^0.39.0",
98
98
  "i18next": "^26.4.2",
99
99
  "react": "^19.3.0",
100
100
  "react-dom": "^19.3.0",
101
101
  "react-i18next": "^17.0.15"
102
102
  },
103
103
  "devDependencies": {
104
- "@adecore/ui": "0.38.0",
104
+ "@adecore/ui": "0.39.0",
105
105
  "@types/bun": "^1.4.2",
106
106
  "@types/react": "^19.3.0",
107
107
  "@types/react-dom": "^19.3.0",
@@ -0,0 +1,40 @@
1
+ import { useContext } from 'react';
2
+ import clsx from 'clsx';
3
+ import type { AgentKind, UsageProvider } from '@adecore/agent-contracts';
4
+ import { ChatScopeContext } from '../scope';
5
+ import { useProvidersStore } from '../state/providers';
6
+ import { AgentIcon } from './AgentIcon';
7
+ import { modelName } from './model-name';
8
+ import { ProviderLogo } from './ProviderLogo';
9
+
10
+ interface AgentModelLabelProps {
11
+ provider: AgentKind;
12
+ /* The model's slug; absent names the CLI itself. */
13
+ model?: string;
14
+ /* The machine whose catalog names the model; absent, the chat scope the label is drawn in. */
15
+ scopeId?: string;
16
+ size?: number;
17
+ className?: string;
18
+ }
19
+
20
+ /*
21
+ * Who answered or will answer, in one line: the CLI's mark and the model, named the way the machine's
22
+ * catalog names it, as a row of facts writes it ("made by", "runs as").
23
+ */
24
+ export function AgentModelLabel({ provider, model, scopeId, size = 14, className }: AgentModelLabelProps) {
25
+ // Outside a chat scope a host names the machine itself; with neither, the CLI still draws, by its kind.
26
+ const scope = useContext(ChatScopeContext);
27
+ const id = scopeId ?? scope?.id;
28
+ const catalog = useProvidersStore((s) => (id === undefined ? undefined : s.byScope[id]?.providers.find((entry) => entry.kind === provider)));
29
+ const name = model === undefined ? (catalog?.name ?? provider) : modelName(model, catalog?.models);
30
+ return (
31
+ <span className={clsx('flex min-w-0 items-center gap-1.5', className)}>
32
+ {provider === 'claude' || provider === 'codex' ? (
33
+ <ProviderLogo provider={provider as UsageProvider} size={size} />
34
+ ) : (
35
+ <AgentIcon kind={provider} size={size} className="shrink-0" />
36
+ )}
37
+ <span className="truncate">{name}</span>
38
+ </span>
39
+ );
40
+ }
@@ -44,7 +44,7 @@ import { usePromptHistory } from './composer/use-prompt-history';
44
44
  import { usePromptHold } from './composer/use-prompt-hold';
45
45
  import { surfacePartOf } from './composer/surface';
46
46
  import { useTakeBack } from './composer/use-take-back';
47
- import { useHostExtraPrompts, useHostSendDelivery } from '../../host-hooks';
47
+ import { useHostComposerSendActions, useHostExtraPrompts, useHostSendDelivery } from '../../host-hooks';
48
48
 
49
49
  interface ComposerProps {
50
50
  chatId: string;
@@ -121,6 +121,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
121
121
  // The stopped turn something went out for, since the chat takes a moment to answer and Continue must not stand there meanwhile.
122
122
  const [sentFor, setSentFor] = useState<string | null>(null);
123
123
  const extraPrompts = useHostExtraPrompts(scope.id, chatId);
124
+ const sendActions = useHostComposerSendActions(scope.id, chatId);
124
125
  const { submit, continueTurn, restoreStashed, toggleStash } = useComposerSubmit({
125
126
  chatId,
126
127
  writable,
@@ -189,7 +190,6 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
189
190
  history,
190
191
  noteKey: files.noteKey,
191
192
  toggleStash,
192
- setText: (next) => box.setText(next),
193
193
  submit
194
194
  });
195
195
 
@@ -336,7 +336,16 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
336
336
  onContinue:
337
337
  continuable !== null && continuable !== sentFor && empty && writable && pending.length === 0 && extraPrompts.length === 0
338
338
  ? continueTurn
339
- : null
339
+ : null,
340
+ actions: sendActions,
341
+ draft: {
342
+ scopeId: scope.id,
343
+ chatId,
344
+ text: withQuote(draft.quote, draft.text.trim()),
345
+ provider: info.provider,
346
+ selection: info.selection,
347
+ runtimeMode: info.runtimeMode
348
+ }
340
349
  }}
341
350
  dictationToolbar={setDictationToolbar}
342
351
  onFiles={(picked) => {
@@ -34,6 +34,8 @@ interface ModelPickerProps {
34
34
  side?: 'top' | 'bottom';
35
35
  /* `chip` names the provider beside the model on a secondary button, for a picker that sits in a card or a form. */
36
36
  trigger?: 'plain' | 'chip';
37
+ /* The chip's height: `sm` in a card, `md` beside the fields of a form. */
38
+ chipSize?: 'sm' | 'md';
37
39
  /* Where the focus goes when the popup closes; by default back to the trigger. */
38
40
  finalFocus?: RefObject<HTMLElement | null>;
39
41
  /* A note under the list, such as what picking an agent here does. */
@@ -55,6 +57,7 @@ export function ModelPicker({
55
57
  kbd = '/model',
56
58
  side = 'top',
57
59
  trigger = 'plain',
60
+ chipSize = 'sm',
58
61
  finalFocus,
59
62
  footer
60
63
  }: ModelPickerProps) {
@@ -150,7 +153,7 @@ export function ModelPicker({
150
153
  <Popover.Root open={open} onOpenChange={setOpen}>
151
154
  <Tooltip label={owner ? `${owner.name} · ${current}` : t('pickers.model.choose')} kbd={kbd ?? undefined}>
152
155
  {trigger === 'chip' ? (
153
- <Popover.Trigger render={<Button variant="secondary" size="sm" className="min-w-0 shrink gap-1.5" />}>
156
+ <Popover.Trigger render={<Button variant="secondary" size={chipSize} className="min-w-0 shrink gap-1.5" />}>
154
157
  {chip.mark === 'logo' ? <ProviderLogo provider={provider as UsageProvider} size={12} /> : <AgentIcon kind={provider} size={12} />}
155
158
  <span className="truncate">{chip.name}</span>
156
159
  <span className="max-w-32 truncate text-text-muted">{chip.model}</span>
@@ -0,0 +1,130 @@
1
+ import { useLayoutEffect, useRef, type SetStateAction } from 'react';
2
+ import clsx from 'clsx';
3
+ import { Surface } from '@adecore/ui';
4
+ import type { ChatSkill } from '@adecore/agent-contracts';
5
+ import type { Referables } from '../chat-references';
6
+ import { ComposerInput, type ComposerInputHandle } from './ComposerInput';
7
+ import { chipDecorations } from './composer/chips';
8
+ import { ComposerMenu } from './composer/ComposerMenu';
9
+ import { pickInMenu } from './composer/key-handler';
10
+ import { usePromptMenus, type PromptDraft } from './composer/use-prompt-menus';
11
+
12
+ interface PromptEditorProps {
13
+ value: PromptDraft;
14
+ onChange(next: PromptDraft): void;
15
+ /* The editor's accessible name; a label element cannot point at the editable element. */
16
+ label: string;
17
+ placeholder: string;
18
+ skills: readonly ChatSkill[];
19
+ /* What the CLI answers after `/`, besides `/goal` where `goals` is set. */
20
+ slashCommands?: readonly string[];
21
+ goals?: boolean;
22
+ /* Where `@` searches for files; null offers none. */
23
+ cwd: string | null;
24
+ referables: Referables;
25
+ providerName?: string;
26
+ disabled?: boolean;
27
+ className?: string;
28
+ }
29
+
30
+ const NO_COMMANDS: readonly string[] = [];
31
+
32
+ /*
33
+ * A prompt written now and sent later, as a form field: the composer's editor with its pickers and chips,
34
+ * without a chat behind it. The host hands in what the CLI it goes to has, and keeps what was picked.
35
+ */
36
+ export function PromptEditor({
37
+ value,
38
+ onChange,
39
+ label,
40
+ placeholder,
41
+ skills,
42
+ slashCommands = NO_COMMANDS,
43
+ goals = false,
44
+ cwd,
45
+ referables,
46
+ providerName,
47
+ disabled = false,
48
+ className
49
+ }: PromptEditorProps) {
50
+ const inputRef = useRef<ComposerInputHandle>(null);
51
+ // A pick writes twice in one event (the text, then what it picked), and the second must build on the first.
52
+ const latest = useRef(value);
53
+
54
+ useLayoutEffect(() => {
55
+ latest.current = value;
56
+ }, [value]);
57
+
58
+ const setDraft = (action: SetStateAction<PromptDraft>): void => {
59
+ const next = typeof action === 'function' ? action(latest.current) : action;
60
+ latest.current = next;
61
+ onChange(next);
62
+ };
63
+
64
+ const setText = (next: string, mentions = value.mentions, chosen = value.skills): void => {
65
+ setDraft((current) => ({ ...current, text: next, mentions, skills: chosen }));
66
+ };
67
+
68
+ const menus = usePromptMenus({
69
+ draft: { draft: value, setDraft, setText },
70
+ inputRef,
71
+ cwd,
72
+ skills,
73
+ slashCommands,
74
+ goals,
75
+ providerName,
76
+ referables,
77
+ label
78
+ });
79
+ const extensions = chipDecorations({ mentions: value.mentions, skills: value.skills, references: menus.referenceChips });
80
+
81
+ const onKeyDown = (e: KeyboardEvent): boolean => {
82
+ if (e.isComposing) {
83
+ return false;
84
+ }
85
+ // Escape closes the picker, and only that: the dialog around the field stays.
86
+ if (e.key === 'Escape' && menus.menuOpen) {
87
+ menus.close();
88
+ e.stopPropagation();
89
+ return true;
90
+ }
91
+ // Enter adds a line here rather than sending, so on a command it completes like Tab.
92
+ if (e.key === 'Enter' && menus.commandMenuOpen) {
93
+ menus.completeCommand(menus.commands[menus.activeIndex]!);
94
+ return true;
95
+ }
96
+ return pickInMenu(e, menus);
97
+ };
98
+
99
+ return (
100
+ <div className={clsx('relative', className)}>
101
+ {/* Over the form under the field, not inside it, so what a person types never moves while the picker opens and closes. */}
102
+ {menus.menuOpen && (
103
+ <Surface className="absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-lg bg-clip-padding *:border-b-0">
104
+ <ComposerMenu menus={menus} />
105
+ </Surface>
106
+ )}
107
+ <div className="field h-auto overflow-hidden p-0 focus-ring-within">
108
+ <ComposerInput
109
+ ref={inputRef}
110
+ dictationToolbar={null}
111
+ className="composer-input prompt-editor select-text text-sm leading-normal text-text"
112
+ value={value.text}
113
+ placeholder={placeholder}
114
+ disabled={disabled}
115
+ tabbable
116
+ extensions={extensions}
117
+ aria={menus.inputAria}
118
+ onChange={(text, selection, state) => {
119
+ setText(text);
120
+ menus.setMenuIndex(0);
121
+ menus.trackTriggers(text, selection, state);
122
+ }}
123
+ onSelectionChange={menus.trackTriggers}
124
+ onBlur={menus.close}
125
+ onKeyDown={onKeyDown}
126
+ />
127
+ </div>
128
+ </div>
129
+ );
130
+ }
@@ -180,8 +180,21 @@ const ThreadRow = memo(function ThreadRow({ row, previous, index, top, measure,
180
180
  /*
181
181
  * `overlay` stands in the thread's place and ends where the composer starts, so the thread keeps
182
182
  * where it was underneath and the composer stays in reach for the prompts it answers.
183
+ * `onContentHeight` hears what the thread's rows and its padding take, without the composer, once
184
+ * the rows in view are laid out at their real heights, for a host that sizes its frame to the thread.
185
+ * Rows out of view still count at an estimate, so a frame up to the viewport's height gets it exact.
183
186
  */
184
- export function Timeline({ chatId, composer, overlay }: { chatId: string; composer?: ReactNode; overlay?: ReactNode }) {
187
+ export function Timeline({
188
+ chatId,
189
+ composer,
190
+ overlay,
191
+ onContentHeight
192
+ }: {
193
+ chatId: string;
194
+ composer?: ReactNode;
195
+ overlay?: ReactNode;
196
+ onContentHeight?: (height: number) => void;
197
+ }) {
185
198
  const host = useChatHost();
186
199
  const { fork } = host;
187
200
  const { t } = useTranslation('agent-chat');
@@ -406,6 +419,14 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
406
419
  setMeasuredThread(keyOf(chatId));
407
420
  }
408
421
  }, [initialLayout, empty, loadingHistory, frame.height, virtualRows, virtualizer, rows.length, keyOf, chatId]);
422
+ useLayoutEffect(() => {
423
+ const thread = threadRef.current;
424
+ if (onContentHeight === undefined || initialLayout || empty || loadingHistory || thread === null) {
425
+ return;
426
+ }
427
+ const style = getComputedStyle(thread);
428
+ onContentHeight(totalSize + (Number.parseFloat(style.paddingTop) || 0) + (Number.parseFloat(style.paddingBottom) || 0));
429
+ }, [onContentHeight, initialLayout, empty, loadingHistory, totalSize]);
409
430
  useLayoutEffect(() => {
410
431
  const element = scrollRef.current;
411
432
  if (!followRef.current || element === null) {
@@ -1,14 +1,16 @@
1
- import { useRef, type Ref } from 'react';
1
+ import { useRef, useState, type Ref } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
- import { ArrowUp, Play, Plus, Square } from 'lucide-react';
3
+ import clsx from 'clsx';
4
+ import { ArrowUp, ChevronDown, Play, Plus, Square } from 'lucide-react';
4
5
  import type { ChatInfo } from '@adecore/agent-contracts';
5
- import { Button, ErrorBoundary, Icon, Tooltip, type Shortcut } from '@adecore/ui';
6
- import { useChatHost } from '../../../host';
6
+ import { Button, ErrorBoundary, Icon, Menu, Tooltip, type Shortcut } from '@adecore/ui';
7
+ import { useChatHost, type ComposerSendAction, type ComposerSendDraft } from '../../../host';
7
8
  import { useChatScope } from '../../../scope';
8
9
  import { useProviders } from '../../../state/providers';
9
10
  import { useAccountChoice } from '../../account-choice';
10
11
  import type { StashedPrompt } from '../../stash';
11
12
  import { composerStopLabel } from '../../subagent-list';
13
+ import { HostMenuItems } from '../HostMenuItems';
12
14
  import { StashPicker } from '../Pickers';
13
15
  import { RunSettings } from '../RunSettings';
14
16
  import type { ChatControls } from './use-chat-controls';
@@ -22,6 +24,64 @@ interface SendProps {
22
24
  onSend(alternate: boolean): void;
23
25
  /* Set when a person stopped the last turn and the box is empty: Continue stands where the send button would. */
24
26
  onContinue: (() => void) | null;
27
+ /* The host's own, behind a chevron on the send button; empty draws the send button alone. */
28
+ actions: readonly ComposerSendAction[];
29
+ draft: ComposerSendDraft;
30
+ }
31
+
32
+ interface SendButtonProps {
33
+ send: SendProps;
34
+ /* The chevron only follows whether the box takes input: an action may want an empty draft, such as to reuse the last message. */
35
+ writable: boolean;
36
+ }
37
+
38
+ /* The send button, split with a chevron that opens the host's send actions when it has any. */
39
+ function SendButton({ send, writable }: SendButtonProps) {
40
+ const { t } = useTranslation('agent-chat');
41
+ const [box, setBox] = useState<HTMLDivElement | null>(null);
42
+ const split = send.actions.length > 0;
43
+ const button = (
44
+ <Tooltip label={send.label} kbd={send.kbd} name>
45
+ <button
46
+ className={clsx(
47
+ 'flex h-9 w-9 shrink-0 items-center justify-center bg-accent text-accent-text hover:brightness-90 disabled:opacity-50 disabled:hover:brightness-100',
48
+ split ? 'focus-visible:-outline-offset-2' : 'rounded-full'
49
+ )}
50
+ disabled={send.disabled}
51
+ onClick={(event) => send.onSend(event.altKey)}
52
+ >
53
+ <Icon icon={ArrowUp} size={16} />
54
+ </button>
55
+ </Tooltip>
56
+ );
57
+ if (!split) {
58
+ return button;
59
+ }
60
+ const { draft } = send;
61
+ return (
62
+ // One round shape that clips both halves, the way a split button of the library draws its frame.
63
+ <div ref={setBox} className="flex h-9 shrink-0 overflow-clip rounded-full">
64
+ {button}
65
+ <Menu.Root>
66
+ <Tooltip label={t('composer.sendActions')} name>
67
+ <Menu.Trigger
68
+ render={
69
+ <button
70
+ type="button"
71
+ className='relative flex h-9 w-6 shrink-0 items-center justify-center bg-accent text-accent-text before:absolute before:inset-y-0 before:left-0 before:my-auto before:h-4 before:w-px before:bg-current/30 before:content-[""] hover:brightness-90 focus-visible:-outline-offset-2 disabled:opacity-50 disabled:hover:brightness-100'
72
+ />
73
+ }
74
+ disabled={!writable}
75
+ >
76
+ <Icon icon={ChevronDown} size={14} />
77
+ </Menu.Trigger>
78
+ </Tooltip>
79
+ <Menu.Popup anchor={box} side="top" sideOffset={8} align="end" className="min-w-48">
80
+ <HostMenuItems actions={send.actions.map((action) => ({ ...action, run: () => action.run(draft) }))} />
81
+ </Menu.Popup>
82
+ </Menu.Root>
83
+ </div>
84
+ );
25
85
  }
26
86
 
27
87
  interface ComposerControlsProps {
@@ -140,17 +200,7 @@ export function ComposerControls({
140
200
  </Button>
141
201
  </Tooltip>
142
202
  )}
143
- {send.onContinue === null && send.visible && (
144
- <Tooltip label={send.label} kbd={send.kbd} name>
145
- <button
146
- className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text hover:brightness-90 disabled:opacity-50 disabled:hover:brightness-100"
147
- disabled={send.disabled}
148
- onClick={(event) => send.onSend(event.altKey)}
149
- >
150
- <Icon icon={ArrowUp} size={16} />
151
- </button>
152
- </Tooltip>
153
- )}
203
+ {send.onContinue === null && send.visible && <SendButton send={send} writable={writable} />}
154
204
  </div>
155
205
  </div>
156
206
  );
@@ -26,7 +26,6 @@ interface KeyHandlerInput {
26
26
  history: PromptHistory;
27
27
  noteKey(e: KeyboardEvent): void;
28
28
  toggleStash(): void;
29
- setText(next: string): void;
30
29
  submit(how: ComposerSend): void;
31
30
  }
32
31
 
@@ -50,19 +49,14 @@ function indentInFence(e: KeyboardEvent, view: EditorView): boolean {
50
49
  }
51
50
 
52
51
  /* The keys an open picker takes: the arrows walk it, and Tab (or Enter, outside the commands) picks. */
53
- function pickInMenu(e: KeyboardEvent, { menus, setText }: KeyHandlerInput): boolean {
52
+ export function pickInMenu(e: KeyboardEvent, menus: ComposerMenus): boolean {
54
53
  const { activeIndex } = menus;
55
54
  if (menus.commandMenuOpen) {
56
55
  if (menus.moveInMenu(e.key, menus.commands.length)) {
57
56
  return true;
58
57
  }
59
58
  if (e.key === 'Tab') {
60
- const chosen = menus.commands[activeIndex]!;
61
- if (chosen.skill) {
62
- menus.chooseSkill(chosen.name);
63
- } else {
64
- setText(`/${chosen.name} `);
65
- }
59
+ menus.completeCommand(menus.commands[activeIndex]!);
66
60
  return true;
67
61
  }
68
62
  }
@@ -167,7 +161,7 @@ export function composerKeyHandler(input: KeyHandlerInput): (e: KeyboardEvent, v
167
161
  input.toggleStash();
168
162
  return true;
169
163
  }
170
- if (pickInMenu(e, input)) {
164
+ if (pickInMenu(e, input.menus)) {
171
165
  return true;
172
166
  }
173
167
  if (e.key === 'Tab' && !e.altKey && !e.metaKey && !e.ctrlKey && indentInFence(e, view)) {
@@ -0,0 +1,17 @@
1
+ import type { ChatSendExtras } from '../../chat-client';
2
+ import { referenceTokens, type Referables } from '../../chat-references';
3
+ import { presentMentions, presentReferences, presentSkills } from '../../mentions';
4
+ import type { PromptDraft } from './use-prompt-menus';
5
+
6
+ /* What a prompt still holds of what was picked in it, as a send of the composer carries it. */
7
+ export function promptPicks(value: PromptDraft, referables: Referables): Pick<ChatSendExtras, 'mentions' | 'skills' | 'chats' | 'references'> {
8
+ const text = value.text.trim();
9
+ const tokens = referenceTokens([...value.chats, ...value.references], referables.items, value.referenceNames);
10
+ return {
11
+ mentions: presentMentions(text, value.mentions),
12
+ skills: presentSkills(text, value.skills),
13
+ chats: presentReferences(text, value.chats, tokens),
14
+ // A machine that resolves only chats would drop the rest.
15
+ references: referables.chatsOnly ? [] : presentReferences(text, value.references, tokens)
16
+ };
17
+ }