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) => (
+
+ )}
+
+
+
+
+
+
+
+ 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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 (
+
+ )
+}
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 = (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({
+ items: local.file.searchFilesAndDirectories,
+ key: (x) => x,
+ onSelect: handleFileSelect,
+ })
+
+ const slashCommands = createMemo(() => {
+ 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({
+ 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("[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 (
+
+
+
+
{
+ 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",
+ }}
+ />
+
+
+ {store.mode === "shell"
+ ? "Enter shell command..."
+ : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
+
+
+
+
+
+
+
+
+
+ Shell
+ esc to exit
+
+
+
+
+ Cycle agent
+ {command.keybind("agent.cycle")}
+
+ }
+ >
+
+ }
+ >
+
+
+
+
+
+
+
+
{
+ 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