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