Skip to content

Commit 268f37f

Browse files
committed
fix(desktop): prompt history nav, optimistic prompt dup
1 parent b0aaf04 commit 268f37f

File tree

7 files changed

+157
-124
lines changed

7 files changed

+157
-124
lines changed

packages/desktop/src/components/prompt-input.tsx

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid
2121
import { useProviders } from "@/hooks/use-providers"
2222
import { useCommand, formatKeybind } from "@/context/command"
2323
import { persisted } from "@/utils/persist"
24+
import { Identifier } from "@opencode-ai/util/identifier"
2425

2526
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
2627
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -100,6 +101,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
100101
dragging: boolean
101102
imageAttachments: ImageAttachmentPart[]
102103
mode: "normal" | "shell"
104+
applyingHistory: boolean
103105
}>({
104106
popover: null,
105107
historyIndex: -1,
@@ -108,6 +110,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
108110
dragging: false,
109111
imageAttachments: [],
110112
mode: "normal",
113+
applyingHistory: false,
111114
})
112115

113116
const MAX_HISTORY = 100
@@ -135,10 +138,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
135138

136139
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
137140
const length = position === "start" ? 0 : promptLength(p)
141+
setStore("applyingHistory", true)
138142
prompt.set(p, length)
139143
requestAnimationFrame(() => {
140144
editorRef.focus()
141145
setCursorPosition(editorRef, length)
146+
setStore("applyingHistory", false)
142147
})
143148
}
144149

@@ -429,21 +434,42 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
429434
const rawParts = parseFromDOM()
430435
const cursorPosition = getCursorPosition(editorRef)
431436
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
437+
const trimmed = rawText.replace(/\u200B/g, "").trim()
438+
const hasNonText = rawParts.some((part) => part.type !== "text")
439+
const shouldReset = trimmed.length === 0 && !hasNonText
432440

433-
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
434-
const slashMatch = rawText.match(/^\/(\S*)$/)
441+
if (shouldReset) {
442+
setStore("popover", null)
443+
if (store.historyIndex >= 0 && !store.applyingHistory) {
444+
setStore("historyIndex", -1)
445+
setStore("savedPrompt", null)
446+
}
447+
if (prompt.dirty()) {
448+
prompt.set(DEFAULT_PROMPT, 0)
449+
}
450+
return
451+
}
435452

436-
if (atMatch) {
437-
onInput(atMatch[1])
438-
setStore("popover", "file")
439-
} else if (slashMatch) {
440-
slashOnInput(slashMatch[1])
441-
setStore("popover", "slash")
453+
const shellMode = store.mode === "shell"
454+
455+
if (!shellMode) {
456+
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
457+
const slashMatch = rawText.match(/^\/(\S*)$/)
458+
459+
if (atMatch) {
460+
onInput(atMatch[1])
461+
setStore("popover", "file")
462+
} else if (slashMatch) {
463+
slashOnInput(slashMatch[1])
464+
setStore("popover", "slash")
465+
} else {
466+
setStore("popover", null)
467+
}
442468
} else {
443469
setStore("popover", null)
444470
}
445471

446-
if (store.historyIndex >= 0) {
472+
if (store.historyIndex >= 0 && !store.applyingHistory) {
447473
setStore("historyIndex", -1)
448474
setStore("savedPrompt", null)
449475
}
@@ -591,8 +617,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
591617
}
592618
}
593619
if (store.mode === "shell") {
594-
const cursorPosition = getCursorPosition(editorRef)
595-
if ((event.key === "Backspace" && cursorPosition === 0) || event.key === "Escape") {
620+
const { collapsed, cursorPosition, textLength } = getCaretState()
621+
if (event.key === "Escape") {
622+
setStore("mode", "normal")
623+
event.preventDefault()
624+
return
625+
}
626+
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
596627
setStore("mode", "normal")
597628
event.preventDefault()
598629
return
@@ -685,6 +716,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
685716
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
686717
: ""
687718
return {
719+
id: Identifier.ascending("part"),
688720
type: "file" as const,
689721
mime: "text/plain",
690722
url: `file://${absolute}${query}`,
@@ -702,6 +734,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
702734
})
703735

704736
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
737+
id: Identifier.ascending("part"),
705738
type: "file" as const,
706739
mime: attachment.mime,
707740
url: attachment.dataUrl,
@@ -747,14 +780,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
747780
}
748781
}
749782

783+
const messageID = Identifier.ascending("message")
784+
const textPart = {
785+
id: Identifier.ascending("part"),
786+
type: "text" as const,
787+
text,
788+
}
789+
const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
790+
const optimisticParts = requestParts.map((part) => ({
791+
...part,
792+
sessionID: existing.id,
793+
messageID,
794+
}))
795+
750796
sync.session.addOptimisticMessage({
751797
sessionID: existing.id,
752-
text,
753-
parts: [
754-
{ type: "text", text } as import("@opencode-ai/sdk/v2/client").Part,
755-
...(fileAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]),
756-
...(imageAttachmentParts as import("@opencode-ai/sdk/v2/client").Part[]),
757-
],
798+
messageID,
799+
parts: optimisticParts,
758800
agent,
759801
model,
760802
})
@@ -763,14 +805,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
763805
sessionID: existing.id,
764806
agent,
765807
model,
766-
parts: [
767-
{
768-
type: "text",
769-
text,
770-
},
771-
...fileAttachmentParts,
772-
...imageAttachmentParts,
773-
],
808+
messageID,
809+
parts: requestParts,
774810
})
775811
}
776812

@@ -911,6 +947,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
911947
classList={{
912948
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
913949
"[&>[data-type=file]]:text-icon-info-active": true,
950+
"font-mono!": store.mode === "shell",
914951
}}
915952
/>
916953
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>

packages/desktop/src/components/terminal.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export const Terminal = (props: TerminalProps) => {
148148
<div
149149
ref={container}
150150
data-component="terminal"
151+
data-prevent-autofocus
151152
classList={{
152153
...(local.classList ?? {}),
153154
"size-full px-6 py-3 font-mono": true,

packages/desktop/src/context/sync.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
3333
},
3434
addOptimisticMessage(input: {
3535
sessionID: string
36-
text: string
36+
messageID: string
3737
parts: Part[]
3838
agent: string
3939
model: { providerID: string; modelID: string }
4040
}) {
41-
const messageID = crypto.randomUUID()
4241
const message: Message = {
43-
id: messageID,
42+
id: input.messageID,
4443
sessionID: input.sessionID,
4544
role: "user",
4645
time: { created: Date.now() },
@@ -53,15 +52,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
5352
if (!messages) {
5453
draft.message[input.sessionID] = [message]
5554
} else {
56-
const result = Binary.search(messages, messageID, (m) => m.id)
55+
const result = Binary.search(messages, input.messageID, (m) => m.id)
5756
messages.splice(result.index, 0, message)
5857
}
59-
draft.part[messageID] = input.parts.map((part, i) => ({
60-
...part,
61-
id: `${messageID}-${i}`,
62-
sessionID: input.sessionID,
63-
messageID,
64-
}))
58+
draft.part[input.messageID] = input.parts.slice()
6559
}),
6660
)
6761
},

packages/desktop/src/pages/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ export default function Layout(props: ParentProps) {
358358
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
359359

360360
return (
361-
<div class="relative size-5 shrink-0 rounded-sm overflow-hidden">
361+
<div class="relative size-5 shrink-0 rounded-sm">
362362
<Avatar
363363
fallback={name()}
364364
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}

packages/desktop/src/pages/session.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,11 +327,15 @@ export default function Page() {
327327
])
328328

329329
const handleKeyDown = (event: KeyboardEvent) => {
330-
if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
330+
const activeElement = document.activeElement as HTMLElement | undefined
331+
if (activeElement) {
332+
const isProtected = activeElement.closest("[data-prevent-autofocus]")
333+
const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable
334+
if (isProtected || isInput) return
335+
}
331336
if (dialog.active) return
332337

333-
const focused = document.activeElement === inputRef
334-
if (focused) {
338+
if (activeElement === inputRef) {
335339
if (event.key === "Escape") inputRef?.blur()
336340
return
337341
}

packages/opencode/src/id/id.ts

Lines changed: 9 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,19 @@
1-
import z from "zod"
2-
import { randomBytes } from "crypto"
1+
import { Identifier as SharedIdentifier } from "@opencode-ai/util/identifier"
32

43
export namespace Identifier {
5-
const prefixes = {
6-
session: "ses",
7-
message: "msg",
8-
permission: "per",
9-
user: "usr",
10-
part: "prt",
11-
pty: "pty",
12-
} as const
4+
export type Prefix = SharedIdentifier.Prefix
135

14-
export function schema(prefix: keyof typeof prefixes) {
15-
return z.string().startsWith(prefixes[prefix])
16-
}
17-
18-
const LENGTH = 26
6+
export const schema = (prefix: Prefix) => SharedIdentifier.schema(prefix)
197

20-
// State for monotonic ID generation
21-
let lastTimestamp = 0
22-
let counter = 0
23-
24-
export function ascending(prefix: keyof typeof prefixes, given?: string) {
25-
return generateID(prefix, false, given)
8+
export function ascending(prefix: Prefix, given?: string) {
9+
return SharedIdentifier.ascending(prefix, given)
2610
}
2711

28-
export function descending(prefix: keyof typeof prefixes, given?: string) {
29-
return generateID(prefix, true, given)
12+
export function descending(prefix: Prefix, given?: string) {
13+
return SharedIdentifier.descending(prefix, given)
3014
}
3115

32-
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
33-
if (!given) {
34-
return create(prefix, descending)
35-
}
36-
37-
if (!given.startsWith(prefixes[prefix])) {
38-
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
39-
}
40-
return given
41-
}
42-
43-
function randomBase62(length: number): string {
44-
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
45-
let result = ""
46-
const bytes = randomBytes(length)
47-
for (let i = 0; i < length; i++) {
48-
result += chars[bytes[i] % 62]
49-
}
50-
return result
51-
}
52-
53-
export function create(prefix: keyof typeof prefixes, descending: boolean, timestamp?: number): string {
54-
const currentTimestamp = timestamp ?? Date.now()
55-
56-
if (currentTimestamp !== lastTimestamp) {
57-
lastTimestamp = currentTimestamp
58-
counter = 0
59-
}
60-
counter++
61-
62-
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
63-
64-
now = descending ? ~now : now
65-
66-
const timeBytes = Buffer.alloc(6)
67-
for (let i = 0; i < 6; i++) {
68-
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
69-
}
70-
71-
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
16+
export function create(prefix: Prefix, descending: boolean, timestamp?: number) {
17+
return SharedIdentifier.createPrefixed(prefix, descending, timestamp)
7218
}
7319
}

0 commit comments

Comments
 (0)