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 = (props) => { dragging: boolean imageAttachments: ImageAttachmentPart[] mode: "normal" | "shell" + applyingHistory: boolean }>({ popover: null, historyIndex: -1, @@ -108,6 +110,7 @@ export const PromptInput: Component = (props) => { dragging: false, imageAttachments: [], mode: "normal", + applyingHistory: false, }) const MAX_HISTORY = 100 @@ -135,10 +138,12 @@ export const PromptInput: Component = (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 = (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 = (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 = (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 = (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 = (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 = (props) => { sessionID: existing.id, agent, model, - parts: [ - { - type: "text", - text, - }, - ...fileAttachmentParts, - ...imageAttachmentParts, - ], + messageID, + parts: requestParts, }) } @@ -911,6 +947,7 @@ export const PromptInput: Component = (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", }} /> 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) => {
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 ( -
+
{ - 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]) + export const schema = (prefix: Prefix) => SharedIdentifier.schema(prefix) + + export function ascending(prefix: Prefix, given?: string) { + return SharedIdentifier.ascending(prefix, given) } - const LENGTH = 26 - - // 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 descending(prefix: Prefix, given?: string) { + return SharedIdentifier.descending(prefix, given) } - export function descending(prefix: keyof typeof prefixes, given?: string) { - return generateID(prefix, true, 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(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) + 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 } - export function descending() { - return create(true) - } - - 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++ + counter += 1 - let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter) + let value = BigInt(currentTimestamp) * 0x1000n + BigInt(counter) + if (descending) value = ~value - now = descending ? ~now : now - - 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) + } - return timeBytes.toString("hex") + randomBase62(LENGTH - 12) + 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) + } + + export function createPrefixed(prefix: Prefix, descending: boolean, timestamp?: number) { + return generateID(prefix, descending, undefined, timestamp) } }