diff options
| author | Adam <[email protected]> | 2025-12-18 09:38:35 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-18 11:16:33 -0600 |
| commit | 268f37f8c9813b3805e508d37c20fec6e6e12cb1 (patch) | |
| tree | 50c24c2763e1a69cceef19f6e675d0bb6e17bca9 | |
| parent | b0aaf04957336538dc49d2b0416f44dfbd538531 (diff) | |
| download | opencode-268f37f8c9813b3805e508d37c20fec6e6e12cb1.tar.gz opencode-268f37f8c9813b3805e508d37c20fec6e6e12cb1.zip | |
fix(desktop): prompt history nav, optimistic prompt dup
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 87 | ||||
| -rw-r--r-- | packages/desktop/src/components/terminal.tsx | 1 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 14 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 10 | ||||
| -rw-r--r-- | packages/opencode/src/id/id.ts | 72 | ||||
| -rw-r--r-- | packages/util/src/identifier.ts | 95 |
7 files changed, 157 insertions, 124 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 } diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index ad6e22e1b..dea89894f 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -1,73 +1,19 @@ -import z from "zod" -import { randomBytes } from "crypto" +import { Identifier as SharedIdentifier } from "@opencode-ai/util/identifier" export namespace Identifier { - const prefixes = { - session: "ses", - message: "msg", - permission: "per", - user: "usr", - part: "prt", - pty: "pty", - } as const + export type Prefix = SharedIdentifier.Prefix - export function schema(prefix: keyof typeof prefixes) { - return z.string().startsWith(prefixes[prefix]) - } - - const LENGTH = 26 + export const schema = (prefix: Prefix) => SharedIdentifier.schema(prefix) - // State for monotonic ID generation - let lastTimestamp = 0 - let counter = 0 - - export function ascending(prefix: keyof typeof prefixes, given?: string) { - return generateID(prefix, false, given) + export function ascending(prefix: Prefix, given?: string) { + return SharedIdentifier.ascending(prefix, given) } - export function descending(prefix: keyof typeof prefixes, given?: string) { - return generateID(prefix, true, given) + export function descending(prefix: Prefix, given?: string) { + return SharedIdentifier.descending(prefix, given) } - function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string { - if (!given) { - return create(prefix, descending) - } - - if (!given.startsWith(prefixes[prefix])) { - throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`) - } - return given - } - - function randomBase62(length: number): string { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - let result = "" - const bytes = randomBytes(length) - for (let i = 0; i < length; i++) { - result += chars[bytes[i] % 62] - } - return result - } - - export function create(prefix: keyof typeof prefixes, descending: boolean, timestamp?: number): string { - const currentTimestamp = timestamp ?? Date.now() - - if (currentTimestamp !== lastTimestamp) { - lastTimestamp = currentTimestamp - counter = 0 - } - counter++ - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) - - now = descending ? ~now : now - - const timeBytes = Buffer.alloc(6) - for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) - } - - return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) + export function create(prefix: Prefix, descending: boolean, timestamp?: number) { + return SharedIdentifier.createPrefixed(prefix, descending, timestamp) } } diff --git a/packages/util/src/identifier.ts b/packages/util/src/identifier.ts index ba28a351b..272507f0a 100644 --- a/packages/util/src/identifier.ts +++ b/packages/util/src/identifier.ts @@ -1,48 +1,99 @@ -import { randomBytes } from "crypto" +import z from "zod" export namespace Identifier { - const LENGTH = 26 + const prefixes = { + session: "ses", + message: "msg", + permission: "per", + user: "usr", + part: "prt", + pty: "pty", + } as const + + export type Prefix = keyof typeof prefixes + type CryptoLike = { + getRandomValues<T extends ArrayBufferView>(array: T): T + } + + const TOTAL_LENGTH = 26 + const RANDOM_LENGTH = TOTAL_LENGTH - 12 + const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - // State for monotonic ID generation let lastTimestamp = 0 let counter = 0 - export function ascending() { - return create(false) - } - - export function descending() { - return create(true) + const fillRandomBytes = (buffer: Uint8Array) => { + const cryptoLike = (globalThis as { crypto?: CryptoLike }).crypto + if (cryptoLike?.getRandomValues) { + cryptoLike.getRandomValues(buffer) + return buffer + } + for (let i = 0; i < buffer.length; i++) { + buffer[i] = Math.floor(Math.random() * 256) + } + return buffer } - function randomBase62(length: number): string { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + const randomBase62 = (length: number) => { + const bytes = fillRandomBytes(new Uint8Array(length)) let result = "" - const bytes = randomBytes(length) for (let i = 0; i < length; i++) { - result += chars[bytes[i] % 62] + result += BASE62[bytes[i] % BASE62.length] } return result } - export function create(descending: boolean, timestamp?: number): string { + const createSuffix = (descending: boolean, timestamp?: number) => { const currentTimestamp = timestamp ?? Date.now() - if (currentTimestamp !== lastTimestamp) { lastTimestamp = currentTimestamp counter = 0 } - counter++ - - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) + counter += 1 - now = descending ? ~now : now + let value = BigInt(currentTimestamp) * 0x1000n + BigInt(counter) + if (descending) value = ~value - const timeBytes = Buffer.alloc(6) + const timeBytes = new Uint8Array(6) for (let i = 0; i < 6; i++) { - timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff)) + timeBytes[i] = Number((value >> BigInt(40 - 8 * i)) & 0xffn) + } + const hex = Array.from(timeBytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + return hex + randomBase62(RANDOM_LENGTH) + } + + const generateID = (prefix: Prefix, descending: boolean, given?: string, timestamp?: number) => { + if (given) { + const expected = `${prefixes[prefix]}_` + if (!given.startsWith(expected)) throw new Error(`ID ${given} does not start with ${expected}`) + return given } + return `${prefixes[prefix]}_${createSuffix(descending, timestamp)}` + } + + export const schema = (prefix: Prefix) => z.string().startsWith(`${prefixes[prefix]}_`) + + export function ascending(): string + export function ascending(prefix: Prefix, given?: string): string + export function ascending(prefix?: Prefix, given?: string) { + if (prefix) return generateID(prefix, false, given) + return create(false) + } + + export function descending(): string + export function descending(prefix: Prefix, given?: string): string + export function descending(prefix?: Prefix, given?: string) { + if (prefix) return generateID(prefix, true, given) + return create(true) + } + + export function create(descending: boolean, timestamp?: number) { + return createSuffix(descending, timestamp) + } - return timeBytes.toString("hex") + randomBase62(LENGTH - 12) + export function createPrefixed(prefix: Prefix, descending: boolean, timestamp?: number) { + return generateID(prefix, descending, undefined, timestamp) } } |
