From 794fe8f381c846f5241800363023d892c12cf495 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 22 Dec 2025 19:38:50 -0600 Subject: chore: rename packages/desktop -> packages/app --- .../app/src/components/dialog-connect-provider.tsx | 383 +++++++ .../app/src/components/dialog-manage-models.tsx | 57 + packages/app/src/components/dialog-select-file.tsx | 48 + .../src/components/dialog-select-model-unpaid.tsx | 110 ++ .../app/src/components/dialog-select-model.tsx | 83 ++ .../app/src/components/dialog-select-provider.tsx | 54 + packages/app/src/components/file-tree.tsx | 112 ++ packages/app/src/components/header.tsx | 209 ++++ packages/app/src/components/link.tsx | 17 + packages/app/src/components/prompt-input.tsx | 1153 ++++++++++++++++++++ .../app/src/components/session-context-usage.tsx | 64 ++ packages/app/src/components/terminal.tsx | 160 +++ 12 files changed, 2450 insertions(+) create mode 100644 packages/app/src/components/dialog-connect-provider.tsx create mode 100644 packages/app/src/components/dialog-manage-models.tsx create mode 100644 packages/app/src/components/dialog-select-file.tsx create mode 100644 packages/app/src/components/dialog-select-model-unpaid.tsx create mode 100644 packages/app/src/components/dialog-select-model.tsx create mode 100644 packages/app/src/components/dialog-select-provider.tsx create mode 100644 packages/app/src/components/file-tree.tsx create mode 100644 packages/app/src/components/header.tsx create mode 100644 packages/app/src/components/link.tsx create mode 100644 packages/app/src/components/prompt-input.tsx create mode 100644 packages/app/src/components/session-context-usage.tsx create mode 100644 packages/app/src/components/terminal.tsx (limited to 'packages/app/src/components') 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(() => ) + return + } + if (store.authorization) { + setStore("authorization", undefined) + setStore("methodIndex", undefined) + return + } + if (store.methodIndex) { + setStore("methodIndex", undefined) + return + } + dialog.show(() => ) + } + + return ( + }> +
+
+ +
+ + + Login with Claude Pro/Max + + Connect {provider().name} + +
+
+
+ + +
Select login method for {provider().name}.
+
+ { + listRef = ref + }} + items={methods} + key={(m) => m?.label} + onSelect={async (method, index) => { + if (!method) return + selectMethod(index) + }} + > + {(i) => ( +
+
+ + {i.label} +
+ )} + +
+ + +
+
+ + Authorization in progress... +
+
+
+ +
+
+ + Authorization failed: {store.error} +
+
+
+ + {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 ( +
+ + +
+
+ OpenCode Zen gives you access to a curated set of reliable optimized models for coding + agents. +
+
+ With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more. +
+
+ Visit{" "} + + opencode.ai/zen + {" "} + to collect your API key. +
+
+
+ +
+ Enter your {provider().name} API key to connect your account and use {provider().name} models + in OpenCode. +
+
+
+
+ + + +
+ ) + })} +
+ + + + {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 ( +
+
+ Visit this link to collect your authorization + code to connect your account and use {provider().name} models in OpenCode. +
+
+ + + +
+ ) + })} +
+ + {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 ( +
+
+ Visit this link and enter the code below to + connect your account and use {provider().name} models in OpenCode. +
+ +
+ + Waiting for authorization... +
+
+ ) + })} +
+
+
+ +
+
+
+ ) +} 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 ( + + `${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) => ( +
+ {i.name} +
e.stopPropagation()}> + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
+
+ )} +
+
+ ) +} 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 ( + + x} + onSelect={(path) => { + if (path) { + tabs().open("file://" + path) + } + dialog.close() + }} + > + {(i) => ( +
+
+ +
+ + {getDirectory(i)} + + {getFilename(i)} +
+
+
+ )} +
+
+ ) +} 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 ( + +
+
Free models provided by OpenCode
+ (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) => ( +
+ {i.name} + Free + + Latest + +
+ )} +
+
+
+
+
+
+
+
Add more models from popular providers
+
+ 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(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ +
+
+
+
+
+ ) +} 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.show(() => )} + > + Connect provider + + } + > + `${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) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} +
+ +
+ ) +} 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 ( + + 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(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+
+ ) +} 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" }) => ( + { + 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} + + {p.node.name} + + + + + + ) + + return ( +
+ + {(node) => ( + + + + (open ? local.file.expand(node.path) : local.file.collapse(node.path))} + > + + + + + + + + + + + + + props.onFileClick?.(node)}> +
+ + + + + + )} + +
+ ) +} 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 ( +
+ + + + +
+ 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 ( + <> +
+
+ + agent.name)} + current={local.agent.current().name} + onSelect={local.agent.set} + class="capitalize" + variant="ghost" + /> + + + Choose model + {command.keybind("model.choose")} +
+ } + > + + + + + +
+
+ { + const file = e.currentTarget.files?.[0] + if (file) addImageAttachment(file) + e.currentTarget.value = "" + }} + /> + + + fileInputRef.click()} + /> + + + + +
+ Stop + ESC +
+
+ +
+ Send + +
+
+ + } + > + +
+
+
+ +
+ ) +} + +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 ( + + {(ctx) => ( + +
+ Tokens + {ctx().tokens} +
+
+ Usage + {ctx().percentage ?? 0}% +
+
+ Cost + {cost()} +
+ + } + placement="top" + > +
+ {`${ctx().percentage ?? 0}%`} + +
+
+ )} +
+ ) +} 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 ( +
+ ) +} -- cgit v1.2.3