From 2c58dd6203df7806f57ef6b29672091cb764e871 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 10:02:31 -0600 Subject: chore: refactoring and tests, splitting up files (#12495) --- .../app/src/components/dialog-custom-provider.tsx | 82 +++--- .../app/src/components/dialog-select-model.tsx | 14 +- .../app/src/components/dialog-select-server.tsx | 98 ++----- packages/app/src/components/prompt-input.tsx | 314 ++++----------------- .../prompt-input/build-request-parts.test.ts | 67 +++++ .../components/prompt-input/build-request-parts.ts | 174 ++++++++++++ .../src/components/prompt-input/context-items.tsx | 82 ++++++ .../src/components/prompt-input/drag-overlay.tsx | 20 ++ .../components/prompt-input/image-attachments.tsx | 51 ++++ .../components/prompt-input/placeholder.test.ts | 35 +++ .../app/src/components/prompt-input/placeholder.ts | 13 + .../src/components/prompt-input/slash-popover.tsx | 144 ++++++++++ packages/app/src/components/prompt-input/submit.ts | 222 ++------------- packages/app/src/components/server/server-row.tsx | 77 +++++ packages/app/src/components/status-popover.tsx | 127 +++------ packages/app/src/components/terminal.tsx | 23 +- 16 files changed, 858 insertions(+), 685 deletions(-) create mode 100644 packages/app/src/components/prompt-input/build-request-parts.test.ts create mode 100644 packages/app/src/components/prompt-input/build-request-parts.ts create mode 100644 packages/app/src/components/prompt-input/context-items.tsx create mode 100644 packages/app/src/components/prompt-input/drag-overlay.tsx create mode 100644 packages/app/src/components/prompt-input/image-attachments.tsx create mode 100644 packages/app/src/components/prompt-input/placeholder.test.ts create mode 100644 packages/app/src/components/prompt-input/placeholder.ts create mode 100644 packages/app/src/components/prompt-input/slash-popover.tsx create mode 100644 packages/app/src/components/server/server-row.tsx (limited to 'packages/app/src/components') diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 28a947f3b..53773ed9e 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) { const key = apiKey && !env ? apiKey : undefined const idError = !providerID - ? "Provider ID is required" + ? language.t("provider.custom.error.providerID.required") : !PROVIDER_ID.test(providerID) - ? "Use lowercase letters, numbers, hyphens, or underscores" + ? language.t("provider.custom.error.providerID.format") : undefined - const nameError = !name ? "Display name is required" : undefined + const nameError = !name ? language.t("provider.custom.error.name.required") : undefined const urlError = !baseURL - ? "Base URL is required" + ? language.t("provider.custom.error.baseURL.required") : !/^https?:\/\//.test(baseURL) - ? "Must start with http:// or https://" + ? language.t("provider.custom.error.baseURL.format") : undefined const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID) @@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) { const existsError = idError ? undefined : existingProvider && !disabled - ? "That provider ID already exists" + ? language.t("provider.custom.error.providerID.exists") : undefined const seenModels = new Set() const modelErrors = form.models.map((m) => { const id = m.id.trim() const modelIdError = !id - ? "Required" + ? language.t("provider.custom.error.required") : seenModels.has(id) - ? "Duplicate" + ? language.t("provider.custom.error.duplicate") : (() => { seenModels.add(id) return undefined })() - const modelNameError = !m.name.trim() ? "Required" : undefined + const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined return { id: modelIdError, name: modelNameError } }) const modelsValid = modelErrors.every((m) => !m.id && !m.name) @@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) { if (!key && !value) return {} const keyError = !key - ? "Required" + ? language.t("provider.custom.error.required") : seenHeaders.has(key.toLowerCase()) - ? "Duplicate" + ? language.t("provider.custom.error.duplicate") : (() => { seenHeaders.add(key.toLowerCase()) return undefined })() - const valueError = !value ? "Required" : undefined + const valueError = !value ? language.t("provider.custom.error.required") : undefined return { key: keyError, value: valueError } }) const headersValid = headerErrors.every((h) => !h.key && !h.value) @@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
-
Custom provider
+
{language.t("provider.custom.title")}

- Configure an OpenAI-compatible provider. See the{" "} + {language.t("provider.custom.description.prefix")} - provider config docs + {language.t("provider.custom.description.link")} - . + {language.t("provider.custom.description.suffix")}

- + {(m, i) => (
setForm("models", i(), "id", v)} validationState={errors.models[i()]?.id ? "invalid" : undefined} @@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
setForm("models", i(), "name", v)} validationState={errors.models[i()]?.name ? "invalid" : undefined} @@ -360,26 +360,26 @@ export function DialogCustomProvider(props: Props) { class="mt-1.5" onClick={() => removeModel(i())} disabled={form.models.length <= 1} - aria-label="Remove model" + aria-label={language.t("provider.custom.models.remove")} />
)}
- + {(h, i) => (
setForm("headers", i(), "key", v)} validationState={errors.headers[i()]?.key ? "invalid" : undefined} @@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
setForm("headers", i(), "value", v)} validationState={errors.headers[i()]?.value ? "invalid" : undefined} @@ -404,18 +404,18 @@ export function DialogCustomProvider(props: Props) { class="mt-1.5" onClick={() => removeHeader(i())} disabled={form.headers.length <= 1} - aria-label="Remove header" + aria-label={language.t("provider.custom.headers.remove")} />
)}
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 3d0d6c793..26021f06a 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -87,11 +87,13 @@ const ModelList: Component<{ ) } -export function ModelSelectorPopover(props: { +type ModelSelectorTriggerProps = Omit, "as" | "ref"> + +export function ModelSelectorPopover(props: { provider?: string children?: JSX.Element - triggerAs?: T - triggerProps?: ComponentProps + triggerAs?: ValidComponent + triggerProps?: ModelSelectorTriggerProps }) { const [store, setStore] = createStore<{ open: boolean @@ -176,11 +178,7 @@ export function ModelSelectorPopover(props: { placement="top-start" gutter={8} > - setStore("trigger", el)} - as={props.triggerAs ?? "div"} - {...(props.triggerProps as any)} - > + setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}> {props.children} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 3d8f5b846..65b679f70 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,4 +1,4 @@ -import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js" +import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -6,17 +6,15 @@ import { List } from "@opencode-ai/ui/list" import { Button } from "@opencode-ai/ui/button" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" -import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { normalizeServerUrl, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { useNavigate } from "@solidjs/router" import { useLanguage } from "@/context/language" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { useGlobalSDK } from "@/context/global-sdk" import { showToast } from "@opencode-ai/ui/toast" - -type ServerStatus = { healthy: boolean; version?: string } +import { ServerRow } from "@/components/server/server-row" +import { checkServerHealth, type ServerHealth } from "@/utils/server-health" interface AddRowProps { value: string @@ -40,19 +38,6 @@ interface EditRowProps { onBlur: () => void } -async function checkHealth(url: string, platform: ReturnType): Promise { - const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) - const sdk = createOpencodeClient({ - baseUrl: url, - fetch: platform.fetch, - signal, - }) - return sdk.global - .health() - .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) - .catch(() => ({ healthy: false })) -} - function AddRow(props: AddRowProps) { return (
@@ -131,7 +116,7 @@ export function DialogSelectServer() { const globalSDK = useGlobalSDK() const language = useLanguage() const [store, setStore] = createStore({ - status: {} as Record, + status: {} as Record, addServer: { url: "", adding: false, @@ -165,6 +150,7 @@ export function DialogSelectServer() { { initialValue: null }, ) const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) + const fetcher = platform.fetch ?? globalThis.fetch const looksComplete = (value: string) => { const normalized = normalizeServerUrl(value) @@ -180,7 +166,7 @@ export function DialogSelectServer() { if (!looksComplete(value)) return const normalized = normalizeServerUrl(value) if (!normalized) return - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStatus(result.healthy) } @@ -227,7 +213,7 @@ export function DialogSelectServer() { if (!list.length) return list const active = current() const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerStatus) => { + const rank = (value?: ServerHealth) => { if (value?.healthy === true) return 0 if (value?.healthy === false) return 2 return 1 @@ -242,10 +228,10 @@ export function DialogSelectServer() { }) async function refreshHealth() { - const results: Record = {} + const results: Record = {} await Promise.all( items().map(async (url) => { - results[url] = await checkHealth(url, platform) + results[url] = await checkServerHealth(url, fetcher) }), ) setStore("status", reconcile(results)) @@ -300,7 +286,7 @@ export function DialogSelectServer() { setStore("addServer", { adding: true, error: "" }) - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStore("addServer", { adding: false }) if (!result.healthy) { @@ -327,7 +313,7 @@ export function DialogSelectServer() { setStore("editServer", { busy: true, error: "" }) - const result = await checkHealth(normalized, platform) + const result = await checkServerHealth(normalized, fetcher) setStore("editServer", { busy: false }) if (!result.healthy) { @@ -413,35 +399,6 @@ export function DialogSelectServer() { } > {(i) => { - const [truncated, setTruncated] = createSignal(false) - let nameRef: HTMLSpanElement | undefined - let versionRef: HTMLSpanElement | undefined - - const check = () => { - const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false - const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false - setTruncated(nameTruncated || versionTruncated) - } - - createEffect(() => { - check() - window.addEventListener("resize", check) - onCleanup(() => window.removeEventListener("resize", check)) - }) - - const tooltipValue = () => { - const name = serverDisplayName(i) - const version = store.status[i]?.version - return ( - - {name} - - {version} - - - ) - } - return (
} > - -
-
- - {serverDisplayName(i)} - - - - {store.status[i]?.version} - - + {language.t("dialog.server.status.default")} -
- + } + />
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3f0ba314e..46d7f93eb 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -19,7 +19,6 @@ import { useSDK } from "@/context/sdk" import { useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" -import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" @@ -27,9 +26,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" @@ -42,6 +39,12 @@ import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" import { createPromptSubmit } from "./prompt-input/submit" +import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" +import { PromptContextItems } from "./prompt-input/context-items" +import { PromptImageAttachments } from "./prompt-input/image-attachments" +import { PromptDragOverlay } from "./prompt-input/drag-overlay" +import { promptPlaceholder } from "./prompt-input/placeholder" +import { ImagePreview } from "@opencode-ai/ui/image-preview" interface PromptInputProps { class?: string @@ -79,16 +82,6 @@ const EXAMPLES = [ "prompt.example.25", ] as const -interface SlashCommand { - id: string - trigger: string - title: string - description?: string - keybind?: string - type: "builtin" | "custom" - source?: "command" | "mcp" | "skill" -} - export const PromptInput: Component = (props) => { const sdk = useSDK() const sync = useSync() @@ -203,8 +196,8 @@ export const PromptInput: Component = (props) => { }, ) const working = createMemo(() => status()?.type !== "idle") - const imageAttachments = createMemo( - () => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[], + const imageAttachments = createMemo(() => + prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), ) const [store, setStore] = createStore<{ @@ -224,6 +217,14 @@ export const PromptInput: Component = (props) => { mode: "normal", applyingHistory: false, }) + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: language.t(EXAMPLES[store.placeholder]), + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) const MAX_HISTORY = 100 const [history, setHistory] = persisted( @@ -296,10 +297,6 @@ export const PromptInput: Component = (props) => { if (!isFocused()) setComposing(false) }) - type AtOption = - | { type: "agent"; name: string; display: string } - | { type: "file"; path: string; display: string; recent?: boolean } - const agentList = createMemo(() => sync.data.agent .filter((agent) => !agent.hidden && agent.mode !== "primary") @@ -509,7 +506,7 @@ export const PromptInput: Component = (props) => { on( () => prompt.current(), (currentParts) => { - const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt + const inputParts = currentParts.filter((part) => part.type !== "image") if (mirror.input) { mirror.input = false @@ -928,110 +925,21 @@ export const PromptInput: Component = (props) => { return (
- -
{ - if (store.popover === "slash") slashPopoverRef = el - }} - class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 rounded-md - border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" - onMouseDown={(e) => e.preventDefault()} - > - - - 0} - fallback={
{language.t("prompt.popover.emptyResults")}
} - > - - {(item) => ( - - )} - -
-
- - 0} - fallback={
{language.t("prompt.popover.emptyCommands")}
} - > - - {(cmd) => ( - - )} - -
-
-
-
-
+ (slashPopoverRef = el)} + atFlat={atFlat()} + atActive={atActive() ?? undefined} + atKey={atKey} + setAtActive={setAtActive} + onAtSelect={handleAtSelect} + slashFlat={slashFlat()} + slashActive={slashActive() ?? undefined} + setSlashActive={setSlashActive} + onSlashSelect={handleSlashSelect} + commandKeybind={command.keybind} + t={(key) => language.t(key as Parameters[0])} + />
= (props) => { [props.class ?? ""]: !!props.class, }} > - -
-
- - {language.t("prompt.dropzone.label")} -
-
-
- 0}> -
- - {(item) => { - const active = () => { - const a = comments.active() - return !!item.commentID && item.commentID === a?.id && item.path === a?.file - } - return ( - - - {getDirectory(item.path)} - - {getFilename(item.path)} - - } - placement="top" - openDelay={2000} - > -
{ - openComment(item) - }} - > -
- -
- {getFilenameTruncated(item.path, 14)} - - {(sel) => ( - - {sel().startLine === sel().endLine - ? `:${sel().startLine}` - : `:${sel().startLine}-${sel().endLine}`} - - )} - -
- { - e.stopPropagation() - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - aria-label={language.t("prompt.context.removeFile")} - /> -
- - {(comment) => ( -
{comment()}
- )} -
-
-
- ) - }} -
-
-
- 0}> -
- - {(attachment) => ( -
- - -
- } - > - {attachment.filename} - dialog.show(() => ) - } - /> - - -
- {attachment.filename} -
-
- )} - -
- + + { + const active = comments.active() + return !!item.commentID && item.commentID === active?.id && item.path === active?.file + }} + openComment={openComment} + remove={(item) => { + if (item.commentID) comments.remove(item.path, item.commentID) + prompt.context.remove(item.key) + }} + t={(key) => language.t(key as Parameters[0])} + /> + + dialog.show(() => ) + } + onRemove={removeImageAttachment} + removeLabel={language.t("prompt.attachment.remove")} + />
(scrollRef = el)}>
= (props) => { }} role="textbox" aria-multiline="true" - aria-label={ - store.mode === "shell" - ? language.t("prompt.placeholder.shell") - : commentCount() > 1 - ? language.t("prompt.placeholder.summarizeComments") - : commentCount() === 1 - ? language.t("prompt.placeholder.summarizeComment") - : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) }) - } + aria-label={placeholder()} contenteditable="true" onInput={handleInput} onPaste={handlePaste} @@ -1194,13 +998,7 @@ export const PromptInput: Component = (props) => { />
- {store.mode === "shell" - ? language.t("prompt.placeholder.shell") - : commentCount() > 1 - ? language.t("prompt.placeholder.summarizeComments") - : commentCount() === 1 - ? language.t("prompt.placeholder.summarizeComment") - : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })} + {placeholder()}
diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts new file mode 100644 index 000000000..b284c3884 --- /dev/null +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import type { Prompt } from "@/context/prompt" +import { buildRequestParts } from "./build-request-parts" + +describe("buildRequestParts", () => { + test("builds typed request and optimistic parts without cast path", () => { + const prompt: Prompt = [ + { type: "text", content: "hello", start: 0, end: 5 }, + { + type: "file", + path: "src/foo.ts", + content: "@src/foo.ts", + start: 5, + end: 16, + selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 }, + }, + { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 }, + ] + + const result = buildRequestParts({ + prompt, + context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }], + images: [ + { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" }, + ], + text: "hello @src/foo.ts @planner", + messageID: "msg_1", + sessionID: "ses_1", + sessionDirectory: "/repo", + }) + + expect(result.requestParts[0]?.type).toBe("text") + expect(result.requestParts.some((part) => part.type === "agent")).toBe(true) + expect( + result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")), + ).toBe(true) + expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true) + + expect(result.optimisticParts).toHaveLength(result.requestParts.length) + expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) + }) + + test("deduplicates context files when prompt already includes same path", () => { + const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] + + const result = buildRequestParts({ + prompt, + context: [ + { key: "ctx:dup", type: "file", path: "src/foo.ts" }, + { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" }, + ], + images: [], + text: "@src/foo.ts", + messageID: "msg_2", + sessionID: "ses_2", + sessionDirectory: "/repo", + }) + + const fooFiles = result.requestParts.filter( + (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"), + ) + const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic) + + expect(fooFiles).toHaveLength(2) + expect(synthetic).toHaveLength(1) + }) +}) diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts new file mode 100644 index 000000000..4cf2f29ac --- /dev/null +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -0,0 +1,174 @@ +import { getFilename } from "@opencode-ai/util/path" +import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client" +import type { FileSelection } from "@/context/file" +import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt" +import { Identifier } from "@/utils/id" + +type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string } + +type ContextFile = { + key: string + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +type BuildRequestPartsInput = { + prompt: Prompt + context: ContextFile[] + images: ImageAttachmentPart[] + text: string + messageID: string + sessionID: string + sessionDirectory: string +} + +const absolute = (directory: string, path: string) => + path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/") + +const fileQuery = (selection: FileSelection | undefined) => + selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" + +const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" +const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" + +const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { + const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined + const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined + const range = + start === undefined || end === undefined + ? "this file" + : start === end + ? `line ${start}` + : `lines ${start} through ${end}` + return `The user made the following comment regarding ${range} of ${path}: ${comment}` +} + +const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => { + if (part.type === "text") { + return { + id: part.id, + type: "text", + text: part.text, + synthetic: part.synthetic, + ignored: part.ignored, + time: part.time, + metadata: part.metadata, + sessionID, + messageID, + } + } + if (part.type === "file") { + return { + id: part.id, + type: "file", + mime: part.mime, + filename: part.filename, + url: part.url, + source: part.source, + sessionID, + messageID, + } + } + return { + id: part.id, + type: "agent", + name: part.name, + source: part.source, + sessionID, + messageID, + } +} + +export function buildRequestParts(input: BuildRequestPartsInput) { + const requestParts: PromptRequestPart[] = [ + { + id: Identifier.ascending("part"), + type: "text", + text: input.text, + }, + ] + + const files = input.prompt.filter(isFileAttachment).map((attachment) => { + const path = absolute(input.sessionDirectory, attachment.path) + return { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url: `file://${path}${fileQuery(attachment.selection)}`, + filename: getFilename(attachment.path), + source: { + type: "file", + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path, + }, + } satisfies PromptRequestPart + }) + + const agents = input.prompt.filter(isAgentAttachment).map((attachment) => { + return { + id: Identifier.ascending("part"), + type: "agent", + name: attachment.name, + source: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + } satisfies PromptRequestPart + }) + + const used = new Set(files.map((part) => part.url)) + const context = input.context.flatMap((item) => { + const path = absolute(input.sessionDirectory, item.path) + const url = `file://${path}${fileQuery(item.selection)}` + const comment = item.comment?.trim() + if (!comment && used.has(url)) return [] + used.add(url) + + const filePart = { + id: Identifier.ascending("part"), + type: "file", + mime: "text/plain", + url, + filename: getFilename(item.path), + } satisfies PromptRequestPart + + if (!comment) return [filePart] + + return [ + { + id: Identifier.ascending("part"), + type: "text", + text: commentNote(item.path, item.selection, comment), + synthetic: true, + } satisfies PromptRequestPart, + filePart, + ] + }) + + const images = input.images.map((attachment) => { + return { + id: Identifier.ascending("part"), + type: "file", + mime: attachment.mime, + url: attachment.dataUrl, + filename: attachment.filename, + } satisfies PromptRequestPart + }) + + requestParts.push(...files, ...context, ...agents, ...images) + + return { + requestParts, + optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)), + } +} diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx new file mode 100644 index 000000000..a843e109d --- /dev/null +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -0,0 +1,82 @@ +import { Component, For, Show } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import type { ContextItem } from "@/context/prompt" + +type PromptContextItem = ContextItem & { key: string } + +type ContextItemsProps = { + items: PromptContextItem[] + active: (item: PromptContextItem) => boolean + openComment: (item: PromptContextItem) => void + remove: (item: PromptContextItem) => void + t: (key: string) => string +} + +export const PromptContextItems: Component = (props) => { + return ( + 0}> +
+ + {(item) => ( + + + {getDirectory(item.path)} + + {getFilename(item.path)} + + } + placement="top" + openDelay={2000} + > +
props.openComment(item)} + > +
+ +
+ {getFilenameTruncated(item.path, 14)} + + {(sel) => ( + + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + + )} + +
+ { + e.stopPropagation() + props.remove(item) + }} + aria-label={props.t("prompt.context.removeFile")} + /> +
+ + {(comment) =>
{comment()}
} +
+
+
+ )} +
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx new file mode 100644 index 000000000..f5a4d399e --- /dev/null +++ b/packages/app/src/components/prompt-input/drag-overlay.tsx @@ -0,0 +1,20 @@ +import { Component, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" + +type PromptDragOverlayProps = { + dragging: boolean + label: string +} + +export const PromptDragOverlay: Component = (props) => { + return ( + +
+
+ + {props.label} +
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx new file mode 100644 index 000000000..ba3addf0a --- /dev/null +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -0,0 +1,51 @@ +import { Component, For, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import type { ImageAttachmentPart } from "@/context/prompt" + +type PromptImageAttachmentsProps = { + attachments: ImageAttachmentPart[] + onOpen: (attachment: ImageAttachmentPart) => void + onRemove: (id: string) => void + removeLabel: string +} + +export const PromptImageAttachments: Component = (props) => { + return ( + 0}> +
+ + {(attachment) => ( +
+ + +
+ } + > + {attachment.filename} props.onOpen(attachment)} + /> + + +
+ {attachment.filename} +
+
+ )} + +
+ + ) +} diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts new file mode 100644 index 000000000..b633df829 --- /dev/null +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { promptPlaceholder } from "./placeholder" + +describe("promptPlaceholder", () => { + const t = (key: string, params?: Record) => `${key}${params?.example ? `:${params.example}` : ""}` + + test("returns shell placeholder in shell mode", () => { + const value = promptPlaceholder({ + mode: "shell", + commentCount: 0, + example: "example", + t, + }) + expect(value).toBe("prompt.placeholder.shell") + }) + + test("returns summarize placeholders for comment context", () => { + expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe( + "prompt.placeholder.summarizeComment", + ) + expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe( + "prompt.placeholder.summarizeComments", + ) + }) + + test("returns default placeholder with example", () => { + const value = promptPlaceholder({ + mode: "normal", + commentCount: 0, + example: "translated-example", + t, + }) + expect(value).toBe("prompt.placeholder.normal:translated-example") + }) +}) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts new file mode 100644 index 000000000..07f6a43b5 --- /dev/null +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -0,0 +1,13 @@ +type PromptPlaceholderInput = { + mode: "normal" | "shell" + commentCount: number + example: string + t: (key: string, params?: Record) => string +} + +export function promptPlaceholder(input: PromptPlaceholderInput) { + if (input.mode === "shell") return input.t("prompt.placeholder.shell") + if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") + if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") + return input.t("prompt.placeholder.normal", { example: input.example }) +} diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx new file mode 100644 index 000000000..b97bb6752 --- /dev/null +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -0,0 +1,144 @@ +import { Component, For, Match, Show, Switch } from "solid-js" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" + +export type AtOption = + | { type: "agent"; name: string; display: string } + | { type: "file"; path: string; display: string; recent?: boolean } + +export interface SlashCommand { + id: string + trigger: string + title: string + description?: string + keybind?: string + type: "builtin" | "custom" + source?: "command" | "mcp" | "skill" +} + +type PromptPopoverProps = { + popover: "at" | "slash" | null + setSlashPopoverRef: (el: HTMLDivElement) => void + atFlat: AtOption[] + atActive?: string + atKey: (item: AtOption) => string + setAtActive: (id: string) => void + onAtSelect: (item: AtOption) => void + slashFlat: SlashCommand[] + slashActive?: string + setSlashActive: (id: string) => void + onSlashSelect: (item: SlashCommand) => void + commandKeybind: (id: string) => string | undefined + t: (key: string) => string +} + +export const PromptPopover: Component = (props) => { + return ( + +
{ + if (props.popover === "slash") props.setSlashPopoverRef(el) + }} + class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 + overflow-auto no-scrollbar flex flex-col p-2 rounded-md + border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" + onMouseDown={(e) => e.preventDefault()} + > + + + 0} + fallback={
{props.t("prompt.popover.emptyResults")}
} + > + + {(item) => ( + + )} + +
+
+ + 0} + fallback={
{props.t("prompt.popover.emptyCommands")}
} + > + + {(cmd) => ( + + )} + +
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 1e5ebe4cb..5ed5eedad 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,19 +1,10 @@ import { Accessor } from "solid-js" -import { produce } from "solid-js/store" import { useNavigate, useParams } from "@solidjs/router" -import { getFilename } from "@opencode-ai/util/path" -import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client" -import { Binary } from "@opencode-ai/util/binary" +import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useLocal } from "@/context/local" -import { - usePrompt, - type AgentPart, - type FileAttachmentPart, - type ImageAttachmentPart, - type Prompt, -} from "@/context/prompt" +import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" @@ -24,6 +15,7 @@ import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" import type { FileSelection } from "@/context/file" import { setCursorPosition } from "./editor-dom" +import { buildRequestParts } from "./build-request-parts" type PendingPrompt = { abort: AbortController @@ -290,138 +282,19 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const toAbsolutePath = (path: string) => - path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/") - - const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[] - const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[] - - const fileAttachmentParts = fileAttachments.map((attachment) => { - const absolute = toAbsolutePath(attachment.path) - const query = attachment.selection - ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` - : "" - return { - id: Identifier.ascending("part"), - type: "file" as const, - mime: "text/plain", - url: `file://${absolute}${query}`, - filename: getFilename(attachment.path), - source: { - type: "file" as const, - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path: absolute, - }, - } - }) - - const agentAttachmentParts = agentAttachments.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "agent" as const, - name: attachment.name, - source: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - })) - - const usedUrls = new Set(fileAttachmentParts.map((part) => part.url)) - const context = prompt.context.items().slice() const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - const contextParts: Array< - | { - id: string - type: "text" - text: string - synthetic?: boolean - } - | { - id: string - type: "file" - mime: string - url: string - filename?: string - } - > = [] - - const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => { - const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined - const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined - const range = - start === undefined || end === undefined - ? "this file" - : start === end - ? `line ${start}` - : `lines ${start} through ${end}` - - return `The user made the following comment regarding ${range} of ${path}: ${comment}` - } - - const addContextFile = (item: { path: string; selection?: FileSelection; comment?: string }) => { - const absolute = toAbsolutePath(item.path) - const query = item.selection ? `?start=${item.selection.startLine}&end=${item.selection.endLine}` : "" - const url = `file://${absolute}${query}` - - const comment = item.comment?.trim() - if (!comment && usedUrls.has(url)) return - usedUrls.add(url) - - if (comment) { - contextParts.push({ - id: Identifier.ascending("part"), - type: "text", - text: commentNote(item.path, item.selection, comment), - synthetic: true, - }) - } - - contextParts.push({ - id: Identifier.ascending("part"), - type: "file", - mime: "text/plain", - url, - filename: getFilename(item.path), - }) - } - - for (const item of context) { - if (item.type !== "file") continue - addContextFile({ path: item.path, selection: item.selection, comment: item.comment }) - } - - const imageAttachmentParts = images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, - })) - const messageID = Identifier.ascending("message") - const requestParts = [ - { - id: Identifier.ascending("part"), - type: "text" as const, - text, - }, - ...fileAttachmentParts, - ...contextParts, - ...agentAttachmentParts, - ...imageAttachmentParts, - ] - - const optimisticParts = requestParts.map((part) => ({ - ...part, + const { requestParts, optimisticParts } = buildRequestParts({ + prompt: currentPrompt, + context, + images, + text, sessionID: session.id, messageID, - })) as unknown as Part[] + sessionDirectory, + }) const optimisticMessage: Message = { id: messageID, @@ -432,69 +305,20 @@ export function createPromptSubmit(input: PromptSubmitInput) { model, } - const addOptimisticMessage = () => { - if (sessionDirectory === projectDirectory) { - sync.set( - produce((draft) => { - const messages = draft.message[session.id] - if (!messages) { - draft.message[session.id] = [optimisticMessage] - } else { - const result = Binary.search(messages, messageID, (m) => m.id) - messages.splice(result.index, 0, optimisticMessage) - } - draft.part[messageID] = optimisticParts - .filter((part) => !!part?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }), - ) - return - } - - globalSync.child(sessionDirectory)[1]( - produce((draft) => { - const messages = draft.message[session.id] - if (!messages) { - draft.message[session.id] = [optimisticMessage] - } else { - const result = Binary.search(messages, messageID, (m) => m.id) - messages.splice(result.index, 0, optimisticMessage) - } - draft.part[messageID] = optimisticParts - .filter((part) => !!part?.id) - .slice() - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }), - ) - } - - const removeOptimisticMessage = () => { - if (sessionDirectory === projectDirectory) { - sync.set( - produce((draft) => { - const messages = draft.message[session.id] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[messageID] - }), - ) - return - } + const addOptimisticMessage = () => + sync.session.optimistic.add({ + directory: sessionDirectory, + sessionID: session.id, + message: optimisticMessage, + parts: optimisticParts, + }) - globalSync.child(sessionDirectory)[1]( - produce((draft) => { - const messages = draft.message[session.id] - if (messages) { - const result = Binary.search(messages, messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) - } - delete draft.part[messageID] - }), - ) - } + const removeOptimisticMessage = () => + sync.session.optimistic.remove({ + directory: sessionDirectory, + sessionID: session.id, + messageID, + }) removeCommentItems(commentItems) clearInput() diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx new file mode 100644 index 000000000..b43c07882 --- /dev/null +++ b/packages/app/src/components/server/server-row.tsx @@ -0,0 +1,77 @@ +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { serverDisplayName } from "@/context/server" +import type { ServerHealth } from "@/utils/server-health" + +interface ServerRowProps extends ParentProps { + url: string + status?: ServerHealth + class?: string + nameClass?: string + versionClass?: string + dimmed?: boolean + badge?: JSXElement +} + +export function ServerRow(props: ServerRowProps) { + const [truncated, setTruncated] = createSignal(false) + let nameRef: HTMLSpanElement | undefined + let versionRef: HTMLSpanElement | undefined + + const check = () => { + const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false + const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false + setTruncated(nameTruncated || versionTruncated) + } + + createEffect(() => { + props.url + props.status?.version + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(check) + return + } + check() + }) + + onMount(() => { + check() + if (typeof window === "undefined") return + window.addEventListener("resize", check) + onCleanup(() => window.removeEventListener("resize", check)) + }) + + const tooltipValue = () => ( + + {serverDisplayName(props.url)} + + {props.status?.version} + + + ) + + return ( + +
+
+ + {serverDisplayName(props.url)} + + + + {props.status?.version} + + + {props.badge} + {props.children} +
+ + ) +} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 102c477a1..3354c3d36 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useNavigate } from "@solidjs/router" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -7,30 +7,15 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { Button } from "@opencode-ai/ui/button" import { Switch } from "@opencode-ai/ui/switch" import { Icon } from "@opencode-ai/ui/icon" -import { Tooltip } from "@opencode-ai/ui/tooltip" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" -import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { normalizeServerUrl, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { DialogSelectServer } from "./dialog-select-server" import { showToast } from "@opencode-ai/ui/toast" - -type ServerStatus = { healthy: boolean; version?: string } - -async function checkHealth(url: string, platform: ReturnType): Promise { - const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000) - const sdk = createOpencodeClient({ - baseUrl: url, - fetch: platform.fetch, - signal, - }) - return sdk.global - .health() - .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) - .catch(() => ({ healthy: false })) -} +import { ServerRow } from "@/components/server/server-row" +import { checkServerHealth, type ServerHealth } from "@/utils/server-health" export function StatusPopover() { const sync = useSync() @@ -42,10 +27,11 @@ export function StatusPopover() { const navigate = useNavigate() const [store, setStore] = createStore({ - status: {} as Record, + status: {} as Record, loading: null as string | null, defaultServerUrl: undefined as string | undefined, }) + const fetcher = platform.fetch ?? globalThis.fetch const servers = createMemo(() => { const current = server.url @@ -60,7 +46,7 @@ export function StatusPopover() { if (!list.length) return list const active = server.url const order = new Map(list.map((url, index) => [url, index] as const)) - const rank = (value?: ServerStatus) => { + const rank = (value?: ServerHealth) => { if (value?.healthy === true) return 0 if (value?.healthy === false) return 2 return 1 @@ -75,10 +61,10 @@ export function StatusPopover() { }) async function refreshHealth() { - const results: Record = {} + const results: Record = {} await Promise.all( servers().map(async (url) => { - results[url] = await checkHealth(url, platform) + results[url] = await checkServerHealth(url, fetcher) }), ) setStore("status", reconcile(results)) @@ -213,78 +199,43 @@ export function StatusPopover() { const isDefault = () => url === store.defaultServerUrl const status = () => store.status[url] const isBlocked = () => status()?.healthy === false - const [truncated, setTruncated] = createSignal(false) - let nameRef: HTMLSpanElement | undefined - let versionRef: HTMLSpanElement | undefined - - onMount(() => { - const check = () => { - const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false - const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false - setTruncated(nameTruncated || versionTruncated) - } - check() - window.addEventListener("resize", check) - onCleanup(() => window.removeEventListener("resize", check)) - }) - - const tooltipValue = () => { - const name = serverDisplayName(url) - const version = status()?.version - return ( - - {name} - - {version} - - - ) - } return ( - - - + + ) }} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 4d44d5f7e..2ee2e074e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -8,6 +8,7 @@ import { LocalPTY } from "@/context/terminal" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" import { useLanguage } from "@/context/language" import { showToast } from "@opencode-ai/ui/toast" +import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY @@ -111,17 +112,13 @@ export const Terminal = (props: TerminalProps) => { const colors = getTerminalColors() setTerminalColors(colors) if (!term) return - const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption - if (!setOption) return - setOption("theme", colors) + setOptionIfSupported(term, "theme", colors) }) createEffect(() => { const font = monoFontFamily(settings.appearance.font()) if (!term) return - const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption - if (!setOption) return - setOption("fontFamily", font) + setOptionIfSupported(term, "fontFamily", font) }) const focusTerminal = () => { @@ -146,12 +143,12 @@ export const Terminal = (props: TerminalProps) => { const t = term if (!t) return - const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink - if (!link?.text) return + const text = getHoveredLinkText(t) + if (!text) return event.preventDefault() event.stopImmediatePropagation() - platform.openLink(link.text) + platform.openLink(text) } onMount(() => { @@ -250,7 +247,7 @@ export const Terminal = (props: TerminalProps) => { const fit = new mod.FitAddon() const serializer = new SerializeAddon() - cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(fit)) t.loadAddon(serializer) t.loadAddon(fit) fitAddon = fit @@ -303,19 +300,19 @@ export const Terminal = (props: TerminalProps) => { .catch(() => {}) } }) - cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(onResize)) const onData = t.onData((data) => { if (socket.readyState === WebSocket.OPEN) { socket.send(data) } }) - cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(onData)) const onKey = t.onKey((key) => { if (key.key == "Enter") { props.onSubmit?.() } }) - cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.()) + cleanups.push(() => disposeIfDisposable(onKey)) // t.onScroll((ydisp) => { // console.log("Scroll position:", ydisp) // }) -- cgit v1.2.3