summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-22 19:38:50 -0600
committerAdam <[email protected]>2025-12-22 19:39:00 -0600
commit794fe8f381c846f5241800363023d892c12cf495 (patch)
treebff98689edfa635a2a9f39cb4ea61639b97f5b2d /packages/desktop/src/components
parenta4eebf9f08262f6bf63017710e2e6d9672ec6708 (diff)
downloadopencode-794fe8f381c846f5241800363023d892c12cf495.tar.gz
opencode-794fe8f381c846f5241800363023d892c12cf495.zip
chore: rename packages/desktop -> packages/app
Diffstat (limited to 'packages/desktop/src/components')
-rw-r--r--packages/desktop/src/components/dialog-connect-provider.tsx383
-rw-r--r--packages/desktop/src/components/dialog-manage-models.tsx57
-rw-r--r--packages/desktop/src/components/dialog-select-file.tsx48
-rw-r--r--packages/desktop/src/components/dialog-select-model-unpaid.tsx110
-rw-r--r--packages/desktop/src/components/dialog-select-model.tsx83
-rw-r--r--packages/desktop/src/components/dialog-select-provider.tsx54
-rw-r--r--packages/desktop/src/components/file-tree.tsx112
-rw-r--r--packages/desktop/src/components/header.tsx209
-rw-r--r--packages/desktop/src/components/link.tsx17
-rw-r--r--packages/desktop/src/components/prompt-input.tsx1153
-rw-r--r--packages/desktop/src/components/session-context-usage.tsx64
-rw-r--r--packages/desktop/src/components/terminal.tsx160
12 files changed, 0 insertions, 2450 deletions
diff --git a/packages/desktop/src/components/dialog-connect-provider.tsx b/packages/desktop/src/components/dialog-connect-provider.tsx
deleted file mode 100644
index 789a5d3b7..000000000
--- a/packages/desktop/src/components/dialog-connect-provider.tsx
+++ /dev/null
@@ -1,383 +0,0 @@
-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/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx
deleted file mode 100644
index 66d125288..000000000
--- a/packages/desktop/src/components/dialog-manage-models.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-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/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx
deleted file mode 100644
index b27afdc8b..000000000
--- a/packages/desktop/src/components/dialog-select-file.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-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/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx
deleted file mode 100644
index 24ec8092d..000000000
--- a/packages/desktop/src/components/dialog-select-model-unpaid.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-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/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx
deleted file mode 100644
index 54783386a..000000000
--- a/packages/desktop/src/components/dialog-select-model.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-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/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx
deleted file mode 100644
index 5bbde5d41..000000000
--- a/packages/desktop/src/components/dialog-select-provider.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-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/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx
deleted file mode 100644
index 0841c71d1..000000000
--- a/packages/desktop/src/components/file-tree.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-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/desktop/src/components/header.tsx b/packages/desktop/src/components/header.tsx
deleted file mode 100644
index ec7cdfa25..000000000
--- a/packages/desktop/src/components/header.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-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/desktop/src/components/link.tsx b/packages/desktop/src/components/link.tsx
deleted file mode 100644
index e13c31330..000000000
--- a/packages/desktop/src/components/link.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-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/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
deleted file mode 100644
index 94d4ae97e..000000000
--- a/packages/desktop/src/components/prompt-input.tsx
+++ /dev/null
@@ -1,1153 +0,0 @@
-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/desktop/src/components/session-context-usage.tsx b/packages/desktop/src/components/session-context-usage.tsx
deleted file mode 100644
index 5474005c7..000000000
--- a/packages/desktop/src/components/session-context-usage.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-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/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx
deleted file mode 100644
index c05ddfbf6..000000000
--- a/packages/desktop/src/components/terminal.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-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}
- />
- )
-}