diff --git a/src/components/agent-picker.tsx b/src/components/agent-picker.tsx new file mode 100644 index 00000000..239ae6a9 --- /dev/null +++ b/src/components/agent-picker.tsx @@ -0,0 +1,148 @@ +import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" +import type { Agent } from "../types/session" + +interface AgentPickerProps { + open: boolean + onSelect: (agentName: string) => void + onClose: () => void + agents: Agent[] + searchQuery: string + textareaRef?: HTMLTextAreaElement +} + +const AgentPicker: Component = (props) => { + const [filteredAgents, setFilteredAgents] = createSignal([]) + const [selectedIndex, setSelectedIndex] = createSignal(0) + + let containerRef: HTMLDivElement | undefined + let scrollContainerRef: HTMLDivElement | undefined + + createEffect(() => { + if (!props.open) return + + const query = props.searchQuery.toLowerCase() + const filtered = query + ? props.agents.filter( + (agent) => + agent.name.toLowerCase().includes(query) || + (agent.description && agent.description.toLowerCase().includes(query)), + ) + : props.agents + + setFilteredAgents(filtered) + setSelectedIndex(0) + + setTimeout(() => { + if (scrollContainerRef) { + scrollContainerRef.scrollTop = 0 + } + }, 0) + }) + + function scrollToSelected() { + setTimeout(() => { + const selectedElement = containerRef?.querySelector('[data-agent-selected="true"]') + if (selectedElement) { + selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" }) + } + }, 0) + } + + function handleSelect(agentName: string) { + props.onSelect(agentName) + } + + function handleKeyDown(e: KeyboardEvent) { + if (!props.open) return + + const agents = filteredAgents() + + if (e.key === "ArrowDown") { + e.preventDefault() + setSelectedIndex((prev) => Math.min(prev + 1, agents.length - 1)) + scrollToSelected() + } else if (e.key === "ArrowUp") { + e.preventDefault() + setSelectedIndex((prev) => Math.max(prev - 1, 0)) + scrollToSelected() + } else if (e.key === "Enter") { + e.preventDefault() + const selected = agents[selectedIndex()] + if (selected) { + handleSelect(selected.name) + } + } else if (e.key === "Escape") { + e.preventDefault() + props.onClose() + } + } + + createEffect(() => { + if (props.open) { + document.addEventListener("keydown", handleKeyDown) + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + } + }) + + return ( + +
+
+
Select Agent
+
+ +
+ +
No agents found
+
+ + + {(agent, index) => ( +
handleSelect(agent.name)} + > +
+
+
+ {agent.name} + + + subagent + + +
+ +
+ {agent.description && agent.description.length > 80 + ? agent.description.slice(0, 80) + "..." + : agent.description} +
+
+
+
+
+ )} +
+
+ +
+
+ ↑↓ navigate • Enter select •{" "} + Esc close +
+
+
+
+ ) +} + +export default AgentPicker diff --git a/src/components/prompt-input.tsx b/src/components/prompt-input.tsx index 1258937a..f6122b50 100644 --- a/src/components/prompt-input.tsx +++ b/src/components/prompt-input.tsx @@ -2,13 +2,15 @@ import { createSignal, Show, onMount, For, onCleanup } from "solid-js" import AgentSelector from "./agent-selector" import ModelSelector from "./model-selector" import FilePicker from "./file-picker" +import AgentPicker from "./agent-picker" import { addToHistory, getHistory } from "../stores/message-history" import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments" -import { createFileAttachment, createTextAttachment } from "../types/attachment" +import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment" import type { Attachment } from "../types/attachment" import Kbd from "./kbd" import HintRow from "./hint-row" import { getActiveInstance } from "../stores/instances" +import { agents } from "../stores/sessions" interface PromptInputProps { instanceId: string @@ -29,7 +31,9 @@ export default function PromptInput(props: PromptInputProps) { const [historyIndex, setHistoryIndex] = createSignal(-1) const [isFocused, setIsFocused] = createSignal(false) const [showFilePicker, setShowFilePicker] = createSignal(false) + const [showAgentPicker, setShowAgentPicker] = createSignal(false) const [fileSearchQuery, setFileSearchQuery] = createSignal("") + const [agentSearchQuery, setAgentSearchQuery] = createSignal("") const [atPosition, setAtPosition] = createSignal(null) const [isDragging, setIsDragging] = createSignal(false) const [ignoredAtPositions, setIgnoredAtPositions] = createSignal>(new Set()) @@ -38,6 +42,7 @@ export default function PromptInput(props: PromptInputProps) { let containerRef: HTMLDivElement | undefined const attachments = () => getAttachments(props.instanceId, props.sessionId) + const instanceAgents = () => agents().get(props.instanceId) || [] function handleRemoveAttachment(attachmentId: string) { const currentAttachments = attachments() @@ -52,6 +57,9 @@ export default function PromptInput(props: PromptInputProps) { if (attachment.source.type === "file") { const filename = attachment.filename newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim() + } else if (attachment.source.type === "agent") { + const agentName = attachment.filename + newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim() } else if (attachment.source.type === "text") { const placeholderMatch = attachment.display.match(/pasted #(\d+)/) if (placeholderMatch) { @@ -198,13 +206,13 @@ export default function PromptInput(props: PromptInputProps) { } } - const fileMentionRegex = /@(\S+)/g - let fileMatch + const mentionRegex = /@(\S+)/g + let mentionMatch - while ((fileMatch = fileMentionRegex.exec(text)) !== null) { - const mentionStart = fileMatch.index - const mentionEnd = fileMatch.index + fileMatch[0].length - const filename = fileMatch[1] + while ((mentionMatch = mentionRegex.exec(text)) !== null) { + const mentionStart = mentionMatch.index + const mentionEnd = mentionMatch.index + mentionMatch[0].length + const name = mentionMatch[1] const isDeletingFromEnd = e.key === "Backspace" && cursorPos === mentionEnd const isDeletingFromStart = e.key === "Delete" && cursorPos === mentionStart @@ -215,7 +223,9 @@ export default function PromptInput(props: PromptInputProps) { if (isDeletingFromEnd || isDeletingFromStart || isSelected) { const currentAttachments = attachments() - const attachment = currentAttachments.find((a) => a.source.type === "file" && a.filename === filename) + const attachment = currentAttachments.find( + (a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name, + ) if (attachment) { e.preventDefault() @@ -243,7 +253,7 @@ export default function PromptInput(props: PromptInputProps) { } } - if (e.key === "Enter" && !e.shiftKey && !showFilePicker()) { + if (e.key === "Enter" && !e.shiftKey && !showFilePicker() && !showAgentPicker()) { e.preventDefault() handleSend() return @@ -252,7 +262,7 @@ export default function PromptInput(props: PromptInputProps) { const atStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0 const currentHistory = history() - if (e.key === "ArrowUp" && !showFilePicker() && atStart && currentHistory.length > 0) { + if (e.key === "ArrowUp" && !showFilePicker() && !showAgentPicker() && atStart && currentHistory.length > 0) { e.preventDefault() const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, currentHistory.length - 1) setHistoryIndex(newIndex) @@ -264,7 +274,7 @@ export default function PromptInput(props: PromptInputProps) { return } - if (e.key === "ArrowDown" && !showFilePicker() && historyIndex() >= 0) { + if (e.key === "ArrowDown" && !showFilePicker() && !showAgentPicker() && historyIndex() >= 0) { e.preventDefault() const newIndex = historyIndex() - 1 if (newIndex >= 0) { @@ -345,14 +355,28 @@ export default function PromptInput(props: PromptInputProps) { if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) { if (!ignoredAtPositions().has(lastAtIndex)) { setAtPosition(lastAtIndex) - setFileSearchQuery(textAfterAt) - setShowFilePicker(true) + + const availableAgents = instanceAgents() + const matchesAgent = availableAgents.some((agent) => + agent.name.toLowerCase().includes(textAfterAt.toLowerCase()), + ) + + if (matchesAgent && textAfterAt.length > 0) { + setAgentSearchQuery(textAfterAt) + setShowAgentPicker(true) + setShowFilePicker(false) + } else { + setFileSearchQuery(textAfterAt) + setShowFilePicker(true) + setShowAgentPicker(false) + } } return } } setShowFilePicker(false) + setShowAgentPicker(false) setAtPosition(null) } @@ -435,6 +459,56 @@ export default function PromptInput(props: PromptInputProps) { function handleFilePickerNavigate(_direction: "up" | "down") {} + function handleAgentSelect(agentName: string) { + const existingAttachments = attachments() + const alreadyAttached = existingAttachments.some( + (att) => att.source.type === "agent" && att.source.name === agentName, + ) + + if (!alreadyAttached) { + const attachment = createAgentAttachment(agentName) + addAttachment(props.instanceId, props.sessionId, attachment) + } + + const currentPrompt = prompt() + const pos = atPosition() + const cursorPos = textareaRef?.selectionStart || 0 + + if (pos !== null) { + const before = currentPrompt.substring(0, pos) + const after = currentPrompt.substring(cursorPos) + const attachmentText = `@${agentName}` + const newPrompt = before + attachmentText + " " + after + setPrompt(newPrompt) + + setTimeout(() => { + if (textareaRef) { + const newCursorPos = pos + attachmentText.length + 1 + textareaRef.setSelectionRange(newCursorPos, newCursorPos) + textareaRef.style.height = "auto" + textareaRef.style.height = Math.min(textareaRef.scrollHeight, 200) + "px" + } + }, 0) + } + + setShowAgentPicker(false) + setAtPosition(null) + setAgentSearchQuery("") + + textareaRef?.focus() + } + + function handleAgentPickerClose() { + const pos = atPosition() + if (pos !== null) { + setIgnoredAtPositions((prev) => new Set(prev).add(pos)) + } + setShowAgentPicker(false) + setAtPosition(null) + setAgentSearchQuery("") + setTimeout(() => textareaRef?.focus(), 0) + } + function handleDragOver(e: DragEvent) { e.preventDefault() e.stopPropagation() @@ -494,6 +568,17 @@ export default function PromptInput(props: PromptInputProps) { /> + + + +
0}>
@@ -503,14 +588,28 @@ export default function PromptInput(props: PromptInputProps) { - - + + + + } + > + + + + } > @@ -545,7 +644,7 @@ export default function PromptInput(props: PromptInputProps) {
- Enter to send • Shift+Enter for new line • @ for files • ↑↓ for - history + Enter to send • Shift+Enter for new line • @ for files/agents • ↑↓{" "} + for history 0}> • {attachments().length} file(s) attached diff --git a/src/types/attachment.ts b/src/types/attachment.ts index ce15cb04..9becc069 100644 --- a/src/types/attachment.ts +++ b/src/types/attachment.ts @@ -93,3 +93,18 @@ export function createTextAttachment(value: string, display: string, filename: s }, } } + +export function createAgentAttachment(agentName: string): Attachment { + return { + id: crypto.randomUUID(), + type: "agent", + display: `@${agentName}`, + url: "", + filename: agentName, + mediaType: "text/plain", + source: { + type: "agent", + name: agentName, + }, + } +}