summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 10:02:31 -0600
committerGitHub <[email protected]>2026-02-06 10:02:31 -0600
commit2c58dd6203df7806f57ef6b29672091cb764e871 (patch)
tree10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/components
parenta4bc883595df9ea0f752079519081bc602408553 (diff)
downloadopencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz
opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/dialog-custom-provider.tsx82
-rw-r--r--packages/app/src/components/dialog-select-model.tsx14
-rw-r--r--packages/app/src/components/dialog-select-server.tsx98
-rw-r--r--packages/app/src/components/prompt-input.tsx314
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.test.ts67
-rw-r--r--packages/app/src/components/prompt-input/build-request-parts.ts174
-rw-r--r--packages/app/src/components/prompt-input/context-items.tsx82
-rw-r--r--packages/app/src/components/prompt-input/drag-overlay.tsx20
-rw-r--r--packages/app/src/components/prompt-input/image-attachments.tsx51
-rw-r--r--packages/app/src/components/prompt-input/placeholder.test.ts35
-rw-r--r--packages/app/src/components/prompt-input/placeholder.ts13
-rw-r--r--packages/app/src/components/prompt-input/slash-popover.tsx144
-rw-r--r--packages/app/src/components/prompt-input/submit.ts222
-rw-r--r--packages/app/src/components/server/server-row.tsx77
-rw-r--r--packages/app/src/components/status-popover.tsx127
-rw-r--r--packages/app/src/components/terminal.tsx23
16 files changed, 858 insertions, 685 deletions
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<string>()
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) {
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
- <div class="text-16-medium text-text-strong">Custom provider</div>
+ <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
</div>
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
- Configure an OpenAI-compatible provider. See the{" "}
+ {language.t("provider.custom.description.prefix")}
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
- provider config docs
+ {language.t("provider.custom.description.link")}
</Link>
- .
+ {language.t("provider.custom.description.suffix")}
</p>
<div class="flex flex-col gap-4">
<TextField
autofocus
- label="Provider ID"
- placeholder="myprovider"
- description="Lowercase letters, numbers, hyphens, or underscores"
+ label={language.t("provider.custom.field.providerID.label")}
+ placeholder={language.t("provider.custom.field.providerID.placeholder")}
+ description={language.t("provider.custom.field.providerID.description")}
value={form.providerID}
onChange={setForm.bind(null, "providerID")}
validationState={errors.providerID ? "invalid" : undefined}
error={errors.providerID}
/>
<TextField
- label="Display name"
- placeholder="My AI Provider"
+ label={language.t("provider.custom.field.name.label")}
+ placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name}
onChange={setForm.bind(null, "name")}
validationState={errors.name ? "invalid" : undefined}
error={errors.name}
/>
<TextField
- label="Base URL"
- placeholder="https://api.myprovider.com/v1"
+ label={language.t("provider.custom.field.baseURL.label")}
+ placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL}
onChange={setForm.bind(null, "baseURL")}
validationState={errors.baseURL ? "invalid" : undefined}
error={errors.baseURL}
/>
<TextField
- label="API key"
- placeholder="API key"
- description="Optional. Leave empty if you manage auth via headers."
+ label={language.t("provider.custom.field.apiKey.label")}
+ placeholder={language.t("provider.custom.field.apiKey.placeholder")}
+ description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey}
onChange={setForm.bind(null, "apiKey")}
/>
</div>
<div class="flex flex-col gap-3">
- <label class="text-12-medium text-text-weak">Models</label>
+ <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}>
{(m, i) => (
<div class="flex gap-2 items-start">
<div class="flex-1">
<TextField
- label="ID"
+ label={language.t("provider.custom.models.id.label")}
hideLabel
- placeholder="model-id"
+ placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id}
onChange={(v) => setForm("models", i(), "id", v)}
validationState={errors.models[i()]?.id ? "invalid" : undefined}
@@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
</div>
<div class="flex-1">
<TextField
- label="Name"
+ label={language.t("provider.custom.models.name.label")}
hideLabel
- placeholder="Display Name"
+ placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name}
onChange={(v) => 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")}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
- Add model
+ {language.t("provider.custom.models.add")}
</Button>
</div>
<div class="flex flex-col gap-3">
- <label class="text-12-medium text-text-weak">Headers (optional)</label>
+ <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}>
{(h, i) => (
<div class="flex gap-2 items-start">
<div class="flex-1">
<TextField
- label="Header"
+ label={language.t("provider.custom.headers.key.label")}
hideLabel
- placeholder="Header-Name"
+ placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key}
onChange={(v) => setForm("headers", i(), "key", v)}
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
@@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
</div>
<div class="flex-1">
<TextField
- label="Value"
+ label={language.t("provider.custom.headers.value.label")}
hideLabel
- placeholder="value"
+ placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value}
onChange={(v) => 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")}
/>
</div>
)}
</For>
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
- Add header
+ {language.t("provider.custom.headers.add")}
</Button>
</div>
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
- {form.saving ? "Saving..." : language.t("common.submit")}
+ {form.saving ? language.t("common.saving") : language.t("common.submit")}
</Button>
</form>
</div>
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<T extends ValidComponent = "div">(props: {
+type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
+
+export function ModelSelectorPopover(props: {
provider?: string
children?: JSX.Element
- triggerAs?: T
- triggerProps?: ComponentProps<T>
+ triggerAs?: ValidComponent
+ triggerProps?: ModelSelectorTriggerProps
}) {
const [store, setStore] = createStore<{
open: boolean
@@ -176,11 +178,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
placement="top-start"
gutter={8}
>
- <Kobalte.Trigger
- ref={(el) => setStore("trigger", el)}
- as={props.triggerAs ?? "div"}
- {...(props.triggerProps as any)}
- >
+ <Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
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<typeof usePlatform>): Promise<ServerStatus> {
- 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 (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
@@ -131,7 +116,7 @@ export function DialogSelectServer() {
const globalSDK = useGlobalSDK()
const language = useLanguage()
const [store, setStore] = createStore({
- status: {} as Record<string, ServerStatus | undefined>,
+ status: {} as Record<string, ServerHealth | undefined>,
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<string, ServerStatus> = {}
+ const results: Record<string, ServerHealth> = {}
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 (
- <span class="flex items-center gap-2">
- <span>{name}</span>
- <Show when={version}>
- <span class="text-text-invert-base">{version}</span>
- </Show>
- </span>
- )
- }
-
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
@@ -459,34 +416,19 @@ export function DialogSelectServer() {
/>
}
>
- <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
- <div
- class="flex items-center gap-3 px-4 min-w-0 flex-1"
- classList={{ "opacity-50": store.status[i]?.healthy === false }}
- >
- <div
- classList={{
- "size-1.5 rounded-full shrink-0": true,
- "bg-icon-success-base": store.status[i]?.healthy === true,
- "bg-icon-critical-base": store.status[i]?.healthy === false,
- "bg-border-weak-base": store.status[i] === undefined,
- }}
- />
- <span ref={nameRef} class="truncate">
- {serverDisplayName(i)}
- </span>
- <Show when={store.status[i]?.version}>
- <span ref={versionRef} class="text-text-weak text-14-regular truncate">
- {store.status[i]?.version}
- </span>
- </Show>
+ <ServerRow
+ url={i}
+ status={store.status[i]}
+ dimmed={store.status[i]?.healthy === false}
+ class="flex items-center gap-3 px-4 min-w-0 flex-1"
+ badge={
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
- </div>
- </Tooltip>
+ }
+ />
</Show>
<Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4">
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<PromptInputProps> = (props) => {
const sdk = useSDK()
const sync = useSync()
@@ -203,8 +196,8 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<typeof language.t>[0], params as never),
+ }),
+ )
const MAX_HISTORY = 100
const [history, setHistory] = persisted(
@@ -296,10 +297,6 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
- <Show when={store.popover}>
- <div
- ref={(el) => {
- 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()}
- >
- <Switch>
- <Match when={store.popover === "at"}>
- <Show
- when={atFlat().length > 0}
- fallback={<div class="text-text-weak px-2 py-1">{language.t("prompt.popover.emptyResults")}</div>}
- >
- <For each={atFlat().slice(0, 10)}>
- {(item) => (
- <button
- classList={{
- "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
- "bg-surface-raised-base-hover": atActive() === atKey(item),
- }}
- onClick={() => handleAtSelect(item)}
- onMouseEnter={() => setAtActive(atKey(item))}
- >
- <Show
- when={item.type === "agent"}
- fallback={
- <>
- <FileIcon
- node={{ path: (item as { type: "file"; path: string }).path, type: "file" }}
- class="shrink-0 size-4"
- />
- <div class="flex items-center text-14-regular min-w-0">
- <span class="text-text-weak whitespace-nowrap truncate min-w-0">
- {(() => {
- const path = (item as { type: "file"; path: string }).path
- return path.endsWith("/") ? path : getDirectory(path)
- })()}
- </span>
- <Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}>
- <span class="text-text-strong whitespace-nowrap">
- {getFilename((item as { type: "file"; path: string }).path)}
- </span>
- </Show>
- </div>
- </>
- }
- >
- <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
- <span class="text-14-regular text-text-strong whitespace-nowrap">
- @{(item as { type: "agent"; name: string }).name}
- </span>
- </Show>
- </button>
- )}
- </For>
- </Show>
- </Match>
- <Match when={store.popover === "slash"}>
- <Show
- when={slashFlat().length > 0}
- fallback={<div class="text-text-weak px-2 py-1">{language.t("prompt.popover.emptyCommands")}</div>}
- >
- <For each={slashFlat()}>
- {(cmd) => (
- <button
- data-slash-id={cmd.id}
- classList={{
- "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
- "bg-surface-raised-base-hover": slashActive() === cmd.id,
- }}
- onClick={() => handleSlashSelect(cmd)}
- onMouseEnter={() => setSlashActive(cmd.id)}
- >
- <div class="flex items-center gap-2 min-w-0">
- <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
- <Show when={cmd.description}>
- <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
- </Show>
- </div>
- <div class="flex items-center gap-2 shrink-0">
- <Show when={cmd.type === "custom" && cmd.source !== "command"}>
- <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
- {cmd.source === "skill"
- ? language.t("prompt.slash.badge.skill")
- : cmd.source === "mcp"
- ? language.t("prompt.slash.badge.mcp")
- : language.t("prompt.slash.badge.custom")}
- </span>
- </Show>
- <Show when={command.keybind(cmd.id)}>
- <span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
- </Show>
- </div>
- </button>
- )}
- </For>
- </Show>
- </Match>
- </Switch>
- </div>
- </Show>
+ <PromptPopover
+ popover={store.popover}
+ setSlashPopoverRef={(el) => (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<typeof language.t>[0])}
+ />
<form
onSubmit={handleSubmit}
classList={{
@@ -1042,124 +950,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
[props.class ?? ""]: !!props.class,
}}
>
- <Show when={store.dragging}>
- <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
- <div class="flex flex-col items-center gap-2 text-text-weak">
- <Icon name="photo" class="size-8" />
- <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
- </div>
- </div>
- </Show>
- <Show when={prompt.context.items().length > 0}>
- <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
- <For each={prompt.context.items()}>
- {(item) => {
- const active = () => {
- const a = comments.active()
- return !!item.commentID && item.commentID === a?.id && item.path === a?.file
- }
- return (
- <Tooltip
- value={
- <span class="flex max-w-[300px]">
- <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
- {getDirectory(item.path)}
- </span>
- <span class="shrink-0">{getFilename(item.path)}</span>
- </span>
- }
- placement="top"
- openDelay={2000}
- >
- <div
- classList={{
- "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
- "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !active(),
- "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
- active(),
- "bg-background-stronger": !active(),
- }}
- onClick={() => {
- openComment(item)
- }}
- >
- <div class="flex items-center gap-1.5">
- <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
- <div class="flex items-center text-11-regular min-w-0 font-medium">
- <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
- <Show when={item.selection}>
- {(sel) => (
- <span class="text-text-weak whitespace-nowrap shrink-0">
- {sel().startLine === sel().endLine
- ? `:${sel().startLine}`
- : `:${sel().startLine}-${sel().endLine}`}
- </span>
- )}
- </Show>
- </div>
- <IconButton
- type="button"
- icon="close-small"
- variant="ghost"
- class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
- onClick={(e) => {
- e.stopPropagation()
- if (item.commentID) comments.remove(item.path, item.commentID)
- prompt.context.remove(item.key)
- }}
- aria-label={language.t("prompt.context.removeFile")}
- />
- </div>
- <Show when={item.comment}>
- {(comment) => (
- <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>
- )}
- </Show>
- </div>
- </Tooltip>
- )
- }}
- </For>
- </div>
- </Show>
- <Show when={imageAttachments().length > 0}>
- <div class="flex flex-wrap gap-2 px-3 pt-3">
- <For each={imageAttachments()}>
- {(attachment) => (
- <div class="relative group">
- <Show
- when={attachment.mime.startsWith("image/")}
- fallback={
- <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
- <Icon name="folder" class="size-6 text-text-weak" />
- </div>
- }
- >
- <img
- src={attachment.dataUrl}
- alt={attachment.filename}
- class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
- onClick={() =>
- dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
- }
- />
- </Show>
- <button
- type="button"
- onClick={() => removeImageAttachment(attachment.id)}
- class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
- aria-label={language.t("prompt.attachment.remove")}
- >
- <Icon name="close" class="size-3 text-text-weak" />
- </button>
- <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
- <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
- </div>
- </div>
- )}
- </For>
- </div>
- </Show>
+ <PromptDragOverlay dragging={store.dragging} label={language.t("prompt.dropzone.label")} />
+ <PromptContextItems
+ items={prompt.context.items()}
+ active={(item) => {
+ 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<typeof language.t>[0])}
+ />
+ <PromptImageAttachments
+ attachments={imageAttachments()}
+ onOpen={(attachment) =>
+ dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
+ }
+ onRemove={removeImageAttachment}
+ removeLabel={language.t("prompt.attachment.remove")}
+ />
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
@@ -1169,15 +981,7 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (props) => {
/>
<Show when={!prompt.dirty()}>
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
- {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()}
</div>
</Show>
</div>
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<ContextItemsProps> = (props) => {
+ return (
+ <Show when={props.items.length > 0}>
+ <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
+ <For each={props.items}>
+ {(item) => (
+ <Tooltip
+ value={
+ <span class="flex max-w-[300px]">
+ <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
+ {getDirectory(item.path)}
+ </span>
+ <span class="shrink-0">{getFilename(item.path)}</span>
+ </span>
+ }
+ placement="top"
+ openDelay={2000}
+ >
+ <div
+ classList={{
+ "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
+ "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
+ "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
+ props.active(item),
+ "bg-background-stronger": !props.active(item),
+ }}
+ onClick={() => props.openComment(item)}
+ >
+ <div class="flex items-center gap-1.5">
+ <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
+ <div class="flex items-center text-11-regular min-w-0 font-medium">
+ <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
+ <Show when={item.selection}>
+ {(sel) => (
+ <span class="text-text-weak whitespace-nowrap shrink-0">
+ {sel().startLine === sel().endLine
+ ? `:${sel().startLine}`
+ : `:${sel().startLine}-${sel().endLine}`}
+ </span>
+ )}
+ </Show>
+ </div>
+ <IconButton
+ type="button"
+ icon="close-small"
+ variant="ghost"
+ class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
+ onClick={(e) => {
+ e.stopPropagation()
+ props.remove(item)
+ }}
+ aria-label={props.t("prompt.context.removeFile")}
+ />
+ </div>
+ <Show when={item.comment}>
+ {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
+ </Show>
+ </div>
+ </Tooltip>
+ )}
+ </For>
+ </div>
+ </Show>
+ )
+}
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<PromptDragOverlayProps> = (props) => {
+ return (
+ <Show when={props.dragging}>
+ <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
+ <div class="flex flex-col items-center gap-2 text-text-weak">
+ <Icon name="photo" class="size-8" />
+ <span class="text-14-regular">{props.label}</span>
+ </div>
+ </div>
+ </Show>
+ )
+}
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<PromptImageAttachmentsProps> = (props) => {
+ return (
+ <Show when={props.attachments.length > 0}>
+ <div class="flex flex-wrap gap-2 px-3 pt-3">
+ <For each={props.attachments}>
+ {(attachment) => (
+ <div class="relative group">
+ <Show
+ when={attachment.mime.startsWith("image/")}
+ fallback={
+ <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
+ <Icon name="folder" class="size-6 text-text-weak" />
+ </div>
+ }
+ >
+ <img
+ src={attachment.dataUrl}
+ alt={attachment.filename}
+ class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+ onClick={() => props.onOpen(attachment)}
+ />
+ </Show>
+ <button
+ type="button"
+ onClick={() => props.onRemove(attachment.id)}
+ class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+ aria-label={props.removeLabel}
+ >
+ <Icon name="close" class="size-3 text-text-weak" />
+ </button>
+ <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
+ <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
+ </div>
+ </div>
+ )}
+ </For>
+ </div>
+ </Show>
+ )
+}
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<string, string>) => `${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, string>) => 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<PromptPopoverProps> = (props) => {
+ return (
+ <Show when={props.popover}>
+ <div
+ ref={(el) => {
+ 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()}
+ >
+ <Switch>
+ <Match when={props.popover === "at"}>
+ <Show
+ when={props.atFlat.length > 0}
+ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
+ >
+ <For each={props.atFlat.slice(0, 10)}>
+ {(item) => (
+ <button
+ classList={{
+ "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
+ "bg-surface-raised-base-hover": props.atActive === props.atKey(item),
+ }}
+ onClick={() => props.onAtSelect(item)}
+ onMouseEnter={() => props.setAtActive(props.atKey(item))}
+ >
+ <Show
+ when={item.type === "agent"}
+ fallback={
+ <>
+ <FileIcon
+ node={{ path: item.type === "file" ? item.path : "", type: "file" }}
+ class="shrink-0 size-4"
+ />
+ <div class="flex items-center text-14-regular min-w-0">
+ <span class="text-text-weak whitespace-nowrap truncate min-w-0">
+ {item.type === "file"
+ ? item.path.endsWith("/")
+ ? item.path
+ : getDirectory(item.path)
+ : ""}
+ </span>
+ <Show when={item.type === "file" && !item.path.endsWith("/")}>
+ <span class="text-text-strong whitespace-nowrap">
+ {item.type === "file" ? getFilename(item.path) : ""}
+ </span>
+ </Show>
+ </div>
+ </>
+ }
+ >
+ <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
+ <span class="text-14-regular text-text-strong whitespace-nowrap">
+ @{item.type === "agent" ? item.name : ""}
+ </span>
+ </Show>
+ </button>
+ )}
+ </For>
+ </Show>
+ </Match>
+ <Match when={props.popover === "slash"}>
+ <Show
+ when={props.slashFlat.length > 0}
+ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
+ >
+ <For each={props.slashFlat}>
+ {(cmd) => (
+ <button
+ data-slash-id={cmd.id}
+ classList={{
+ "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
+ "bg-surface-raised-base-hover": props.slashActive === cmd.id,
+ }}
+ onClick={() => props.onSlashSelect(cmd)}
+ onMouseEnter={() => props.setSlashActive(cmd.id)}
+ >
+ <div class="flex items-center gap-2 min-w-0">
+ <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
+ <Show when={cmd.description}>
+ <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
+ </Show>
+ </div>
+ <div class="flex items-center gap-2 shrink-0">
+ <Show when={cmd.type === "custom" && cmd.source !== "command"}>
+ <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
+ {cmd.source === "skill"
+ ? props.t("prompt.slash.badge.skill")
+ : cmd.source === "mcp"
+ ? props.t("prompt.slash.badge.mcp")
+ : props.t("prompt.slash.badge.custom")}
+ </span>
+ </Show>
+ <Show when={props.commandKeybind(cmd.id)}>
+ <span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
+ </Show>
+ </div>
+ </button>
+ )}
+ </For>
+ </Show>
+ </Match>
+ </Switch>
+ </div>
+ </Show>
+ )
+}
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 = () => (
+ <span class="flex items-center gap-2">
+ <span>{serverDisplayName(props.url)}</span>
+ <Show when={props.status?.version}>
+ <span class="text-text-invert-base">{props.status?.version}</span>
+ </Show>
+ </span>
+ )
+
+ return (
+ <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
+ <div class={props.class} classList={{ "opacity-50": props.dimmed }}>
+ <div
+ classList={{
+ "size-1.5 rounded-full shrink-0": true,
+ "bg-icon-success-base": props.status?.healthy === true,
+ "bg-icon-critical-base": props.status?.healthy === false,
+ "bg-border-weak-base": props.status === undefined,
+ }}
+ />
+ <span ref={nameRef} class={props.nameClass ?? "truncate"}>
+ {serverDisplayName(props.url)}
+ </span>
+ <Show when={props.status?.version}>
+ <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
+ {props.status?.version}
+ </span>
+ </Show>
+ {props.badge}
+ {props.children}
+ </div>
+ </Tooltip>
+ )
+}
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<typeof usePlatform>): Promise<ServerStatus> {
- 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<string, ServerStatus | undefined>,
+ status: {} as Record<string, ServerHealth | undefined>,
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<string, ServerStatus> = {}
+ const results: Record<string, ServerHealth> = {}
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 (
- <span class="flex items-center gap-2">
- <span>{name}</span>
- <Show when={version}>
- <span class="text-text-invert-base">{version}</span>
- </Show>
- </span>
- )
- }
return (
- <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
- <button
- type="button"
- class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
- classList={{
- "opacity-50": isBlocked(),
- "hover:bg-surface-raised-base-hover": !isBlocked(),
- "cursor-not-allowed": isBlocked(),
- }}
- aria-disabled={isBlocked()}
- onClick={() => {
- if (isBlocked()) return
- server.setActive(url)
- navigate("/")
- }}
+ <button
+ type="button"
+ class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
+ classList={{
+ "hover:bg-surface-raised-base-hover": !isBlocked(),
+ "cursor-not-allowed": isBlocked(),
+ }}
+ aria-disabled={isBlocked()}
+ onClick={() => {
+ if (isBlocked()) return
+ server.setActive(url)
+ navigate("/")
+ }}
+ >
+ <ServerRow
+ url={url}
+ status={status()}
+ dimmed={isBlocked()}
+ class="flex items-center gap-2 w-full min-w-0"
+ nameClass="text-14-regular text-text-base truncate"
+ versionClass="text-12-regular text-text-weak truncate"
+ badge={
+ <Show when={isDefault()}>
+ <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
+ {language.t("common.default")}
+ </span>
+ </Show>
+ }
>
- <div
- classList={{
- "size-1.5 rounded-full shrink-0": true,
- "bg-icon-success-base": status()?.healthy === true,
- "bg-icon-critical-base": status()?.healthy === false,
- "bg-border-weak-base": status() === undefined,
- }}
- />
- <span ref={nameRef} class="text-14-regular text-text-base truncate">
- {serverDisplayName(url)}
- </span>
- <Show when={status()?.version}>
- <span ref={versionRef} class="text-12-regular text-text-weak truncate">
- {status()?.version}
- </span>
- </Show>
- <Show when={isDefault()}>
- <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
- {language.t("common.default")}
- </span>
- </Show>
<div class="flex-1" />
<Show when={isActive()}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
- </button>
- </Tooltip>
+ </ServerRow>
+ </button>
)
}}
</For>
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)
// })