diff options
| author | Adam <[email protected]> | 2025-12-22 19:38:50 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-22 19:39:00 -0600 |
| commit | 794fe8f381c846f5241800363023d892c12cf495 (patch) | |
| tree | bff98689edfa635a2a9f39cb4ea61639b97f5b2d /packages/desktop/src/components | |
| parent | a4eebf9f08262f6bf63017710e2e6d9672ec6708 (diff) | |
| download | opencode-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.tsx | 383 | ||||
| -rw-r--r-- | packages/desktop/src/components/dialog-manage-models.tsx | 57 | ||||
| -rw-r--r-- | packages/desktop/src/components/dialog-select-file.tsx | 48 | ||||
| -rw-r--r-- | packages/desktop/src/components/dialog-select-model-unpaid.tsx | 110 | ||||
| -rw-r--r-- | packages/desktop/src/components/dialog-select-model.tsx | 83 | ||||
| -rw-r--r-- | packages/desktop/src/components/dialog-select-provider.tsx | 54 | ||||
| -rw-r--r-- | packages/desktop/src/components/file-tree.tsx | 112 | ||||
| -rw-r--r-- | packages/desktop/src/components/header.tsx | 209 | ||||
| -rw-r--r-- | packages/desktop/src/components/link.tsx | 17 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 1153 | ||||
| -rw-r--r-- | packages/desktop/src/components/session-context-usage.tsx | 64 | ||||
| -rw-r--r-- | packages/desktop/src/components/terminal.tsx | 160 |
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} - /> - ) -} |
