void
}
@@ -197,7 +182,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended()) return
- // Check for command palette keybind (mod+shift+p)
const paletteKeybinds = parseKeybind("mod+shift+p")
if (matchKeybind(paletteKeybinds, event)) {
event.preventDefault()
@@ -205,7 +189,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return
}
- // Check registered command keybinds
for (const option of options()) {
if (option.disabled) continue
if (!option.keybind) continue
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index d49779587..9e743e48f 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -49,7 +49,6 @@ export default function Page() {
const params = useParams()
const navigate = useNavigate()
- // Session-specific derived state
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
@@ -132,7 +131,6 @@ export default function Page() {
}
})
- // Register commands for this page
command.register(() => [
{
id: "session.new",
@@ -230,28 +228,17 @@ export default function Page() {
},
])
- // Handle keyboard events that aren't commands
const handleKeyDown = (event: KeyboardEvent) => {
- // Don't interfere with terminal
// @ts-expect-error
- if (document.activeElement?.dataset?.component === "terminal") {
- return
- }
-
- // Don't interfere with dialogs
- if (dialog.stack.length > 0) {
- return
- }
+ if (document.activeElement?.dataset?.component === "terminal") return
+ if (dialog.stack.length > 0) return
const focused = document.activeElement === inputRef
if (focused) {
- if (event.key === "Escape") {
- inputRef?.blur()
- }
+ if (event.key === "Escape") inputRef?.blur()
return
}
- // Focus input when typing characters
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 807092d03..f905abbd1 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -228,7 +228,6 @@ export function SessionTurn(
duration: duration(),
})
- // Sync with controlled prop
createEffect(() => {
if (props.stepsExpanded !== undefined) {
setStore("stepsExpanded", props.stepsExpanded)
--
cgit v1.2.3
From 5eaa8e1bf4862bfc64f114f7e9b31fc22e79be44 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 07:18:15 -0600
Subject: chore: cleanup
---
packages/desktop/src/pages/session.tsx | 13 +++++++++++++
1 file changed, 13 insertions(+)
(limited to 'packages/desktop/src')
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 9e743e48f..05a9e8a1d 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -233,6 +233,19 @@ export default function Page() {
if (document.activeElement?.dataset?.component === "terminal") return
if (dialog.stack.length > 0) return
+ if (event.key === "PageUp" || event.key === "PageDown") {
+ const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement
+ if (scrollContainer) {
+ event.preventDefault()
+ const scrollAmount = scrollContainer.clientHeight * 0.8
+ scrollContainer.scrollBy({
+ top: event.key === "PageUp" ? -scrollAmount : scrollAmount,
+ behavior: "instant",
+ })
+ }
+ return
+ }
+
const focused = document.activeElement === inputRef
if (focused) {
if (event.key === "Escape") inputRef?.blur()
--
cgit v1.2.3
From 44d6c5780d41616bf29a749020c9d7f98895407f Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 07:25:24 -0600
Subject: wip(desktop): progress
---
packages/desktop/src/components/prompt-input.tsx | 47 ++++++++----------------
1 file changed, 16 insertions(+), 31 deletions(-)
(limited to 'packages/desktop/src')
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 9be09507a..37d05c311 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -133,31 +133,20 @@ export const PromptInput: Component
= (props) => {
})
}
- 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,
}
}
@@ -505,17 +494,13 @@ export const PromptInput: Component = (props) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
if (event.altKey || event.ctrlKey || event.metaKey) return
- const { collapsed, onFirstLine, onLastLine } = getCaretLineState()
+ const { collapsed, cursorPosition, textLength } = getCaretState()
if (!collapsed) return
- const cursorPos = getCursorPosition(editorRef)
- const textLength = promptLength(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
--
cgit v1.2.3
From 5cf6a1343c6ca088bd2b586197faf7fe58961290 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 09:34:00 -0600
Subject: wip(desktop): progress
---
packages/desktop/src/components/prompt-input.tsx | 191 +++++++++++++++++---
packages/desktop/src/context/global-sync.tsx | 40 ++++-
packages/desktop/src/context/local.tsx | 2 +-
packages/desktop/src/context/prompt.tsx | 14 +-
packages/desktop/src/pages/layout.tsx | 211 +++++++++++++++--------
packages/desktop/src/pages/session.tsx | 148 +++++++++++-----
packages/desktop/src/utils/prompt.ts | 47 +++++
packages/ui/src/components/message-part.css | 76 +++++++-
packages/ui/src/components/message-part.tsx | 96 ++++++++++-
packages/ui/src/components/session-turn.tsx | 10 +-
10 files changed, 676 insertions(+), 159 deletions(-)
create mode 100644 packages/desktop/src/utils/prompt.ts
(limited to 'packages/desktop/src')
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 37d05c311..f3f758102 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -1,10 +1,10 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
-import { createStore } from "solid-js/store"
+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, usePrompt } from "@/context/prompt"
+import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useNavigate, useParams } from "@solidjs/router"
@@ -22,6 +22,9 @@ 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
ref?: (el: HTMLDivElement) => void
@@ -93,11 +96,15 @@ export const PromptInput: Component = (props) => {
historyIndex: number
savedPrompt: Prompt | null
placeholder: number
+ dragging: boolean
+ imageAttachments: ImageAttachmentPart[]
}>({
popover: null,
historyIndex: -1,
savedPrompt: null,
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
+ dragging: false,
+ imageAttachments: [],
})
const MAX_HISTORY = 100
@@ -113,16 +120,17 @@ export const PromptInput: Component = (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 = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p)
@@ -162,14 +170,89 @@ export const PromptInput: Component = (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)
})
@@ -328,7 +411,7 @@ export const PromptInput: Component = (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*)$/)
// Slash commands only trigger when / is at the start of input
@@ -358,7 +441,7 @@ export const PromptInput: Component = (props) => {
const cursorPosition = getCursorPosition(editorRef)
const currentPrompt = prompt.current()
- const rawText = currentPrompt.map((p) => p.content).join("")
+ const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
@@ -424,7 +507,7 @@ export const PromptInput: Component = (props) => {
const addToHistory = (prompt: Prompt) => {
const text = prompt
- .map((p) => p.content)
+ .map((p) => ("content" in p ? p.content : ""))
.join("")
.trim()
if (!text) return
@@ -432,7 +515,7 @@ export const PromptInput: Component = (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
}
@@ -532,8 +615,9 @@ export const PromptInput: Component = (props) => {
const handleSubmit = async (event: Event) => {
event.preventDefault()
const currentPrompt = prompt.current()
- const text = currentPrompt.map((part) => part.content).join("")
- if (text.trim().length === 0) {
+ 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
}
@@ -555,7 +639,7 @@ export const PromptInput: Component = (props) => {
(part) => part.type === "file",
) as import("@/context/prompt").FileAttachmentPart[]
- const attachmentParts = attachments.map((attachment) => {
+ const fileAttachmentParts = attachments.map((attachment) => {
const absolute = toAbsolutePath(attachment.path)
const query = attachment.selection
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
@@ -577,9 +661,17 @@ export const PromptInput: Component = (props) => {
}
})
+ const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
+ type: "file" as const,
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ }))
+
tabs().setActive(undefined)
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+ setStore("imageAttachments", [])
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
@@ -609,7 +701,8 @@ export const PromptInput: Component = (props) => {
type: "text",
text,
},
- ...attachmentParts,
+ ...fileAttachmentParts,
+ ...imageAttachmentParts,
],
})
}
@@ -686,12 +779,58 @@ export const PromptInput: Component = (props) => {
+