summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/components
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-12-16 10:54:51 +0000
committerDavid Hill <[email protected]>2025-12-16 10:54:51 +0000
commit05e0759878cb0f24c981c69ae26f6be3ea5583c6 (patch)
tree39aaf6b86a6f2b8e653120e80d8f0facb528051b /packages/desktop/src/components
parent2330ec6dc3000ae8b86810e9d59b414ad4f05f47 (diff)
parent75e5130cf8f58b32ee3f3ba2249d5917e7e3d6fc (diff)
downloadopencode-05e0759878cb0f24c981c69ae26f6be3ea5583c6.tar.gz
opencode-05e0759878cb0f24c981c69ae26f6be3ea5583c6.zip
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/desktop/src/components')
-rw-r--r--packages/desktop/src/components/dialog-connect-provider.tsx22
-rw-r--r--packages/desktop/src/components/dialog-select-file.tsx13
-rw-r--r--packages/desktop/src/components/dialog-select-model-unpaid.tsx6
-rw-r--r--packages/desktop/src/components/dialog-select-model.tsx6
-rw-r--r--packages/desktop/src/components/dialog-select-provider.tsx2
-rw-r--r--packages/desktop/src/components/prompt-input.tsx611
-rw-r--r--packages/desktop/src/components/terminal.tsx2
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"> {