summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-18 09:38:35 -0600
committerAdam <[email protected]>2025-12-18 11:16:33 -0600
commit268f37f8c9813b3805e508d37c20fec6e6e12cb1 (patch)
tree50c24c2763e1a69cceef19f6e675d0bb6e17bca9 /packages/desktop/src
parentb0aaf04957336538dc49d2b0416f44dfbd538531 (diff)
downloadopencode-268f37f8c9813b3805e508d37c20fec6e6e12cb1.tar.gz
opencode-268f37f8c9813b3805e508d37c20fec6e6e12cb1.zip
fix(desktop): prompt history nav, optimistic prompt dup
Diffstat (limited to 'packages/desktop/src')
-rw-r--r--packages/desktop/src/components/prompt-input.tsx87
-rw-r--r--packages/desktop/src/components/terminal.tsx1
-rw-r--r--packages/desktop/src/context/sync.tsx14
-rw-r--r--packages/desktop/src/pages/layout.tsx2
-rw-r--r--packages/desktop/src/pages/session.tsx10
5 files changed, 75 insertions, 39 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 02fa700bf..98092f5d5 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -21,6 +21,7 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
import { useProviders } from "@/hooks/use-providers"
import { useCommand, formatKeybind } from "@/context/command"
import { persisted } from "@/utils/persist"
+import { Identifier } from "@opencode-ai/util/identifier"
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -100,6 +101,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
dragging: boolean
imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell"
+ applyingHistory: boolean
}>({
popover: null,
historyIndex: -1,
@@ -108,6 +110,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
dragging: false,
imageAttachments: [],
mode: "normal",
+ applyingHistory: false,
})
const MAX_HISTORY = 100
@@ -135,10 +138,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p)
+ setStore("applyingHistory", true)
prompt.set(p, length)
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, length)
+ setStore("applyingHistory", false)
})
}
@@ -429,21 +434,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawParts = parseFromDOM()
const cursorPosition = getCursorPosition(editorRef)
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
+ const trimmed = rawText.replace(/\u200B/g, "").trim()
+ const hasNonText = rawParts.some((part) => part.type !== "text")
+ const shouldReset = trimmed.length === 0 && !hasNonText
- const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
- const slashMatch = rawText.match(/^\/(\S*)$/)
+ if (shouldReset) {
+ setStore("popover", null)
+ if (store.historyIndex >= 0 && !store.applyingHistory) {
+ setStore("historyIndex", -1)
+ setStore("savedPrompt", null)
+ }
+ if (prompt.dirty()) {
+ prompt.set(DEFAULT_PROMPT, 0)
+ }
+ return
+ }
- if (atMatch) {
- onInput(atMatch[1])
- setStore("popover", "file")
- } else if (slashMatch) {
- slashOnInput(slashMatch[1])
- setStore("popover", "slash")
+ const shellMode = store.mode === "shell"
+
+ if (!shellMode) {
+ const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
+ const slashMatch = rawText.match(/^\/(\S*)$/)
+
+ if (atMatch) {
+ onInput(atMatch[1])
+ setStore("popover", "file")
+ } else if (slashMatch) {
+ slashOnInput(slashMatch[1])
+ setStore("popover", "slash")
+ } else {
+ setStore("popover", null)
+ }
} else {
setStore("popover", null)
}
- if (store.historyIndex >= 0) {
+ if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
@@ -591,8 +617,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
if (store.mode === "shell") {
- const cursorPosition = getCursorPosition(editorRef)
- if ((event.key === "Backspace" && cursorPosition === 0) || event.key === "Escape") {
+ const { collapsed, cursorPosition, textLength } = getCaretState()
+ if (event.key === "Escape") {
+ setStore("mode", "normal")
+ event.preventDefault()
+ return
+ }
+ if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
setStore("mode", "normal")
event.preventDefault()
return
@@ -685,6 +716,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
: ""
return {
+ id: Identifier.ascending("part"),
type: "file" as const,
mime: "text/plain",
url: `file://${absolute}${query}`,
@@ -702,6 +734,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
+ id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
@@ -747,14 +780,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
+ const messageID = Identifier.ascending("message")
+ const textPart = {
+ id: Identifier.ascending("part"),
+ type: "text" as const,
+ text,
+ }
+ const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
+ const optimisticParts = requestParts.map((part) => ({
+ ...part,
+ sessionID: existing.id,
+ messageID,
+ }))
+
sync.session.addOptimisticMessage({
sessionID: existing.id,
- text,
- parts: [
- { type: "text", text } as import("@opencode-ai/sdk/v2/client").Part,
- ...(fileAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]),
- ...(imageAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]),
- ],
+ messageID,
+ parts: optimisticParts,
agent,
model,
})
@@ -763,14 +805,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
sessionID: existing.id,
agent,
model,
- parts: [
- {
- type: "text",
- text,
- },
- ...fileAttachmentParts,
- ...imageAttachmentParts,
- ],
+ messageID,
+ parts: requestParts,
})
}
@@ -911,6 +947,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
classList={{
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&>[data-type=file]]:text-icon-info-active": true,
+ "font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx
index 2156b10ee..c05ddfbf6 100644
--- a/packages/desktop/src/components/terminal.tsx
+++ b/packages/desktop/src/components/terminal.tsx
@@ -148,6 +148,7 @@ export const Terminal = (props: TerminalProps) => {
<div
ref={container}
data-component="terminal"
+ data-prevent-autofocus
classList={{
...(local.classList ?? {}),
"size-full px-6 py-3 font-mono": true,
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index cb782c24b..ca25cae98 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -33,14 +33,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
addOptimisticMessage(input: {
sessionID: string
- text: string
+ messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
}) {
- const messageID = crypto.randomUUID()
const message: Message = {
- id: messageID,
+ id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
@@ -53,15 +52,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (!messages) {
draft.message[input.sessionID] = [message]
} else {
- const result = Binary.search(messages, messageID, (m) => m.id)
+ const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
- draft.part[messageID] = input.parts.map((part, i) => ({
- ...part,
- id: `${messageID}-${i}`,
- sessionID: input.sessionID,
- messageID,
- }))
+ draft.part[input.messageID] = input.parts.slice()
}),
)
},
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 387f2415f..5f4a5d797 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -358,7 +358,7 @@ export default function Layout(props: ParentProps) {
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
- <div class="relative size-5 shrink-0 rounded-sm overflow-hidden">
+ <div class="relative size-5 shrink-0 rounded-sm">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index c9f24518e..c3cc31b5c 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -327,11 +327,15 @@ export default function Page() {
])
const handleKeyDown = (event: KeyboardEvent) => {
- if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
+ const activeElement = document.activeElement as HTMLElement | undefined
+ if (activeElement) {
+ const isProtected = activeElement.closest("[data-prevent-autofocus]")
+ const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable
+ if (isProtected || isInput) return
+ }
if (dialog.active) return
- const focused = document.activeElement === inputRef
- if (focused) {
+ if (activeElement === inputRef) {
if (event.key === "Escape") inputRef?.blur()
return
}