diff options
Diffstat (limited to 'packages/desktop/src/components')
7 files changed, 467 insertions, 195 deletions
diff --git a/packages/desktop/src/components/dialog-connect-provider.tsx b/packages/desktop/src/components/dialog-connect-provider.tsx index 4660e1398..0d6737815 100644 --- a/packages/desktop/src/components/dialog-connect-provider.tsx +++ b/packages/desktop/src/components/dialog-connect-provider.tsx @@ -108,20 +108,18 @@ export function DialogConnectProvider(props: { provider: string }) { async function complete() { await globalSDK.client.global.dispose() - setTimeout(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: `${provider().name} connected`, - description: `${provider().name} models are now available to use.`, - }) - dialog.replace(() => <DialogSelectModel provider={props.provider} />) - }, 1000) + dialog.close() + showToast({ + variant: "success", + icon: "circle-check", + title: `${provider().name} connected`, + description: `${provider().name} models are now available to use.`, + }) } function goBack() { if (methods().length === 1) { - dialog.replace(() => <DialogSelectProvider />) + dialog.show(() => <DialogSelectProvider />) return } if (store.authorization) { @@ -133,7 +131,7 @@ export function DialogConnectProvider(props: { provider: string }) { setStore("methodIndex", undefined) return } - dialog.replace(() => <DialogSelectProvider />) + dialog.show(() => <DialogSelectProvider />) } return ( @@ -352,7 +350,7 @@ export function DialogConnectProvider(props: { provider: string }) { }) if (result.error) { // TODO: show error - dialog.clear() + dialog.close() return } await complete() diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx index 0250963b0..61c518719 100644 --- a/packages/desktop/src/components/dialog-select-file.tsx +++ b/packages/desktop/src/components/dialog-select-file.tsx @@ -3,13 +3,18 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { FileIcon } from "@opencode-ai/ui/file-icon" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { useSession } from "@/context/session" +import { useLayout } from "@/context/layout" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useParams } from "@solidjs/router" +import { createMemo } from "solid-js" export function DialogSelectFile() { - const session = useSession() + const layout = useLayout() const local = useLocal() const dialog = useDialog() + const params = useParams() + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) return ( <Dialog title="Select file"> <List @@ -20,9 +25,9 @@ export function DialogSelectFile() { key={(x) => x} onSelect={(path) => { if (path) { - session.layout.openTab("file://" + path) + tabs().open("file://" + path) } - dialog.clear() + dialog.close() }} > {(i) => ( diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx index 7cdb24915..77e493d3c 100644 --- a/packages/desktop/src/components/dialog-select-model-unpaid.tsx +++ b/packages/desktop/src/components/dialog-select-model-unpaid.tsx @@ -42,7 +42,7 @@ export const DialogSelectModelUnpaid: Component = () => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) - dialog.clear() + dialog.close() }} > {(i) => ( @@ -75,7 +75,7 @@ export const DialogSelectModelUnpaid: Component = () => { }} onSelect={(x) => { if (!x) return - dialog.replace(() => <DialogConnectProvider provider={x.id} />) + dialog.show(() => <DialogConnectProvider provider={x.id} />) }} > {(i) => ( @@ -105,7 +105,7 @@ export const DialogSelectModelUnpaid: Component = () => { class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium" icon="dot-grid" onClick={() => { - dialog.replace(() => <DialogSelectProvider />) + dialog.show(() => <DialogSelectProvider />) }} > View all providers diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx index f0b2e6db9..622ab15fb 100644 --- a/packages/desktop/src/components/dialog-select-model.tsx +++ b/packages/desktop/src/components/dialog-select-model.tsx @@ -28,7 +28,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} - onClick={() => dialog.replace(() => <DialogSelectProvider />)} + onClick={() => dialog.show(() => <DialogSelectProvider />)} > Connect provider </Button> @@ -57,7 +57,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { recent: true, }) - dialog.clear() + dialog.close() }} > {(i) => ( @@ -75,7 +75,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => { <Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" - onClick={() => dialog.replace(() => <DialogManageModels />)} + onClick={() => dialog.show(() => <DialogManageModels />)} > Manage models </Button> diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 8da10b1d5..52fac7073 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -34,7 +34,7 @@ export const DialogSelectProvider: Component = () => { }} onSelect={(x) => { if (!x) return - dialog.replace(() => <DialogConnectProvider provider={x.id} />) + dialog.show(() => <DialogConnectProvider provider={x.id} />) }} > {(i) => ( diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 0c1be77db..6e147242d 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,12 +1,13 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" -import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" -import { createStore } from "solid-js/store" +import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js" +import { createStore, produce } from "solid-js/store" import { makePersisted } from "@solid-primitives/storage" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session" +import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt" +import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" -import { useNavigate } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" @@ -19,6 +20,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" +import { useCommand, formatKeybind } from "@/context/command" + +const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] interface PromptInputProps { class?: string @@ -53,26 +58,54 @@ const PLACEHOLDERS = [ "How do environment variables work here?", ] +interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string + type: "builtin" | "custom" +} + export const PromptInput: Component<PromptInputProps> = (props) => { const navigate = useNavigate() const sdk = useSDK() const sync = useSync() const local = useLocal() - const session = useSession() + const prompt = usePrompt() + const layout = useLayout() + const params = useParams() const dialog = useDialog() const providers = useProviders() + const command = useCommand() let editorRef!: HTMLDivElement + let fileInputRef!: HTMLInputElement + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey())) + const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const status = createMemo( + () => + sync.data.session_status[params.id ?? ""] ?? { + type: "idle", + }, + ) + const working = createMemo(() => status()?.type !== "idle") const [store, setStore] = createStore<{ - popoverIsOpen: boolean + popover: "file" | "slash" | null historyIndex: number savedPrompt: Prompt | null placeholder: number + dragging: boolean + imageAttachments: ImageAttachmentPart[] }>({ - popoverIsOpen: false, + popover: null, historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * PLACEHOLDERS.length), + dragging: false, + imageAttachments: [], }) const MAX_HISTORY = 100 @@ -88,58 +121,48 @@ export const PromptInput: Component<PromptInputProps> = (props) => { ) const clonePromptParts = (prompt: Prompt): Prompt => - prompt.map((part) => - part.type === "text" - ? { ...part } - : { - ...part, - selection: part.selection ? { ...part.selection } : undefined, - }, - ) + prompt.map((part) => { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: part.selection ? { ...part.selection } : undefined, + } + }) - const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0) + const promptLength = (prompt: Prompt) => + prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0) - const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => { - const length = position === "start" ? 0 : promptLength(prompt) - session.prompt.set(prompt, length) + const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { + const length = position === "start" ? 0 : promptLength(p) + prompt.set(p, length) requestAnimationFrame(() => { editorRef.focus() setCursorPosition(editorRef, length) }) } - const getCaretLineState = () => { + const getCaretState = () => { const selection = window.getSelection() - if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false } - const range = selection.getRangeAt(0) - const rect = range.getBoundingClientRect() - const editorRect = editorRef.getBoundingClientRect() - const style = window.getComputedStyle(editorRef) - const paddingTop = parseFloat(style.paddingTop) || 0 - const paddingBottom = parseFloat(style.paddingBottom) || 0 - let lineHeight = parseFloat(style.lineHeight) - if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16 - const scrollTop = editorRef.scrollTop - let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop - if (!Number.isFinite(relativeTop)) relativeTop = scrollTop - relativeTop = Math.max(0, relativeTop) - let caretHeight = rect.height - if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight - const relativeBottom = relativeTop + caretHeight - const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom) - const threshold = Math.max(2, lineHeight / 2) - + const textLength = promptLength(prompt.current()) + if (!selection || selection.rangeCount === 0) { + return { collapsed: false, cursorPosition: 0, textLength } + } + const anchorNode = selection.anchorNode + if (!anchorNode || !editorRef.contains(anchorNode)) { + return { collapsed: false, cursorPosition: 0, textLength } + } return { collapsed: selection.isCollapsed, - onFirstLine: relativeTop <= threshold, - onLastLine: contentHeight - relativeBottom <= threshold, + cursorPosition: getCursorPosition(editorRef), + textLength, } } createEffect(() => { - session.id + params.id editorRef.focus() - if (session.id) return + if (params.id) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length) }, 6500) @@ -148,14 +171,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const isFocused = createFocusSignal(() => editorRef) - const handlePaste = (event: ClipboardEvent) => { + const addImageAttachment = async (file: File) => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: file.name, + mime: file.type, + dataUrl, + } + setStore( + produce((draft) => { + draft.imageAttachments.push(attachment) + }), + ) + } + reader.readAsDataURL(file) + } + + const removeImageAttachment = (id: string) => { + setStore( + produce((draft) => { + draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id) + }), + ) + } + + const handlePaste = async (event: ClipboardEvent) => { + const clipboardData = event.clipboardData + if (!clipboardData) return + + const items = Array.from(clipboardData.items) + const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (imageItems.length > 0) { + event.preventDefault() + event.stopPropagation() + for (const item of imageItems) { + const file = item.getAsFile() + if (file) await addImageAttachment(file) + } + return + } + event.preventDefault() event.stopPropagation() - // @ts-expect-error - const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? "" + const plainText = clipboardData.getData("text/plain") ?? "" addPart({ type: "text", content: plainText, start: 0, end: 0 }) } + const handleDragOver = (event: DragEvent) => { + event.preventDefault() + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + setStore("dragging", true) + } + } + + const handleDragLeave = (event: DragEvent) => { + const related = event.relatedTarget as Node | null + const form = event.currentTarget as HTMLElement + if (!related || !form.contains(related)) { + setStore("dragging", false) + } + } + + const handleDrop = async (event: DragEvent) => { + event.preventDefault() + setStore("dragging", false) + + const files = event.dataTransfer?.files + if (!files) return + + for (const file of Array.from(files)) { + if (ACCEPTED_FILE_TYPES.includes(file.type)) { + await addImageAttachment(file) + } + } + } + onMount(() => { editorRef.addEventListener("paste", handlePaste) }) @@ -167,7 +265,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (isFocused()) { handleInput() } else { - setStore("popoverIsOpen", false) + setStore("popover", null) } }) @@ -182,9 +280,70 @@ export const PromptInput: Component<PromptInputProps> = (props) => { onSelect: handleFileSelect, }) + const slashCommands = createMemo<SlashCommand[]>(() => { + const builtin = command.options + .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) + .map((opt) => ({ + id: opt.id, + trigger: opt.slash!, + title: opt.title, + description: opt.description, + keybind: opt.keybind, + type: "builtin" as const, + })) + + const custom = sync.data.command.map((cmd) => ({ + id: `custom.${cmd.name}`, + trigger: cmd.name, + title: cmd.name, + description: cmd.description, + type: "custom" as const, + })) + + return [...custom, ...builtin] + }) + + const handleSlashSelect = (cmd: SlashCommand | undefined) => { + if (!cmd) return + setStore("popover", null) + + if (cmd.type === "custom") { + const text = `/${cmd.trigger} ` + editorRef.innerHTML = "" + editorRef.textContent = text + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const sel = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + sel?.removeAllRanges() + sel?.addRange(range) + }) + return + } + + editorRef.innerHTML = "" + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + command.trigger(cmd.id, "slash") + } + + const { + flat: slashFlat, + active: slashActive, + onInput: slashOnInput, + onKeyDown: slashOnKeyDown, + } = useFilteredList<SlashCommand>({ + items: slashCommands, + key: (x) => x?.id, + filterKeys: ["trigger", "title", "description"], + onSelect: handleSlashSelect, + }) + createEffect( on( - () => session.prompt.current(), + () => prompt.current(), (currentParts) => { const domParts = parseFromDOM() if (isPromptEqual(currentParts, domParts)) return @@ -253,14 +412,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const handleInput = () => { const rawParts = parseFromDOM() const cursorPosition = getCursorPosition(editorRef) - const rawText = rawParts.map((p) => p.content).join("") + const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("") const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) + const slashMatch = rawText.match(/^\/(\S*)$/) + if (atMatch) { onInput(atMatch[1]) - setStore("popoverIsOpen", true) - } else if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) + setStore("popover", "file") + } else if (slashMatch) { + slashOnInput(slashMatch[1]) + setStore("popover", "slash") + } else { + setStore("popover", null) } if (store.historyIndex >= 0) { @@ -268,7 +432,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { setStore("savedPrompt", null) } - session.prompt.set(rawParts, cursorPosition) + prompt.set(rawParts, cursorPosition) } const addPart = (part: ContentPart) => { @@ -276,8 +440,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (!selection || selection.rangeCount === 0) return const cursorPosition = getCursorPosition(editorRef) - const prompt = session.prompt.current() - const rawText = prompt.map((p) => p.content).join("") + const currentPrompt = prompt.current() + const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("") const textBeforeCursor = rawText.substring(0, cursorPosition) const atMatch = textBeforeCursor.match(/@(\S*)$/) @@ -294,8 +458,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const range = selection.getRangeAt(0) if (atMatch) { - // let node: Node | null = range.startContainer - // let offset = range.startOffset let runningLength = 0 const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null) @@ -335,17 +497,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } handleInput() - setStore("popoverIsOpen", false) + setStore("popover", null) } const abort = () => sdk.client.session.abort({ - sessionID: session.id!, + sessionID: params.id!, }) const addToHistory = (prompt: Prompt) => { const text = prompt - .map((p) => p.content) + .map((p) => ("content" in p ? p.content : "")) .join("") .trim() if (!text) return @@ -353,7 +515,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const entry = clonePromptParts(prompt) const lastEntry = history.entries[0] if (lastEntry) { - const lastText = lastEntry.map((p) => p.content).join("") + const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("") if (lastText === text) return } @@ -367,7 +529,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { if (direction === "up") { if (entries.length === 0) return false if (current === -1) { - setStore("savedPrompt", clonePromptParts(session.prompt.current())) + setStore("savedPrompt", clonePromptParts(prompt.current())) setStore("historyIndex", 0) applyHistoryPrompt(entries[0], "start") return true @@ -403,24 +565,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { - onKeyDown(event) + if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { + if (store.popover === "file") { + onKeyDown(event) + } else { + slashOnKeyDown(event) + } event.preventDefault() return } if (event.key === "ArrowUp" || event.key === "ArrowDown") { - const { collapsed, onFirstLine, onLastLine } = getCaretLineState() + if (event.altKey || event.ctrlKey || event.metaKey) return + const { collapsed, cursorPosition, textLength } = getCaretState() if (!collapsed) return - const cursorPos = getCursorPosition(editorRef) - const textLength = promptLength(session.prompt.current()) const inHistory = store.historyIndex >= 0 - const isStart = cursorPos === 0 - const isEnd = cursorPos === textLength - const atAbsoluteStart = onFirstLine && isStart - const atAbsoluteEnd = onLastLine && isEnd - const allowUp = (inHistory && isEnd) || atAbsoluteStart - const allowDown = (inHistory && isStart) || atAbsoluteEnd + const atAbsoluteStart = cursorPosition === 0 + const atAbsoluteEnd = cursorPosition === textLength + const allowUp = (inHistory && atAbsoluteEnd) || atAbsoluteStart + const allowDown = (inHistory && atAbsoluteStart) || atAbsoluteEnd if (event.key === "ArrowUp") { if (!allowUp) return @@ -441,9 +604,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => { handleSubmit(event) } if (event.key === "Escape") { - if (store.popoverIsOpen) { - setStore("popoverIsOpen", false) - } else if (session.working()) { + if (store.popover) { + setStore("popover", null) + } else if (working()) { abort() } } @@ -451,18 +614,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => { const handleSubmit = async (event: Event) => { event.preventDefault() - const prompt = session.prompt.current() - const text = prompt.map((part) => part.content).join("") - if (text.trim().length === 0) { - if (session.working()) abort() + const currentPrompt = prompt.current() + const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("") + const hasImageAttachments = store.imageAttachments.length > 0 + if (text.trim().length === 0 && !hasImageAttachments) { + if (working()) abort() return } - addToHistory(prompt) + addToHistory(currentPrompt) setStore("historyIndex", -1) setStore("savedPrompt", null) - let existing = session.info() + let existing = info() if (!existing) { const created = await sdk.client.session.create() existing = created.data ?? undefined @@ -470,32 +634,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } if (!existing) return - // if (!session.id) { - // session.layout.setOpenedTabs( - // session.layout.copyTabs("", session.id) - // } - const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - const attachments = prompt.filter((part) => part.type === "file") - - // const activeFile = local.context.active() - // if (activeFile) { - // registerAttachment( - // activeFile.path, - // activeFile.selection, - // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection), - // ) - // } - - // for (const contextFile of local.context.all()) { - // registerAttachment( - // contextFile.path, - // contextFile.selection, - // formatAttachmentLabel(contextFile.path, contextFile.selection), - // ) - // } - - const attachmentParts = attachments.map((attachment) => { + const attachments = currentPrompt.filter( + (part) => part.type === "file", + ) as import("@/context/prompt").FileAttachmentPart[] + + const fileAttachmentParts = attachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` @@ -517,11 +661,33 @@ export const PromptInput: Component<PromptInputProps> = (props) => { } }) - session.layout.setActiveTab(undefined) - session.messages.setActive(undefined) - // Clear the editor DOM directly to ensure it's empty + const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ + type: "file" as const, + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + })) + + tabs().setActive(undefined) editorRef.innerHTML = "" - session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + setStore("imageAttachments", []) + + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) + const customCommand = sync.data.command.find((c) => c.name === commandName) + if (customCommand) { + sdk.client.session.command({ + sessionID: existing.id, + command: commandName, + arguments: args.join(" "), + agent: local.agent.current()!.name, + model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`, + }) + return + } + } sdk.client.session.prompt({ sessionID: existing.id, @@ -535,55 +701,136 @@ export const PromptInput: Component<PromptInputProps> = (props) => { type: "text", text, }, - ...attachmentParts, + ...fileAttachmentParts, + ...imageAttachmentParts, ], }) } return ( <div class="relative size-full _max-h-[320px] flex flex-col gap-3"> - <Show when={store.popoverIsOpen}> + <Show when={store.popover}> <div - class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 pb-0 rounded-md + class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 + overflow-auto no-scrollbar flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" > - <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}> - <For each={flat()}> - {(i) => ( - <button - classList={{ - "w-full flex items-center justify-between rounded-md": true, - "bg-surface-raised-base-hover": active() === i, - }} - onClick={() => handleFileSelect(i)} - > - <div class="flex items-center gap-x-2 grow min-w-0"> - <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> - <div class="flex items-center text-14-regular"> - <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0"> - {getDirectory(i)} - </span> - <Show when={!i.endsWith("/")}> - <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> - </Show> - </div> - </div> - <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div> - </button> - )} - </For> - </Show> + <Switch> + <Match when={store.popover === "file"}> + <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}> + <For each={flat()}> + {(i) => ( + <button + classList={{ + "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, + "bg-surface-raised-base-hover": active() === i, + }} + onClick={() => handleFileSelect(i)} + > + <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" /> + <div class="flex items-center text-14-regular min-w-0"> + <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span> + <Show when={!i.endsWith("/")}> + <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span> + </Show> + </div> + </button> + )} + </For> + </Show> + </Match> + <Match when={store.popover === "slash"}> + <Show + when={slashFlat().length > 0} + fallback={<div class="text-text-weak px-2 py-1">No matching commands</div>} + > + <For each={slashFlat()}> + {(cmd) => ( + <button + classList={{ + "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true, + "bg-surface-raised-base-hover": slashActive() === cmd.id, + }} + onClick={() => handleSlashSelect(cmd)} + > + <div class="flex items-center gap-2 min-w-0"> + <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span> + <Show when={cmd.description}> + <span class="text-14-regular text-text-weak truncate">{cmd.description}</span> + </Show> + </div> + <div class="flex items-center gap-2 shrink-0"> + <Show when={cmd.type === "custom"}> + <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded"> + custom + </span> + </Show> + <Show when={cmd.keybind}> + <span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span> + </Show> + </div> + </button> + )} + </For> + </Show> + </Match> + </Switch> </div> </Show> <form onSubmit={handleSubmit} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} classList={{ - "bg-surface-raised-stronger-non-alpha shadow-xs-border": true, + "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, "rounded-md overflow-clip focus-within:shadow-xs-border": true, + "border-icon-info-active border-dashed": store.dragging, [props.class ?? ""]: !!props.class, }} > + <Show when={store.dragging}> + <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none"> + <div class="flex flex-col items-center gap-2 text-text-weak"> + <Icon name="photo" class="size-8" /> + <span class="text-14-regular">Drop images or PDFs here</span> + </div> + </div> + </Show> + <Show when={store.imageAttachments.length > 0}> + <div class="flex flex-wrap gap-2 px-3 pt-3"> + <For each={store.imageAttachments}> + {(attachment) => ( + <div class="relative group"> + <Show + when={attachment.mime.startsWith("image/")} + fallback={ + <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"> + <Icon name="folder" class="size-6 text-text-weak" /> + </div> + } + > + <img + src={attachment.dataUrl} + alt={attachment.filename} + class="size-16 rounded-md object-cover border border-border-base" + /> + </Show> + <button + type="button" + onClick={() => removeImageAttachment(attachment.id)} + class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" + > + <Icon name="close" class="size-3 text-text-weak" /> + </button> + <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"> + <span class="text-10-regular text-white truncate block">{attachment.filename}</span> + </div> + </div> + )} + </For> + </div> + </Show> <div class="relative max-h-[240px] overflow-y-auto"> <div ref={(el) => { @@ -598,7 +845,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { "[&>[data-type=file]]:text-icon-info-active": true, }} /> - <Show when={!session.prompt.dirty()}> + <Show when={!prompt.dirty() && store.imageAttachments.length === 0}> <div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none"> Ask anything... "{PLACEHOLDERS[store.placeholder]}" </div> @@ -617,7 +864,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { as="div" variant="ghost" onClick={() => - dialog.push(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />)) + dialog.show(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />)) } > {local.model.current()?.name ?? "Select model"} @@ -625,34 +872,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => { <Icon name="chevron-down" size="small" /> </Button> </div> - <Tooltip - placement="top" - inactive={!session.prompt.dirty() && !session.working()} - value={ - <Switch> - <Match when={session.working()}> - <div class="flex items-center gap-2"> - <span>Stop</span> - <span class="text-icon-base text-12-medium text-[10px]!">ESC</span> - </div> - </Match> - <Match when={true}> - <div class="flex items-center gap-2"> - <span>Send</span> - <Icon name="enter" size="small" class="text-icon-base" /> - </div> - </Match> - </Switch> - } - > - <IconButton - type="submit" - disabled={!session.prompt.dirty() && !session.working()} - icon={session.working() ? "stop" : "arrow-up"} - variant="primary" - class="h-10 w-8 absolute right-2 bottom-2" + <div class="flex items-center gap-1 absolute right-2 bottom-2"> + <input + ref={fileInputRef} + type="file" + accept={ACCEPTED_IMAGE_TYPES.join(",")} + class="hidden" + onChange={(e) => { + const file = e.currentTarget.files?.[0] + if (file) addImageAttachment(file) + e.currentTarget.value = "" + }} /> - </Tooltip> + <Tooltip placement="top" value="Attach image"> + <IconButton + type="button" + icon="photo" + variant="ghost" + class="h-10 w-8" + onClick={() => fileInputRef.click()} + /> + </Tooltip> + <Tooltip + placement="top" + inactive={!prompt.dirty() && !working()} + value={ + <Switch> + <Match when={working()}> + <div class="flex items-center gap-2"> + <span>Stop</span> + <span class="text-icon-base text-12-medium text-[10px]!">ESC</span> + </div> + </Match> + <Match when={true}> + <div class="flex items-center gap-2"> + <span>Send</span> + <Icon name="enter" size="small" class="text-icon-base" /> + </div> + </Match> + </Switch> + } + > + <IconButton + type="submit" + disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()} + icon={working() ? "stop" : "arrow-up"} + variant="primary" + class="h-10 w-8" + /> + </Tooltip> + </div> </div> </form> </div> diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx index 865d9b30f..082525e28 100644 --- a/packages/desktop/src/components/terminal.tsx +++ b/packages/desktop/src/components/terminal.tsx @@ -2,7 +2,7 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" -import { LocalPTY } from "@/context/session" +import { LocalPTY } from "@/context/terminal" import { usePrefersDark } from "@solid-primitives/media" export interface TerminalProps extends ComponentProps<"div"> { |
