summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/dialog-connect-provider.tsx383
-rw-r--r--packages/app/src/components/dialog-manage-models.tsx57
-rw-r--r--packages/app/src/components/dialog-select-file.tsx48
-rw-r--r--packages/app/src/components/dialog-select-model-unpaid.tsx110
-rw-r--r--packages/app/src/components/dialog-select-model.tsx83
-rw-r--r--packages/app/src/components/dialog-select-provider.tsx54
-rw-r--r--packages/app/src/components/file-tree.tsx112
-rw-r--r--packages/app/src/components/header.tsx209
-rw-r--r--packages/app/src/components/link.tsx17
-rw-r--r--packages/app/src/components/prompt-input.tsx1153
-rw-r--r--packages/app/src/components/session-context-usage.tsx64
-rw-r--r--packages/app/src/components/terminal.tsx160
12 files changed, 2450 insertions, 0 deletions
diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx
new file mode 100644
index 000000000..789a5d3b7
--- /dev/null
+++ b/packages/app/src/components/dialog-connect-provider.tsx
@@ -0,0 +1,383 @@
+import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import type { IconName } from "@opencode-ai/ui/icons/provider"
+import { List, type ListRef } from "@opencode-ai/ui/list"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { showToast } from "@opencode-ai/ui/toast"
+import { iife } from "@opencode-ai/util/iife"
+import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { Link } from "@/components/link"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
+import { DialogSelectModel } from "./dialog-select-model"
+import { DialogSelectProvider } from "./dialog-select-provider"
+
+export function DialogConnectProvider(props: { provider: string }) {
+ const dialog = useDialog()
+ const globalSync = useGlobalSync()
+ const globalSDK = useGlobalSDK()
+ const platform = usePlatform()
+ const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
+ const methods = createMemo(
+ () =>
+ globalSync.data.provider_auth[props.provider] ?? [
+ {
+ type: "api",
+ label: "API key",
+ },
+ ],
+ )
+ const [store, setStore] = createStore({
+ methodIndex: undefined as undefined | number,
+ authorization: undefined as undefined | ProviderAuthAuthorization,
+ state: "pending" as undefined | "pending" | "complete" | "error",
+ error: undefined as string | undefined,
+ })
+
+ const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
+
+ async function selectMethod(index: number) {
+ const method = methods()[index]
+ setStore(
+ produce((draft) => {
+ draft.methodIndex = index
+ draft.authorization = undefined
+ draft.state = undefined
+ draft.error = undefined
+ }),
+ )
+
+ if (method.type === "oauth") {
+ setStore("state", "pending")
+ const start = Date.now()
+ await globalSDK.client.provider.oauth
+ .authorize(
+ {
+ providerID: props.provider,
+ method: index,
+ },
+ { throwOnError: true },
+ )
+ .then((x) => {
+ const elapsed = Date.now() - start
+ const delay = 1000 - elapsed
+
+ if (delay > 0) {
+ setTimeout(() => {
+ setStore("state", "complete")
+ setStore("authorization", x.data!)
+ }, delay)
+ return
+ }
+ setStore("state", "complete")
+ setStore("authorization", x.data!)
+ })
+ .catch((e) => {
+ setStore("state", "error")
+ setStore("error", String(e))
+ })
+ }
+ }
+
+ let listRef: ListRef | undefined
+ function handleKey(e: KeyboardEvent) {
+ if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
+ return
+ }
+ if (e.key === "Escape") return
+ listRef?.onKeyDown(e)
+ }
+
+ onMount(() => {
+ if (methods().length === 1) {
+ selectMethod(0)
+ }
+ document.addEventListener("keydown", handleKey)
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKey)
+ })
+ })
+
+ async function complete() {
+ await globalSDK.client.global.dispose()
+ dialog.close()
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: `${provider().name} connected`,
+ description: `${provider().name} models are now available to use.`,
+ })
+ }
+
+ function goBack() {
+ if (methods().length === 1) {
+ dialog.show(() => <DialogSelectProvider />)
+ return
+ }
+ if (store.authorization) {
+ setStore("authorization", undefined)
+ setStore("methodIndex", undefined)
+ return
+ }
+ if (store.methodIndex) {
+ setStore("methodIndex", undefined)
+ return
+ }
+ dialog.show(() => <DialogSelectProvider />)
+ }
+
+ return (
+ <Dialog title={<IconButton tabIndex={-1} icon="arrow-left" variant="ghost" onClick={goBack} />}>
+ <div class="flex flex-col gap-6 px-2.5 pb-3">
+ <div class="px-2.5 flex gap-4 items-center">
+ <ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
+ <div class="text-16-medium text-text-strong">
+ <Switch>
+ <Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
+ Login with Claude Pro/Max
+ </Match>
+ <Match when={true}>Connect {provider().name}</Match>
+ </Switch>
+ </div>
+ </div>
+ <div class="px-2.5 pb-10 flex flex-col gap-6">
+ <Switch>
+ <Match when={store.methodIndex === undefined}>
+ <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
+ <div class="">
+ <List
+ ref={(ref) => {
+ listRef = ref
+ }}
+ items={methods}
+ key={(m) => m?.label}
+ onSelect={async (method, index) => {
+ if (!method) return
+ selectMethod(index)
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-2">
+ <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
+ <div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
+ </div>
+ <span>{i.label}</span>
+ </div>
+ )}
+ </List>
+ </div>
+ </Match>
+ <Match when={store.state === "pending"}>
+ <div class="text-14-regular text-text-base">
+ <div class="flex items-center gap-x-2">
+ <Spinner />
+ <span>Authorization in progress...</span>
+ </div>
+ </div>
+ </Match>
+ <Match when={store.state === "error"}>
+ <div class="text-14-regular text-text-base">
+ <div class="flex items-center gap-x-2">
+ <Icon name="circle-ban-sign" class="text-icon-critical-base" />
+ <span>Authorization failed: {store.error}</span>
+ </div>
+ </div>
+ </Match>
+ <Match when={method()?.type === "api"}>
+ {iife(() => {
+ const [formStore, setFormStore] = createStore({
+ value: "",
+ error: undefined as string | undefined,
+ })
+
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+
+ const form = e.currentTarget as HTMLFormElement
+ const formData = new FormData(form)
+ const apiKey = formData.get("apiKey") as string
+
+ if (!apiKey?.trim()) {
+ setFormStore("error", "API key is required")
+ return
+ }
+
+ setFormStore("error", undefined)
+ await globalSDK.client.auth.set({
+ providerID: props.provider,
+ auth: {
+ type: "api",
+ key: apiKey,
+ },
+ })
+ await complete()
+ }
+
+ return (
+ <div class="flex flex-col gap-6">
+ <Switch>
+ <Match when={provider().id === "opencode"}>
+ <div class="flex flex-col gap-4">
+ <div class="text-14-regular text-text-base">
+ OpenCode Zen gives you access to a curated set of reliable optimized models for coding
+ agents.
+ </div>
+ <div class="text-14-regular text-text-base">
+ With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.
+ </div>
+ <div class="text-14-regular text-text-base">
+ Visit{" "}
+ <Link href="https://opencode.ai/zen" tabIndex={-1}>
+ opencode.ai/zen
+ </Link>{" "}
+ to collect your API key.
+ </div>
+ </div>
+ </Match>
+ <Match when={true}>
+ <div class="text-14-regular text-text-base">
+ Enter your {provider().name} API key to connect your account and use {provider().name} models
+ in OpenCode.
+ </div>
+ </Match>
+ </Switch>
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+ <TextField
+ autofocus
+ type="text"
+ label={`${provider().name} API key`}
+ placeholder="API key"
+ name="apiKey"
+ value={formStore.value}
+ onChange={setFormStore.bind(null, "value")}
+ validationState={formStore.error ? "invalid" : undefined}
+ error={formStore.error}
+ />
+ <Button class="w-auto" type="submit" size="large" variant="primary">
+ Submit
+ </Button>
+ </form>
+ </div>
+ )
+ })}
+ </Match>
+ <Match when={method()?.type === "oauth"}>
+ <Switch>
+ <Match when={store.authorization?.method === "code"}>
+ {iife(() => {
+ const [formStore, setFormStore] = createStore({
+ value: "",
+ error: undefined as string | undefined,
+ })
+
+ onMount(() => {
+ if (store.authorization?.method === "code" && store.authorization?.url) {
+ platform.openLink(store.authorization.url)
+ }
+ })
+
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+
+ const form = e.currentTarget as HTMLFormElement
+ const formData = new FormData(form)
+ const code = formData.get("code") as string
+
+ if (!code?.trim()) {
+ setFormStore("error", "Authorization code is required")
+ return
+ }
+
+ setFormStore("error", undefined)
+ const { error } = await globalSDK.client.provider.oauth.callback({
+ providerID: props.provider,
+ method: store.methodIndex,
+ code,
+ })
+ if (!error) {
+ await complete()
+ return
+ }
+ setFormStore("error", "Invalid authorization code")
+ }
+
+ return (
+ <div class="flex flex-col gap-6">
+ <div class="text-14-regular text-text-base">
+ Visit <Link href={store.authorization!.url}>this link</Link> to collect your authorization
+ code to connect your account and use {provider().name} models in OpenCode.
+ </div>
+ <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
+ <TextField
+ autofocus
+ type="text"
+ label={`${method()?.label} authorization code`}
+ placeholder="Authorization code"
+ name="code"
+ value={formStore.value}
+ onChange={setFormStore.bind(null, "value")}
+ validationState={formStore.error ? "invalid" : undefined}
+ error={formStore.error}
+ />
+ <Button class="w-auto" type="submit" size="large" variant="primary">
+ Submit
+ </Button>
+ </form>
+ </div>
+ )
+ })}
+ </Match>
+ <Match when={store.authorization?.method === "auto"}>
+ {iife(() => {
+ const code = createMemo(() => {
+ const instructions = store.authorization?.instructions
+ if (instructions?.includes(":")) {
+ return instructions?.split(":")[1]?.trim()
+ }
+ return instructions
+ })
+
+ onMount(async () => {
+ const result = await globalSDK.client.provider.oauth.callback({
+ providerID: props.provider,
+ method: store.methodIndex,
+ })
+ if (result.error) {
+ // TODO: show error
+ dialog.close()
+ return
+ }
+ await complete()
+ })
+
+ return (
+ <div class="flex flex-col gap-6">
+ <div class="text-14-regular text-text-base">
+ Visit <Link href={store.authorization!.url}>this link</Link> and enter the code below to
+ connect your account and use {provider().name} models in OpenCode.
+ </div>
+ <TextField label="Confirmation code" class="font-mono" value={code()} readOnly copyable />
+ <div class="text-14-regular text-text-base flex items-center gap-4">
+ <Spinner />
+ <span>Waiting for authorization...</span>
+ </div>
+ </div>
+ )
+ })}
+ </Match>
+ </Switch>
+ </Match>
+ </Switch>
+ </div>
+ </div>
+ </Dialog>
+ )
+}
diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx
new file mode 100644
index 000000000..66d125288
--- /dev/null
+++ b/packages/app/src/components/dialog-manage-models.tsx
@@ -0,0 +1,57 @@
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { Switch } from "@opencode-ai/ui/switch"
+import type { Component } from "solid-js"
+import { useLocal } from "@/context/local"
+import { popularProviders } from "@/hooks/use-providers"
+
+export const DialogManageModels: Component = () => {
+ const local = useLocal()
+ return (
+ <Dialog title="Manage models" description="Customize which models appear in the model selector.">
+ <List
+ search={{ placeholder: "Search models", autofocus: true }}
+ emptyMessage="No model results"
+ key={(x) => `${x?.provider?.id}:${x?.id}`}
+ items={local.model.list()}
+ filterKeys={["provider.name", "name", "id"]}
+ sortBy={(a, b) => a.name.localeCompare(b.name)}
+ groupBy={(x) => x.provider.name}
+ sortGroupsBy={(a, b) => {
+ const aProvider = a.items[0].provider.id
+ const bProvider = b.items[0].provider.id
+ if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
+ if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
+ return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
+ }}
+ onSelect={(x) => {
+ if (!x) return
+ const visible = local.model.visible({
+ modelID: x.id,
+ providerID: x.provider.id,
+ })
+ local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center justify-between gap-x-3">
+ <span>{i.name}</span>
+ <div onClick={(e) => e.stopPropagation()}>
+ <Switch
+ checked={
+ !!local.model.visible({
+ modelID: i.id,
+ providerID: i.provider.id,
+ })
+ }
+ onChange={(checked) => {
+ local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
+ }}
+ />
+ </div>
+ </div>
+ )}
+ </List>
+ </Dialog>
+ )
+}
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx
new file mode 100644
index 000000000..b27afdc8b
--- /dev/null
+++ b/packages/app/src/components/dialog-select-file.tsx
@@ -0,0 +1,48 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { List } from "@opencode-ai/ui/list"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { useParams } from "@solidjs/router"
+import { createMemo } from "solid-js"
+import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
+
+export function DialogSelectFile() {
+ const layout = useLayout()
+ const local = useLocal()
+ const dialog = useDialog()
+ const params = useParams()
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const tabs = createMemo(() => layout.tabs(sessionKey()))
+ return (
+ <Dialog title="Select file">
+ <List
+ search={{ placeholder: "Search files", autofocus: true }}
+ emptyMessage="No files found"
+ items={local.file.searchFiles}
+ key={(x) => x}
+ onSelect={(path) => {
+ if (path) {
+ tabs().open("file://" + path)
+ }
+ dialog.close()
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center justify-between rounded-md">
+ <div class="flex items-center gap-x-3 grow min-w-0">
+ <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
+ <div class="flex items-center text-14-regular">
+ <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
+ {getDirectory(i)}
+ </span>
+ <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
+ </div>
+ </div>
+ </div>
+ )}
+ </List>
+ </Dialog>
+ )
+}
diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx
new file mode 100644
index 000000000..24ec8092d
--- /dev/null
+++ b/packages/app/src/components/dialog-select-model-unpaid.tsx
@@ -0,0 +1,110 @@
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import type { IconName } from "@opencode-ai/ui/icons/provider"
+import { List, type ListRef } from "@opencode-ai/ui/list"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { Tag } from "@opencode-ai/ui/tag"
+import { type Component, onCleanup, onMount, Show } from "solid-js"
+import { useLocal } from "@/context/local"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { DialogConnectProvider } from "./dialog-connect-provider"
+import { DialogSelectProvider } from "./dialog-select-provider"
+
+export const DialogSelectModelUnpaid: Component = () => {
+ const local = useLocal()
+ const dialog = useDialog()
+ const providers = useProviders()
+
+ let listRef: ListRef | undefined
+ const handleKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") return
+ listRef?.onKeyDown(e)
+ }
+
+ onMount(() => {
+ document.addEventListener("keydown", handleKey)
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKey)
+ })
+ })
+
+ return (
+ <Dialog title="Select model">
+ <div class="flex flex-col gap-3 px-2.5">
+ <div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
+ <List
+ ref={(ref) => (listRef = ref)}
+ items={local.model.list}
+ current={local.model.current()}
+ key={(x) => `${x.provider.id}:${x.id}`}
+ onSelect={(x) => {
+ local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+ recent: true,
+ })
+ dialog.close()
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-2.5">
+ <span>{i.name}</span>
+ <Tag>Free</Tag>
+ <Show when={i.latest}>
+ <Tag>Latest</Tag>
+ </Show>
+ </div>
+ )}
+ </List>
+ <div />
+ <div />
+ </div>
+ <div class="px-1.5 pb-1.5">
+ <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
+ <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
+ <div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
+ <div class="w-full">
+ <List
+ class="w-full px-0"
+ key={(x) => x?.id}
+ items={providers.popular}
+ activeIcon="plus-small"
+ sortBy={(a, b) => {
+ if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
+ return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
+ return a.name.localeCompare(b.name)
+ }}
+ onSelect={(x) => {
+ if (!x) return
+ dialog.show(() => <DialogConnectProvider provider={x.id} />)
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-3">
+ <ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
+ <span>{i.name}</span>
+ <Show when={i.id === "opencode"}>
+ <Tag>Recommended</Tag>
+ </Show>
+ <Show when={i.id === "anthropic"}>
+ <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
+ </Show>
+ </div>
+ )}
+ </List>
+ <Button
+ variant="ghost"
+ class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
+ icon="dot-grid"
+ onClick={() => {
+ dialog.show(() => <DialogSelectProvider />)
+ }}
+ >
+ View all providers
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </Dialog>
+ )
+}
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx
new file mode 100644
index 000000000..54783386a
--- /dev/null
+++ b/packages/app/src/components/dialog-select-model.tsx
@@ -0,0 +1,83 @@
+import { Component, createMemo, Show } from "solid-js"
+import { useLocal } from "@/context/local"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { popularProviders } from "@/hooks/use-providers"
+import { Button } from "@opencode-ai/ui/button"
+import { Tag } from "@opencode-ai/ui/tag"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { DialogSelectProvider } from "./dialog-select-provider"
+import { DialogManageModels } from "./dialog-manage-models"
+
+export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
+ const local = useLocal()
+ const dialog = useDialog()
+
+ const models = createMemo(() =>
+ local.model
+ .list()
+ .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
+ .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
+ )
+
+ return (
+ <Dialog
+ title="Select model"
+ action={
+ <Button
+ class="h-7 -my-1 text-14-medium"
+ icon="plus-small"
+ tabIndex={-1}
+ onClick={() => dialog.show(() => <DialogSelectProvider />)}
+ >
+ Connect provider
+ </Button>
+ }
+ >
+ <List
+ search={{ placeholder: "Search models", autofocus: true }}
+ emptyMessage="No model results"
+ key={(x) => `${x.provider.id}:${x.id}`}
+ items={models}
+ current={local.model.current()}
+ filterKeys={["provider.name", "name", "id"]}
+ sortBy={(a, b) => a.name.localeCompare(b.name)}
+ groupBy={(x) => x.provider.name}
+ sortGroupsBy={(a, b) => {
+ if (a.category === "Recent" && b.category !== "Recent") return -1
+ if (b.category === "Recent" && a.category !== "Recent") return 1
+ const aProvider = a.items[0].provider.id
+ const bProvider = b.items[0].provider.id
+ if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
+ if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
+ return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
+ }}
+ onSelect={(x) => {
+ local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+ recent: true,
+ })
+ dialog.close()
+ }}
+ >
+ {(i) => (
+ <div class="w-full flex items-center gap-x-3">
+ <span>{i.name}</span>
+ <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
+ <Tag>Free</Tag>
+ </Show>
+ <Show when={i.latest}>
+ <Tag>Latest</Tag>
+ </Show>
+ </div>
+ )}
+ </List>
+ <Button
+ variant="ghost"
+ class="ml-3 mt-5 mb-6 text-text-base self-start"
+ onClick={() => dialog.show(() => <DialogManageModels />)}
+ >
+ Manage models
+ </Button>
+ </Dialog>
+ )
+}
diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx
new file mode 100644
index 000000000..5bbde5d41
--- /dev/null
+++ b/packages/app/src/components/dialog-select-provider.tsx
@@ -0,0 +1,54 @@
+import { Component, Show } from "solid-js"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { Tag } from "@opencode-ai/ui/tag"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { IconName } from "@opencode-ai/ui/icons/provider"
+import { DialogConnectProvider } from "./dialog-connect-provider"
+
+export const DialogSelectProvider: Component = () => {
+ const dialog = useDialog()
+ const providers = useProviders()
+
+ return (
+ <Dialog title="Connect provider">
+ <List
+ search={{ placeholder: "Search providers", autofocus: true }}
+ activeIcon="plus-small"
+ key={(x) => x?.id}
+ items={providers.all}
+ filterKeys={["id", "name"]}
+ groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
+ sortBy={(a, b) => {
+ if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
+ return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
+ return a.name.localeCompare(b.name)
+ }}
+ sortGroupsBy={(a, b) => {
+ if (a.category === "Popular" && b.category !== "Popular") return -1
+ if (b.category === "Popular" && a.category !== "Popular") return 1
+ return 0
+ }}
+ onSelect={(x) => {
+ if (!x) return
+ dialog.show(() => <DialogConnectProvider provider={x.id} />)
+ }}
+ >
+ {(i) => (
+ <div class="px-1.25 w-full flex items-center gap-x-3">
+ <ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
+ <span>{i.name}</span>
+ <Show when={i.id === "opencode"}>
+ <Tag>Recommended</Tag>
+ </Show>
+ <Show when={i.id === "anthropic"}>
+ <div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
+ </Show>
+ </div>
+ )}
+ </List>
+ </Dialog>
+ )
+}
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
new file mode 100644
index 000000000..0841c71d1
--- /dev/null
+++ b/packages/app/src/components/file-tree.tsx
@@ -0,0 +1,112 @@
+import { useLocal, type LocalFile } from "@/context/local"
+import { Collapsible } from "@opencode-ai/ui/collapsible"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
+import { Dynamic } from "solid-js/web"
+
+export default function FileTree(props: {
+ path: string
+ class?: string
+ nodeClass?: string
+ level?: number
+ onFileClick?: (file: LocalFile) => void
+}) {
+ const local = useLocal()
+ const level = props.level ?? 0
+
+ const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => (
+ <Dynamic
+ component={p.as ?? "div"}
+ classList={{
+ "p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
+ // "bg-background-element": local.file.active()?.path === p.node.path,
+ [props.nodeClass ?? ""]: !!props.nodeClass,
+ }}
+ style={`padding-left: ${level * 10}px`}
+ draggable={true}
+ onDragStart={(e: any) => {
+ const evt = e as globalThis.DragEvent
+ evt.dataTransfer!.effectAllowed = "copy"
+ evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
+
+ // Create custom drag image without margins
+ const dragImage = document.createElement("div")
+ dragImage.className =
+ "flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
+ dragImage.style.position = "absolute"
+ dragImage.style.top = "-1000px"
+
+ // Copy only the icon and text content without padding
+ const icon = e.currentTarget.querySelector("svg")
+ const text = e.currentTarget.querySelector("span")
+ if (icon && text) {
+ dragImage.innerHTML = icon.outerHTML + text.outerHTML
+ }
+
+ document.body.appendChild(dragImage)
+ evt.dataTransfer!.setDragImage(dragImage, 0, 12)
+ setTimeout(() => document.body.removeChild(dragImage), 0)
+ }}
+ {...p}
+ >
+ {p.children}
+ <span
+ classList={{
+ "text-xs whitespace-nowrap truncate": true,
+ "text-text-muted/40": p.node.ignored,
+ "text-text-muted/80": !p.node.ignored,
+ // "!text-text": local.file.active()?.path === p.node.path,
+ "!text-primary": local.file.changed(p.node.path),
+ }}
+ >
+ {p.node.name}
+ </span>
+ <Show when={local.file.changed(p.node.path)}>
+ <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" />
+ </Show>
+ </Dynamic>
+ )
+
+ return (
+ <div class={`flex flex-col ${props.class}`}>
+ <For each={local.file.children(props.path)}>
+ {(node) => (
+ <Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right">
+ <Switch>
+ <Match when={node.type === "directory"}>
+ <Collapsible
+ variant="ghost"
+ class="w-full"
+ forceMount={false}
+ // open={local.file.node(node.path)?.expanded}
+ onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
+ >
+ <Collapsible.Trigger>
+ <Node node={node}>
+ <Collapsible.Arrow class="text-text-muted/60 ml-1" />
+ <FileIcon
+ node={node}
+ // expanded={local.file.node(node.path).expanded}
+ class="text-text-muted/60 -ml-1"
+ />
+ </Node>
+ </Collapsible.Trigger>
+ <Collapsible.Content>
+ <FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
+ </Collapsible.Content>
+ </Collapsible>
+ </Match>
+ <Match when={node.type === "file"}>
+ <Node node={node} as="button" onClick={() => props.onFileClick?.(node)}>
+ <div class="w-4 shrink-0" />
+ <FileIcon node={node} class="text-primary" />
+ </Node>
+ </Match>
+ </Switch>
+ </Tooltip>
+ )}
+ </For>
+ </div>
+ )
+}
diff --git a/packages/app/src/components/header.tsx b/packages/app/src/components/header.tsx
new file mode 100644
index 000000000..ec7cdfa25
--- /dev/null
+++ b/packages/app/src/components/header.tsx
@@ -0,0 +1,209 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useLayout } from "@/context/layout"
+import { Session } from "@opencode-ai/sdk/v2/client"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Mark } from "@opencode-ai/ui/logo"
+import { Popover } from "@opencode-ai/ui/popover"
+import { Select } from "@opencode-ai/ui/select"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { base64Decode } from "@opencode-ai/util/encode"
+import { useCommand } from "@/context/command"
+import { getFilename } from "@opencode-ai/util/path"
+import { A, useParams } from "@solidjs/router"
+import { createMemo, createResource, Show } from "solid-js"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { iife } from "@opencode-ai/util/iife"
+
+export function Header(props: {
+ navigateToProject: (directory: string) => void
+ navigateToSession: (session: Session | undefined) => void
+ onMobileMenuToggle?: () => void
+}) {
+ const globalSync = useGlobalSync()
+ const globalSDK = useGlobalSDK()
+ const layout = useLayout()
+ const params = useParams()
+ const command = useCommand()
+
+ return (
+ <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
+ <button
+ type="button"
+ class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
+ onClick={props.onMobileMenuToggle}
+ >
+ <Icon name="menu" size="small" />
+ </button>
+ <A
+ href="/"
+ classList={{
+ "hidden xl:flex": true,
+ "w-12 shrink-0 px-4 py-3.5": true,
+ "items-center justify-start self-stretch": true,
+ "border-r border-border-weak-base": true,
+ }}
+ style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
+ data-tauri-drag-region
+ >
+ <Mark class="shrink-0" />
+ </A>
+ <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
+ <Show when={layout.projects.list().length > 0 && params.dir}>
+ {(directory) => {
+ const currentDirectory = createMemo(() => base64Decode(directory()))
+ const store = createMemo(() => globalSync.child(currentDirectory())[0])
+ const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
+ const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
+ const shareEnabled = createMemo(() => store().config.share !== "disabled")
+ return (
+ <>
+ <div class="flex items-center gap-3 min-w-0">
+ <div class="flex items-center gap-2 min-w-0">
+ <div class="hidden xl:flex items-center gap-2">
+ <Select
+ options={layout.projects.list().map((project) => project.worktree)}
+ current={currentDirectory()}
+ label={(x) => getFilename(x)}
+ onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
+ class="text-14-regular text-text-base"
+ variant="ghost"
+ >
+ {/* @ts-ignore */}
+ {(i) => (
+ <div class="flex items-center gap-2">
+ <Icon name="folder" size="small" />
+ <div class="text-text-strong">{getFilename(i)}</div>
+ </div>
+ )}
+ </Select>
+ <div class="text-text-weaker">/</div>
+ </div>
+ <Select
+ options={sessions()}
+ current={currentSession()}
+ placeholder="New session"
+ label={(x) => x.title}
+ value={(x) => x.id}
+ onSelect={props.navigateToSession}
+ class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
+ variant="ghost"
+ />
+ </div>
+ <Show when={currentSession()}>
+ <Tooltip
+ class="hidden xl:block"
+ value={
+ <div class="flex items-center gap-2">
+ <span>New session</span>
+ <span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
+ </div>
+ }
+ >
+ <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
+ New session
+ </Button>
+ </Tooltip>
+ </Show>
+ </div>
+ <div class="flex items-center gap-4">
+ <Tooltip
+ class="hidden md:block shrink-0"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Toggle review</span>
+ <span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
+ </div>
+ }
+ >
+ <Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
+ <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+ <Icon
+ size="small"
+ name={layout.review.opened() ? "layout-right-full" : "layout-right"}
+ class="group-hover/review-toggle:hidden"
+ />
+ <Icon
+ size="small"
+ name="layout-right-partial"
+ class="hidden group-hover/review-toggle:inline-block"
+ />
+ <Icon
+ size="small"
+ name={layout.review.opened() ? "layout-right" : "layout-right-full"}
+ class="hidden group-active/review-toggle:inline-block"
+ />
+ </div>
+ </Button>
+ </Tooltip>
+ <Tooltip
+ class="hidden md:block shrink-0"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Toggle terminal</span>
+ <span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
+ </div>
+ }
+ >
+ <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
+ <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+ <Icon
+ size="small"
+ name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
+ class="group-hover/terminal-toggle:hidden"
+ />
+ <Icon
+ size="small"
+ name="layout-bottom-partial"
+ class="hidden group-hover/terminal-toggle:inline-block"
+ />
+ <Icon
+ size="small"
+ name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
+ class="hidden group-active/terminal-toggle:inline-block"
+ />
+ </div>
+ </Button>
+ </Tooltip>
+ <Show when={shareEnabled() && currentSession()}>
+ <Popover
+ title="Share session"
+ trigger={
+ <Tooltip class="shrink-0" value="Share session">
+ <IconButton icon="share" variant="ghost" class="" />
+ </Tooltip>
+ }
+ >
+ {iife(() => {
+ const [url] = createResource(
+ () => currentSession(),
+ async (session) => {
+ if (!session) return
+ let shareURL = session.share?.url
+ if (!shareURL) {
+ shareURL = await globalSDK.client.session
+ .share({ sessionID: session.id, directory: currentDirectory() })
+ .then((r) => r.data?.share?.url)
+ }
+ return shareURL
+ },
+ )
+ return (
+ <Show when={url()}>
+ {(url) => <TextField value={url()} readOnly copyable class="w-72" />}
+ </Show>
+ )
+ })}
+ </Popover>
+ </Show>
+ </div>
+ </>
+ )
+ }}
+ </Show>
+ </div>
+ </header>
+ )
+}
diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx
new file mode 100644
index 000000000..e13c31330
--- /dev/null
+++ b/packages/app/src/components/link.tsx
@@ -0,0 +1,17 @@
+import { ComponentProps, splitProps } from "solid-js"
+import { usePlatform } from "@/context/platform"
+
+export interface LinkProps extends ComponentProps<"button"> {
+ href: string
+}
+
+export function Link(props: LinkProps) {
+ const platform = usePlatform()
+ const [local, rest] = splitProps(props, ["href", "children"])
+
+ return (
+ <button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
+ {local.children}
+ </button>
+ )
+}
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
new file mode 100644
index 000000000..94d4ae97e
--- /dev/null
+++ b/packages/app/src/components/prompt-input.tsx
@@ -0,0 +1,1153 @@
+import { useFilteredList } from "@opencode-ai/ui/hooks"
+import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { createFocusSignal } from "@solid-primitives/active-element"
+import { useLocal } from "@/context/local"
+import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
+import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
+import { useNavigate, useParams } from "@solidjs/router"
+import { useSync } from "@/context/sync"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Select } from "@opencode-ai/ui/select"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DialogSelectModel } from "@/components/dialog-select-model"
+import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
+import { useProviders } from "@/hooks/use-providers"
+import { useCommand } from "@/context/command"
+import { persisted } from "@/utils/persist"
+import { Identifier } from "@/utils/id"
+import { SessionContextUsage } from "@/components/session-context-usage"
+
+const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
+
+interface PromptInputProps {
+ class?: string
+ ref?: (el: HTMLDivElement) => void
+}
+
+const PLACEHOLDERS = [
+ "Fix a TODO in the codebase",
+ "What is the tech stack of this project?",
+ "Fix broken tests",
+ "Explain how authentication works",
+ "Find and fix security vulnerabilities",
+ "Add unit tests for the user service",
+ "Refactor this function to be more readable",
+ "What does this error mean?",
+ "Help me debug this issue",
+ "Generate API documentation",
+ "Optimize database queries",
+ "Add input validation",
+ "Create a new component for...",
+ "How do I deploy this project?",
+ "Review my code for best practices",
+ "Add error handling to this function",
+ "Explain this regex pattern",
+ "Convert this to TypeScript",
+ "Add logging throughout the codebase",
+ "What dependencies are outdated?",
+ "Help me write a migration script",
+ "Implement caching for this endpoint",
+ "Add pagination to this list",
+ "Create a CLI command for...",
+ "How do environment variables work here?",
+]
+
+interface SlashCommand {
+ id: string
+ trigger: string
+ title: string
+ description?: string
+ keybind?: string
+ type: "builtin" | "custom"
+}
+
+export const PromptInput: Component<PromptInputProps> = (props) => {
+ const navigate = useNavigate()
+ const sdk = useSDK()
+ const sync = useSync()
+ const local = useLocal()
+ const prompt = usePrompt()
+ const layout = useLayout()
+ const params = useParams()
+ const dialog = useDialog()
+ const providers = useProviders()
+ const command = useCommand()
+ let editorRef!: HTMLDivElement
+ let fileInputRef!: HTMLInputElement
+
+ const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+ const tabs = createMemo(() => layout.tabs(sessionKey()))
+ const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+ const status = createMemo(
+ () =>
+ sync.data.session_status[params.id ?? ""] ?? {
+ type: "idle",
+ },
+ )
+ const working = createMemo(() => status()?.type !== "idle")
+
+ const [store, setStore] = createStore<{
+ popover: "file" | "slash" | null
+ historyIndex: number
+ savedPrompt: Prompt | null
+ placeholder: number
+ dragging: boolean
+ imageAttachments: ImageAttachmentPart[]
+ mode: "normal" | "shell"
+ applyingHistory: boolean
+ userHasEdited: boolean
+ }>({
+ popover: null,
+ historyIndex: -1,
+ savedPrompt: null,
+ placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
+ dragging: false,
+ imageAttachments: [],
+ mode: "normal",
+ applyingHistory: false,
+ userHasEdited: false,
+ })
+
+ const MAX_HISTORY = 100
+ const [history, setHistory] = persisted(
+ "prompt-history.v1",
+ createStore<{
+ entries: Prompt[]
+ }>({
+ entries: [],
+ }),
+ )
+ const [shellHistory, setShellHistory] = persisted(
+ "prompt-history-shell.v1",
+ createStore<{
+ entries: Prompt[]
+ }>({
+ entries: [],
+ }),
+ )
+
+ const clonePromptParts = (prompt: Prompt): Prompt =>
+ prompt.map((part) => {
+ if (part.type === "text") return { ...part }
+ if (part.type === "image") return { ...part }
+ return {
+ ...part,
+ selection: part.selection ? { ...part.selection } : undefined,
+ }
+ })
+
+ const promptLength = (prompt: Prompt) =>
+ prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
+
+ const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
+ const length = position === "start" ? 0 : promptLength(p)
+ setStore("applyingHistory", true)
+ setStore("userHasEdited", false)
+ prompt.set(p, length)
+ requestAnimationFrame(() => {
+ editorRef.focus()
+ setCursorPosition(editorRef, length)
+ setStore("applyingHistory", false)
+ })
+ }
+
+ const getCaretState = () => {
+ const selection = window.getSelection()
+ const textLength = promptLength(prompt.current())
+ if (!selection || selection.rangeCount === 0) {
+ return { collapsed: false, cursorPosition: 0, textLength }
+ }
+ const anchorNode = selection.anchorNode
+ if (!anchorNode || !editorRef.contains(anchorNode)) {
+ return { collapsed: false, cursorPosition: 0, textLength }
+ }
+ return {
+ collapsed: selection.isCollapsed,
+ cursorPosition: getCursorPosition(editorRef),
+ textLength,
+ }
+ }
+
+ createEffect(() => {
+ params.id
+ editorRef.focus()
+ if (params.id) return
+ const interval = setInterval(() => {
+ setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
+ }, 6500)
+ onCleanup(() => clearInterval(interval))
+ })
+
+ const isFocused = createFocusSignal(() => editorRef)
+
+ const addImageAttachment = async (file: File) => {
+ if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
+
+ const reader = new FileReader()
+ reader.onload = () => {
+ const dataUrl = reader.result as string
+ const attachment: ImageAttachmentPart = {
+ type: "image",
+ id: crypto.randomUUID(),
+ filename: file.name,
+ mime: file.type,
+ dataUrl,
+ }
+ setStore(
+ produce((draft) => {
+ draft.imageAttachments.push(attachment)
+ }),
+ )
+ }
+ reader.readAsDataURL(file)
+ }
+
+ const removeImageAttachment = (id: string) => {
+ setStore(
+ produce((draft) => {
+ draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
+ }),
+ )
+ }
+
+ const handlePaste = async (event: ClipboardEvent) => {
+ const clipboardData = event.clipboardData
+ if (!clipboardData) return
+
+ const items = Array.from(clipboardData.items)
+ const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
+
+ if (imageItems.length > 0) {
+ event.preventDefault()
+ event.stopPropagation()
+ for (const item of imageItems) {
+ const file = item.getAsFile()
+ if (file) await addImageAttachment(file)
+ }
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+ const plainText = clipboardData.getData("text/plain") ?? ""
+ addPart({ type: "text", content: plainText, start: 0, end: 0 })
+ }
+
+ const handleDragOver = (event: DragEvent) => {
+ event.preventDefault()
+ const hasFiles = event.dataTransfer?.types.includes("Files")
+ if (hasFiles) {
+ setStore("dragging", true)
+ }
+ }
+
+ const handleDragLeave = (event: DragEvent) => {
+ const related = event.relatedTarget as Node | null
+ const form = event.currentTarget as HTMLElement
+ if (!related || !form.contains(related)) {
+ setStore("dragging", false)
+ }
+ }
+
+ const handleDrop = async (event: DragEvent) => {
+ event.preventDefault()
+ setStore("dragging", false)
+
+ const files = event.dataTransfer?.files
+ if (!files) return
+
+ for (const file of Array.from(files)) {
+ if (ACCEPTED_FILE_TYPES.includes(file.type)) {
+ await addImageAttachment(file)
+ }
+ }
+ }
+
+ onMount(() => {
+ editorRef.addEventListener("paste", handlePaste)
+ })
+ onCleanup(() => {
+ editorRef.removeEventListener("paste", handlePaste)
+ })
+
+ createEffect(() => {
+ if (isFocused()) {
+ handleInput()
+ } else {
+ setStore("popover", null)
+ }
+ })
+
+ const handleFileSelect = (path: string | undefined) => {
+ if (!path) return
+ addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
+ }
+
+ const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
+ items: local.file.searchFilesAndDirectories,
+ key: (x) => x,
+ onSelect: handleFileSelect,
+ })
+
+ const slashCommands = createMemo<SlashCommand[]>(() => {
+ const builtin = command.options
+ .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
+ .map((opt) => ({
+ id: opt.id,
+ trigger: opt.slash!,
+ title: opt.title,
+ description: opt.description,
+ keybind: opt.keybind,
+ type: "builtin" as const,
+ }))
+
+ const custom = sync.data.command.map((cmd) => ({
+ id: `custom.${cmd.name}`,
+ trigger: cmd.name,
+ title: cmd.name,
+ description: cmd.description,
+ type: "custom" as const,
+ }))
+
+ return [...custom, ...builtin]
+ })
+
+ const handleSlashSelect = (cmd: SlashCommand | undefined) => {
+ if (!cmd) return
+ setStore("popover", null)
+
+ if (cmd.type === "custom") {
+ const text = `/${cmd.trigger} `
+ editorRef.innerHTML = ""
+ editorRef.textContent = text
+ prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
+ requestAnimationFrame(() => {
+ editorRef.focus()
+ const range = document.createRange()
+ const sel = window.getSelection()
+ range.selectNodeContents(editorRef)
+ range.collapse(false)
+ sel?.removeAllRanges()
+ sel?.addRange(range)
+ })
+ return
+ }
+
+ editorRef.innerHTML = ""
+ prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+ command.trigger(cmd.id, "slash")
+ }
+
+ const {
+ flat: slashFlat,
+ active: slashActive,
+ onInput: slashOnInput,
+ onKeyDown: slashOnKeyDown,
+ } = useFilteredList<SlashCommand>({
+ items: slashCommands,
+ key: (x) => x?.id,
+ filterKeys: ["trigger", "title", "description"],
+ onSelect: handleSlashSelect,
+ })
+
+ createEffect(
+ on(
+ () => prompt.current(),
+ (currentParts) => {
+ const domParts = parseFromDOM()
+ if (isPromptEqual(currentParts, domParts)) return
+
+ const selection = window.getSelection()
+ let cursorPosition: number | null = null
+ if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
+ cursorPosition = getCursorPosition(editorRef)
+ }
+
+ editorRef.innerHTML = ""
+ currentParts.forEach((part) => {
+ if (part.type === "text") {
+ editorRef.appendChild(document.createTextNode(part.content))
+ } else if (part.type === "file") {
+ const pill = document.createElement("span")
+ pill.textContent = part.content
+ pill.setAttribute("data-type", "file")
+ pill.setAttribute("data-path", part.path)
+ pill.setAttribute("contenteditable", "false")
+ pill.style.userSelect = "text"
+ pill.style.cursor = "default"
+ editorRef.appendChild(pill)
+ }
+ })
+
+ if (cursorPosition !== null) {
+ setCursorPosition(editorRef, cursorPosition)
+ }
+ },
+ ),
+ )
+
+ const parseFromDOM = (): Prompt => {
+ const newParts: Prompt = []
+ let position = 0
+
+ const pushText = (content: string) => {
+ if (!content) return
+ newParts.push({ type: "text", content, start: position, end: position + content.length })
+ position += content.length
+ }
+
+ const rangeText = (range: Range) => {
+ const fragment = range.cloneContents()
+ const container = document.createElement("div")
+ container.append(fragment)
+ return container.innerText
+ }
+
+ const files = Array.from(editorRef.querySelectorAll<HTMLElement>("[data-type=file]"))
+ let last: HTMLElement | undefined
+
+ files.forEach((file) => {
+ const before = document.createRange()
+ before.selectNodeContents(editorRef)
+ if (last) before.setStartAfter(last)
+ before.setEndBefore(file)
+ pushText(rangeText(before))
+
+ const content = file.textContent ?? ""
+ newParts.push({
+ type: "file",
+ path: file.dataset.path!,
+ content,
+ start: position,
+ end: position + content.length,
+ })
+ position += content.length
+ last = file
+ })
+
+ const after = document.createRange()
+ after.selectNodeContents(editorRef)
+ if (last) after.setStartAfter(last)
+ pushText(rangeText(after))
+
+ if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
+ return newParts
+ }
+
+ const handleInput = () => {
+ const rawParts = parseFromDOM()
+ const cursorPosition = getCursorPosition(editorRef)
+ const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
+ const trimmed = rawText.replace(/\u200B/g, "").trim()
+ const hasNonText = rawParts.some((part) => part.type !== "text")
+ const shouldReset = trimmed.length === 0 && !hasNonText
+
+ if (shouldReset) {
+ setStore("popover", null)
+ setStore("userHasEdited", false)
+ if (store.historyIndex >= 0 && !store.applyingHistory) {
+ setStore("historyIndex", -1)
+ setStore("savedPrompt", null)
+ }
+ if (prompt.dirty()) {
+ prompt.set(DEFAULT_PROMPT, 0)
+ }
+ return
+ }
+
+ const shellMode = store.mode === "shell"
+
+ if (!shellMode) {
+ const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
+ const slashMatch = rawText.match(/^\/(\S*)$/)
+
+ if (atMatch) {
+ onInput(atMatch[1])
+ setStore("popover", "file")
+ } else if (slashMatch) {
+ slashOnInput(slashMatch[1])
+ setStore("popover", "slash")
+ } else {
+ setStore("popover", null)
+ }
+ } else {
+ setStore("popover", null)
+ }
+
+ if (store.historyIndex >= 0 && !store.applyingHistory) {
+ setStore("historyIndex", -1)
+ setStore("savedPrompt", null)
+ }
+
+ if (!store.applyingHistory) {
+ setStore("userHasEdited", true)
+ }
+
+ prompt.set(rawParts, cursorPosition)
+ }
+
+ const addPart = (part: ContentPart) => {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return
+
+ const cursorPosition = getCursorPosition(editorRef)
+ const currentPrompt = prompt.current()
+ const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
+ const textBeforeCursor = rawText.substring(0, cursorPosition)
+ const atMatch = textBeforeCursor.match(/@(\S*)$/)
+
+ if (part.type === "file") {
+ const pill = document.createElement("span")
+ pill.textContent = part.content
+ pill.setAttribute("data-type", "file")
+ pill.setAttribute("data-path", part.path)
+ pill.setAttribute("contenteditable", "false")
+ pill.style.userSelect = "text"
+ pill.style.cursor = "default"
+
+ const gap = document.createTextNode(" ")
+ const range = selection.getRangeAt(0)
+
+ if (atMatch) {
+ let runningLength = 0
+
+ const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
+ let currentNode = walker.nextNode()
+ while (currentNode) {
+ const textContent = currentNode.textContent || ""
+ if (runningLength + textContent.length >= atMatch.index!) {
+ const localStart = atMatch.index! - runningLength
+ const localEnd = cursorPosition - runningLength
+ if (currentNode === range.startContainer || runningLength + textContent.length >= cursorPosition) {
+ range.setStart(currentNode, localStart)
+ range.setEnd(currentNode, Math.min(localEnd, textContent.length))
+ break
+ }
+ }
+ runningLength += textContent.length
+ currentNode = walker.nextNode()
+ }
+ }
+
+ range.deleteContents()
+ range.insertNode(gap)
+ range.insertNode(pill)
+ range.setStartAfter(gap)
+ range.collapse(true)
+ selection.removeAllRanges()
+ selection.addRange(range)
+ } else if (part.type === "text") {
+ const textNode = document.createTextNode(part.content)
+ const range = selection.getRangeAt(0)
+ range.deleteContents()
+ range.insertNode(textNode)
+ range.setStartAfter(textNode)
+ range.collapse(true)
+ selection.removeAllRanges()
+ selection.addRange(range)
+ }
+
+ handleInput()
+ setStore("popover", null)
+ }
+
+ const abort = () =>
+ sdk.client.session.abort({
+ sessionID: params.id!,
+ })
+
+ const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
+ const text = prompt
+ .map((p) => ("content" in p ? p.content : ""))
+ .join("")
+ .trim()
+ if (!text) return
+
+ const entry = clonePromptParts(prompt)
+ const currentHistory = mode === "shell" ? shellHistory : history
+ const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
+ const lastEntry = currentHistory.entries[0]
+ if (lastEntry) {
+ const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
+ if (lastText === text) return
+ }
+
+ setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
+ }
+
+ const navigateHistory = (direction: "up" | "down") => {
+ if (store.userHasEdited) return false
+
+ const entries = store.mode === "shell" ? shellHistory.entries : history.entries
+ const current = store.historyIndex
+
+ if (direction === "up") {
+ if (entries.length === 0) return false
+ if (current === -1) {
+ setStore("savedPrompt", clonePromptParts(prompt.current()))
+ setStore("historyIndex", 0)
+ applyHistoryPrompt(entries[0], "start")
+ return true
+ }
+ if (current < entries.length - 1) {
+ const next = current + 1
+ setStore("historyIndex", next)
+ applyHistoryPrompt(entries[next], "start")
+ return true
+ }
+ return false
+ }
+
+ if (current > 0) {
+ const next = current - 1
+ setStore("historyIndex", next)
+ applyHistoryPrompt(entries[next], "end")
+ return true
+ }
+ if (current === 0) {
+ setStore("historyIndex", -1)
+ const saved = store.savedPrompt
+ if (saved) {
+ applyHistoryPrompt(saved, "end")
+ setStore("savedPrompt", null)
+ return true
+ }
+ applyHistoryPrompt(DEFAULT_PROMPT, "end")
+ return true
+ }
+
+ return false
+ }
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "!" && store.mode === "normal") {
+ const cursorPosition = getCursorPosition(editorRef)
+ if (cursorPosition === 0) {
+ setStore("mode", "shell")
+ setStore("popover", null)
+ event.preventDefault()
+ return
+ }
+ }
+ if (store.mode === "shell") {
+ const { collapsed, cursorPosition, textLength } = getCaretState()
+ if (event.key === "Escape") {
+ setStore("mode", "normal")
+ event.preventDefault()
+ return
+ }
+ if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
+ setStore("mode", "normal")
+ event.preventDefault()
+ return
+ }
+ }
+
+ if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
+ if (store.popover === "file") {
+ onKeyDown(event)
+ } else {
+ slashOnKeyDown(event)
+ }
+ event.preventDefault()
+ return
+ }
+
+ if (event.key === "ArrowUp" || event.key === "ArrowDown") {
+ if (event.altKey || event.ctrlKey || event.metaKey) return
+ const { collapsed } = getCaretState()
+ if (!collapsed) return
+
+ const cursorPosition = getCursorPosition(editorRef)
+ const textLength = promptLength(prompt.current())
+ const textContent = editorRef.textContent ?? ""
+ const isEmpty = textContent.trim() === "" || textLength <= 1
+ const hasNewlines = textContent.includes("\n")
+ const inHistory = store.historyIndex >= 0
+ const atStart = cursorPosition <= (isEmpty ? 1 : 0)
+ const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
+ const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
+ const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
+
+ if (event.key === "ArrowUp") {
+ if (!allowUp) return
+ if (navigateHistory("up")) {
+ event.preventDefault()
+ }
+ return
+ }
+
+ if (!allowDown) return
+ if (navigateHistory("down")) {
+ event.preventDefault()
+ }
+ return
+ }
+
+ if (event.key === "Enter" && !event.shiftKey) {
+ handleSubmit(event)
+ }
+ if (event.key === "Escape") {
+ if (store.popover) {
+ setStore("popover", null)
+ } else if (working()) {
+ abort()
+ }
+ }
+ }
+
+ const handleSubmit = async (event: Event) => {
+ event.preventDefault()
+ const currentPrompt = prompt.current()
+ const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
+ const hasImageAttachments = store.imageAttachments.length > 0
+ if (text.trim().length === 0 && !hasImageAttachments) {
+ if (working()) abort()
+ return
+ }
+
+ addToHistory(currentPrompt, store.mode)
+ setStore("historyIndex", -1)
+ setStore("savedPrompt", null)
+ setStore("userHasEdited", false)
+
+ let existing = info()
+ if (!existing) {
+ const created = await sdk.client.session.create()
+ existing = created.data ?? undefined
+ if (existing) navigate(existing.id)
+ }
+ if (!existing) return
+
+ const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
+ const attachments = currentPrompt.filter(
+ (part) => part.type === "file",
+ ) as import("@/context/prompt").FileAttachmentPart[]
+
+ const fileAttachmentParts = attachments.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 imageAttachmentParts = store.imageAttachments.map((attachment) => ({
+ id: Identifier.ascending("part"),
+ type: "file" as const,
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ }))
+
+ const isShellMode = store.mode === "shell"
+ tabs().setActive(undefined)
+ editorRef.innerHTML = ""
+ prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+ setStore("imageAttachments", [])
+ setStore("mode", "normal")
+
+ const model = {
+ modelID: local.model.current()!.id,
+ providerID: local.model.current()!.provider.id,
+ }
+ const agent = local.agent.current()!.name
+
+ if (isShellMode) {
+ sdk.client.session.shell({
+ sessionID: existing.id,
+ agent,
+ model,
+ command: text,
+ })
+ return
+ }
+
+ if (text.startsWith("/")) {
+ const [cmdName, ...args] = text.split(" ")
+ const commandName = cmdName.slice(1)
+ const customCommand = sync.data.command.find((c) => c.name === commandName)
+ if (customCommand) {
+ sdk.client.session.command({
+ sessionID: existing.id,
+ command: commandName,
+ arguments: args.join(" "),
+ agent,
+ model: `${model.providerID}/${model.modelID}`,
+ })
+ return
+ }
+ }
+
+ const messageID = Identifier.ascending("message")
+ const textPart = {
+ id: Identifier.ascending("part"),
+ type: "text" as const,
+ text,
+ }
+ const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
+ const optimisticParts = requestParts.map((part) => ({
+ ...part,
+ sessionID: existing.id,
+ messageID,
+ }))
+
+ sync.session.addOptimisticMessage({
+ sessionID: existing.id,
+ messageID,
+ parts: optimisticParts,
+ agent,
+ model,
+ })
+
+ sdk.client.session.prompt({
+ sessionID: existing.id,
+ agent,
+ model,
+ messageID,
+ parts: requestParts,
+ })
+ }
+
+ return (
+ <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
+ <Show when={store.popover}>
+ <div
+ 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"
+ >
+ <Switch>
+ <Match when={store.popover === "file"}>
+ <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
+ <For each={flat()}>
+ {(i) => (
+ <button
+ classList={{
+ "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
+ "bg-surface-raised-base-hover": active() === i,
+ }}
+ onClick={() => handleFileSelect(i)}
+ >
+ <FileIcon node={{ path: i, 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">{getDirectory(i)}</span>
+ <Show when={!i.endsWith("/")}>
+ <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
+ </Show>
+ </div>
+ </button>
+ )}
+ </For>
+ </Show>
+ </Match>
+ <Match when={store.popover === "slash"}>
+ <Show
+ when={slashFlat().length > 0}
+ fallback={<div class="text-text-weak px-2 py-1">No matching commands</div>}
+ >
+ <For each={slashFlat()}>
+ {(cmd) => (
+ <button
+ 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)}
+ >
+ <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"}>
+ <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
+ 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>
+ <form
+ onSubmit={handleSubmit}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ classList={{
+ "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
+ "rounded-md overflow-clip focus-within:shadow-xs-border": true,
+ "border-icon-info-active border-dashed": store.dragging,
+ [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">Drop images or PDFs here</span>
+ </div>
+ </div>
+ </Show>
+ <Show when={store.imageAttachments.length > 0}>
+ <div class="flex flex-wrap gap-2 px-3 pt-3">
+ <For each={store.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"
+ />
+ </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"
+ >
+ <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>
+ <div class="relative max-h-[240px] overflow-y-auto">
+ <div
+ data-component="prompt-input"
+ ref={(el) => {
+ editorRef = el
+ props.ref?.(el)
+ }}
+ contenteditable="true"
+ onInput={handleInput}
+ onKeyDown={handleKeyDown}
+ classList={{
+ "w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
+ "[&>[data-type=file]]:text-icon-info-active": true,
+ "font-mono!": store.mode === "shell",
+ }}
+ />
+ <Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
+ <div class="absolute top-0 inset-x-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
+ {store.mode === "shell"
+ ? "Enter shell command..."
+ : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
+ </div>
+ </Show>
+ </div>
+ <div class="relative p-3 flex items-center justify-between">
+ <div class="flex items-center justify-start gap-1">
+ <Switch>
+ <Match when={store.mode === "shell"}>
+ <div class="flex items-center gap-2 px-2 h-6">
+ <Icon name="console" size="small" class="text-icon-primary" />
+ <span class="text-12-regular text-text-primary">Shell</span>
+ <span class="text-12-regular text-text-weak">esc to exit</span>
+ </div>
+ </Match>
+ <Match when={store.mode === "normal"}>
+ <Tooltip
+ placement="top"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Cycle agent</span>
+ <span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
+ </div>
+ }
+ >
+ <Select
+ options={local.agent.list().map((agent) => agent.name)}
+ current={local.agent.current().name}
+ onSelect={local.agent.set}
+ class="capitalize"
+ variant="ghost"
+ />
+ </Tooltip>
+ <Tooltip
+ placement="top"
+ value={
+ <div class="flex items-center gap-2">
+ <span>Choose model</span>
+ <span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
+ </div>
+ }
+ >
+ <Button
+ as="div"
+ variant="ghost"
+ onClick={() =>
+ dialog.show(() =>
+ providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
+ )
+ }
+ >
+ {local.model.current()?.name ?? "Select model"}
+ <span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
+ {local.model.current()?.provider.name}
+ </span>
+ <Icon name="chevron-down" size="small" />
+ </Button>
+ </Tooltip>
+ </Match>
+ </Switch>
+ <SessionContextUsage />
+ </div>
+ <div class="flex items-center gap-1 absolute right-2 bottom-2">
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept={ACCEPTED_IMAGE_TYPES.join(",")}
+ class="hidden"
+ onChange={(e) => {
+ const file = e.currentTarget.files?.[0]
+ if (file) addImageAttachment(file)
+ e.currentTarget.value = ""
+ }}
+ />
+ <Show when={store.mode === "normal"}>
+ <Tooltip placement="top" value="Attach image">
+ <IconButton
+ type="button"
+ icon="photo"
+ variant="ghost"
+ class="h-10 w-8"
+ onClick={() => fileInputRef.click()}
+ />
+ </Tooltip>
+ </Show>
+ <Tooltip
+ placement="top"
+ inactive={!prompt.dirty() && !working()}
+ value={
+ <Switch>
+ <Match when={working()}>
+ <div class="flex items-center gap-2">
+ <span>Stop</span>
+ <span class="text-icon-base text-12-medium text-[10px]!">ESC</span>
+ </div>
+ </Match>
+ <Match when={true}>
+ <div class="flex items-center gap-2">
+ <span>Send</span>
+ <Icon name="enter" size="small" class="text-icon-base" />
+ </div>
+ </Match>
+ </Switch>
+ }
+ >
+ <IconButton
+ type="submit"
+ disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
+ icon={working() ? "stop" : "arrow-up"}
+ variant="primary"
+ class="h-10 w-8"
+ />
+ </Tooltip>
+ </div>
+ </div>
+ </form>
+ </div>
+ )
+}
+
+function getCursorPosition(parent: HTMLElement): number {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return 0
+ const range = selection.getRangeAt(0)
+ const preCaretRange = range.cloneRange()
+ preCaretRange.selectNodeContents(parent)
+ preCaretRange.setEnd(range.startContainer, range.startOffset)
+ return preCaretRange.toString().length
+}
+
+function setCursorPosition(parent: HTMLElement, position: number) {
+ let remaining = position
+ let node = parent.firstChild
+ while (node) {
+ const length = node.textContent ? node.textContent.length : 0
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+
+ if (isText && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStart(node, remaining)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ if (isFile && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStartAfter(node)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ remaining -= length
+ node = node.nextSibling
+ }
+
+ const fallbackRange = document.createRange()
+ const fallbackSelection = window.getSelection()
+ const last = parent.lastChild
+ if (last && last.nodeType === Node.TEXT_NODE) {
+ const len = last.textContent ? last.textContent.length : 0
+ fallbackRange.setStart(last, len)
+ }
+ if (!last || last.nodeType !== Node.TEXT_NODE) {
+ fallbackRange.selectNodeContents(parent)
+ }
+ fallbackRange.collapse(false)
+ fallbackSelection?.removeAllRanges()
+ fallbackSelection?.addRange(fallbackRange)
+}
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
new file mode 100644
index 000000000..5474005c7
--- /dev/null
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -0,0 +1,64 @@
+import { createMemo, Show } from "solid-js"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
+import { useSync } from "@/context/sync"
+import { useParams } from "@solidjs/router"
+import { AssistantMessage } from "@opencode-ai/sdk/v2"
+
+export function SessionContextUsage() {
+ const sync = useSync()
+ const params = useParams()
+ const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+
+ const cost = createMemo(() => {
+ const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(total)
+ })
+
+ const context = createMemo(() => {
+ const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
+ if (!last) return
+ const total =
+ last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
+ const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
+ return {
+ tokens: total.toLocaleString(),
+ percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
+ }
+ })
+
+ return (
+ <Show when={context?.()}>
+ {(ctx) => (
+ <Tooltip
+ openDelay={300}
+ value={
+ <div class="flex flex-col gap-1 p-2">
+ <div class="flex justify-between gap-4">
+ <span class="text-text-weaker">Tokens</span>
+ <span class="text-text-strong">{ctx().tokens}</span>
+ </div>
+ <div class="flex justify-between gap-4">
+ <span class="text-text-weaker">Usage</span>
+ <span class="text-text-strong">{ctx().percentage ?? 0}%</span>
+ </div>
+ <div class="flex justify-between gap-4">
+ <span class="text-text-weaker">Cost</span>
+ <span class="text-text-strong">{cost()}</span>
+ </div>
+ </div>
+ }
+ placement="top"
+ >
+ <div class="flex items-center gap-1">
+ <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span>
+ <ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
+ </div>
+ </Tooltip>
+ )}
+ </Show>
+ )
+}
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
new file mode 100644
index 000000000..c05ddfbf6
--- /dev/null
+++ b/packages/app/src/components/terminal.tsx
@@ -0,0 +1,160 @@
+import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
+import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
+import { useSDK } from "@/context/sdk"
+import { SerializeAddon } from "@/addons/serialize"
+import { LocalPTY } from "@/context/terminal"
+import { usePrefersDark } from "@solid-primitives/media"
+
+export interface TerminalProps extends ComponentProps<"div"> {
+ pty: LocalPTY
+ onSubmit?: () => void
+ onCleanup?: (pty: LocalPTY) => void
+ onConnectError?: (error: unknown) => void
+}
+
+export const Terminal = (props: TerminalProps) => {
+ const sdk = useSDK()
+ let container!: HTMLDivElement
+ const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
+ let ws: WebSocket
+ let term: Term
+ let ghostty: Ghostty
+ let serializeAddon: SerializeAddon
+ let fitAddon: FitAddon
+ let handleResize: () => void
+ const prefersDark = usePrefersDark()
+
+ onMount(async () => {
+ ghostty = await Ghostty.load()
+
+ ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
+ term = new Term({
+ cursorBlink: true,
+ fontSize: 14,
+ fontFamily: "IBM Plex Mono, monospace",
+ allowTransparency: true,
+ theme: prefersDark()
+ ? {
+ background: "#191515",
+ foreground: "#d4d4d4",
+ cursor: "#d4d4d4",
+ }
+ : {
+ background: "#fcfcfc",
+ foreground: "#211e1e",
+ cursor: "#211e1e",
+ },
+ scrollback: 10_000,
+ ghostty,
+ })
+ term.attachCustomKeyEventHandler((event) => {
+ // allow for ctrl-` to toggle terminal in parent
+ if (event.ctrlKey && event.key.toLowerCase() === "`") {
+ event.preventDefault()
+ return true
+ }
+ return false
+ })
+
+ fitAddon = new FitAddon()
+ serializeAddon = new SerializeAddon()
+ term.loadAddon(serializeAddon)
+ term.loadAddon(fitAddon)
+
+ term.open(container)
+
+ if (local.pty.buffer) {
+ if (local.pty.rows && local.pty.cols) {
+ term.resize(local.pty.cols, local.pty.rows)
+ }
+ term.reset()
+ term.write(local.pty.buffer)
+ if (local.pty.scrollY) {
+ term.scrollToLine(local.pty.scrollY)
+ }
+ fitAddon.fit()
+ }
+
+ container.focus()
+
+ fitAddon.observeResize()
+ handleResize = () => fitAddon.fit()
+ window.addEventListener("resize", handleResize)
+ term.onResize(async (size) => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ await sdk.client.pty.update({
+ ptyID: local.pty.id,
+ size: {
+ cols: size.cols,
+ rows: size.rows,
+ },
+ })
+ }
+ })
+ term.onData((data) => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(data)
+ }
+ })
+ term.onKey((key) => {
+ if (key.key == "Enter") {
+ props.onSubmit?.()
+ }
+ })
+ // term.onScroll((ydisp) => {
+ // console.log("Scroll position:", ydisp)
+ // })
+ ws.addEventListener("open", () => {
+ console.log("WebSocket connected")
+ sdk.client.pty.update({
+ ptyID: local.pty.id,
+ size: {
+ cols: term.cols,
+ rows: term.rows,
+ },
+ })
+ })
+ ws.addEventListener("message", (event) => {
+ term.write(event.data)
+ })
+ ws.addEventListener("error", (error) => {
+ console.error("WebSocket error:", error)
+ props.onConnectError?.(error)
+ })
+ ws.addEventListener("close", () => {
+ console.log("WebSocket disconnected")
+ })
+ })
+
+ onCleanup(() => {
+ if (handleResize) {
+ window.removeEventListener("resize", handleResize)
+ }
+ if (serializeAddon && props.onCleanup) {
+ const buffer = serializeAddon.serialize()
+ props.onCleanup({
+ ...local.pty,
+ buffer,
+ rows: term.rows,
+ cols: term.cols,
+ scrollY: term.getViewportY(),
+ })
+ }
+ ws?.close()
+ term?.dispose()
+ })
+
+ return (
+ <div
+ ref={container}
+ data-component="terminal"
+ data-prevent-autofocus
+ classList={{
+ ...(local.classList ?? {}),
+ "size-full px-6 py-3 font-mono": true,
+ [local.class ?? ""]: !!local.class,
+ }}
+ {...others}
+ />
+ )
+}