summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-15 04:37:14 -0600
committerAdam <[email protected]>2025-12-15 10:20:16 -0600
commitd66d806700de9ad9fb0f3997a076ad23a815e6ea (patch)
treeba651bd6ffa5a6f9d222326d2af627be1cf7d9f4
parente9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21 (diff)
downloadopencode-d66d806700de9ad9fb0f3997a076ad23a815e6ea.tar.gz
opencode-d66d806700de9ad9fb0f3997a076ad23a815e6ea.zip
wip(desktop): progress
-rw-r--r--packages/desktop/src/app.tsx11
-rw-r--r--packages/desktop/src/components/dialog-select-file.tsx11
-rw-r--r--packages/desktop/src/components/prompt-input.tsx78
-rw-r--r--packages/desktop/src/components/terminal.tsx2
-rw-r--r--packages/desktop/src/context/command.tsx2
-rw-r--r--packages/desktop/src/context/layout.tsx92
-rw-r--r--packages/desktop/src/context/prompt.tsx100
-rw-r--r--packages/desktop/src/context/session.tsx321
-rw-r--r--packages/desktop/src/context/terminal.tsx106
-rw-r--r--packages/desktop/src/pages/session.tsx184
10 files changed, 483 insertions, 424 deletions
diff --git a/packages/desktop/src/app.tsx b/packages/desktop/src/app.tsx
index 6414d0d49..2530f92dd 100644
--- a/packages/desktop/src/app.tsx
+++ b/packages/desktop/src/app.tsx
@@ -9,7 +9,8 @@ import { Diff } from "@opencode-ai/ui/diff"
import { GlobalSyncProvider } from "@/context/global-sync"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
-import { SessionProvider } from "@/context/session"
+import { TerminalProvider } from "@/context/terminal"
+import { PromptProvider } from "@/context/prompt"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
@@ -53,9 +54,11 @@ export function App() {
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
- <SessionProvider>
- <Session />
- </SessionProvider>
+ <TerminalProvider>
+ <PromptProvider>
+ <Session />
+ </PromptProvider>
+ </TerminalProvider>
</Show>
)}
/>
diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx
index 0250963b0..b719e15d2 100644
--- a/packages/desktop/src/components/dialog-select-file.tsx
+++ b/packages/desktop/src/components/dialog-select-file.tsx
@@ -3,13 +3,18 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useSession } from "@/context/session"
+import { useLayout } from "@/context/layout"
import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useParams } from "@solidjs/router"
+import { createMemo } from "solid-js"
export function DialogSelectFile() {
- const session = useSession()
+ 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
@@ -20,7 +25,7 @@ export function DialogSelectFile() {
key={(x) => x}
onSelect={(path) => {
if (path) {
- session.layout.openTab("file://" + path)
+ tabs().open("file://" + path)
}
dialog.clear()
}}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 6ab280fa6..a498593bd 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -4,9 +4,10 @@ import { createStore } from "solid-js/store"
import { makePersisted } from "@solid-primitives/storage"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
-import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
+import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt"
+import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
-import { useNavigate } from "@solidjs/router"
+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"
@@ -67,12 +68,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const sync = useSync()
const local = useLocal()
- const session = useSession()
+ const prompt = usePrompt()
+ const layout = useLayout()
+ const params = useParams()
const dialog = useDialog()
const providers = useProviders()
const command = useCommand()
let editorRef!: HTMLDivElement
+ // Session-derived state
+ 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
@@ -111,9 +126,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0)
- const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => {
- const length = position === "start" ? 0 : promptLength(prompt)
- session.prompt.set(prompt, length)
+ const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
+ const length = position === "start" ? 0 : promptLength(p)
+ prompt.set(p, length)
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, length)
@@ -149,9 +164,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
createEffect(() => {
- session.id
+ params.id
editorRef.focus()
- if (session.id) return
+ if (params.id) return
const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
}, 6500)
@@ -211,7 +226,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!cmd) return
// Since slash commands only trigger from start, just clear the input
editorRef.innerHTML = ""
- session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+ prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
setStore("popover", null)
command.trigger(cmd.id, "slash")
}
@@ -243,7 +258,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
createEffect(
on(
- () => session.prompt.current(),
+ () => prompt.current(),
(currentParts) => {
const domParts = parseFromDOM()
if (isPromptEqual(currentParts, domParts)) return
@@ -255,7 +270,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
editorRef.innerHTML = ""
- currentParts.forEach((part) => {
+ currentParts.forEach((part: ContentPart) => {
if (part.type === "text") {
editorRef.appendChild(document.createTextNode(part.content))
} else if (part.type === "file") {
@@ -333,7 +348,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null)
}
- session.prompt.set(rawParts, cursorPosition)
+ prompt.set(rawParts, cursorPosition)
}
const addPart = (part: ContentPart) => {
@@ -341,8 +356,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!selection || selection.rangeCount === 0) return
const cursorPosition = getCursorPosition(editorRef)
- const prompt = session.prompt.current()
- const rawText = prompt.map((p) => p.content).join("")
+ const currentPrompt = prompt.current()
+ const rawText = currentPrompt.map((p: ContentPart) => p.content).join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
@@ -403,7 +418,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const abort = () =>
sdk.client.session.abort({
- sessionID: session.id!,
+ sessionID: params.id!,
})
const addToHistory = (prompt: Prompt) => {
@@ -430,7 +445,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (direction === "up") {
if (entries.length === 0) return false
if (current === -1) {
- setStore("savedPrompt", clonePromptParts(session.prompt.current()))
+ setStore("savedPrompt", clonePromptParts(prompt.current()))
setStore("historyIndex", 0)
applyHistoryPrompt(entries[0], "start")
return true
@@ -481,7 +496,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const { collapsed, onFirstLine, onLastLine } = getCaretLineState()
if (!collapsed) return
const cursorPos = getCursorPosition(editorRef)
- const textLength = promptLength(session.prompt.current())
+ const textLength = promptLength(prompt.current())
const inHistory = store.historyIndex >= 0
const isStart = cursorPos === 0
const isEnd = cursorPos === textLength
@@ -511,7 +526,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (event.key === "Escape") {
if (store.popover) {
setStore("popover", null)
- } else if (session.working()) {
+ } else if (working()) {
abort()
}
}
@@ -519,18 +534,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSubmit = async (event: Event) => {
event.preventDefault()
- const prompt = session.prompt.current()
- const text = prompt.map((part) => part.content).join("")
+ const currentPrompt = prompt.current()
+ const text = currentPrompt.map((part: ContentPart) => part.content).join("")
if (text.trim().length === 0) {
- if (session.working()) abort()
+ if (working()) abort()
return
}
- addToHistory(prompt)
+ addToHistory(currentPrompt)
setStore("historyIndex", -1)
setStore("savedPrompt", null)
- let existing = session.info()
+ let existing = info()
if (!existing) {
const created = await sdk.client.session.create()
existing = created.data ?? undefined
@@ -539,7 +554,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!existing) return
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
- const attachments = prompt.filter((part) => part.type === "file")
+ const attachments = currentPrompt.filter(
+ (part: ContentPart) => part.type === "file",
+ ) as import("@/context/prompt").FileAttachmentPart[]
const attachmentParts = attachments.map((attachment) => {
const absolute = toAbsolutePath(attachment.path)
@@ -563,10 +580,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
})
- session.layout.setActiveTab(undefined)
- session.messages.setActive(undefined)
+ tabs().setActive(undefined)
editorRef.innerHTML = ""
- session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+ prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
sdk.client.session.prompt({
sessionID: existing.id,
@@ -671,7 +687,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"[&>[data-type=file]]:text-icon-info-active": true,
}}
/>
- <Show when={!session.prompt.dirty()}>
+ <Show when={!prompt.dirty()}>
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
</div>
@@ -703,7 +719,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
inactive={!session.prompt.dirty() && !session.working()}
value={
<Switch>
- <Match when={session.working()}>
+ <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>
@@ -720,8 +736,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<IconButton
type="submit"
- disabled={!session.prompt.dirty() && !session.working()}
- icon={session.working() ? "stop" : "arrow-up"}
+ disabled={!prompt.dirty() && !working()}
+ icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-10 w-8 absolute right-2 bottom-2"
/>
diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx
index 865d9b30f..082525e28 100644
--- a/packages/desktop/src/components/terminal.tsx
+++ b/packages/desktop/src/components/terminal.tsx
@@ -2,7 +2,7 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
-import { LocalPTY } from "@/context/session"
+import { LocalPTY } from "@/context/terminal"
import { usePrefersDark } from "@solid-primitives/media"
export interface TerminalProps extends ComponentProps<"div"> {
diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx
index b17a98270..26b03f980 100644
--- a/packages/desktop/src/context/command.tsx
+++ b/packages/desktop/src/context/command.tsx
@@ -138,7 +138,7 @@ function DialogCommand(props: { options: CommandOption[] }) {
search={{ placeholder: "Search commands", autofocus: true }}
emptyMessage="No commands found"
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
- key={(x) => x.id}
+ key={(x) => x?.id}
groupBy={(x) => x.category ?? ""}
onSelect={(option) => {
if (option) {
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 925bf4d4c..af71c6a00 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/packages/desktop/src/context/layout.tsx
@@ -1,5 +1,5 @@
-import { createStore } from "solid-js/store"
-import { createMemo, onMount } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { batch, createMemo, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync"
@@ -22,6 +22,11 @@ export function getAvatarColors(key?: string) {
}
}
+type SessionTabs = {
+ active?: string
+ all: string[]
+}
+
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -41,9 +46,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
review: {
state: "pane" as "pane" | "tab",
},
+ sessionTabs: {} as Record<string, SessionTabs>,
}),
{
- name: "layout.v2",
+ name: "layout.v3",
},
)
@@ -155,6 +161,86 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "state", "tab")
},
},
+ tabs(sessionKey: string) {
+ const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
+ return {
+ tabs,
+ active: createMemo(() => tabs().active),
+ all: createMemo(() => tabs().all),
+ setActive(tab: string | undefined) {
+ if (!store.sessionTabs[sessionKey]) {
+ setStore("sessionTabs", sessionKey, { all: [], active: tab })
+ } else {
+ setStore("sessionTabs", sessionKey, "active", tab)
+ }
+ },
+ setAll(all: string[]) {
+ if (!store.sessionTabs[sessionKey]) {
+ setStore("sessionTabs", sessionKey, { all, active: undefined })
+ } else {
+ setStore("sessionTabs", sessionKey, "all", all)
+ }
+ },
+ async open(tab: string) {
+ if (tab === "chat") {
+ if (!store.sessionTabs[sessionKey]) {
+ setStore("sessionTabs", sessionKey, { all: [], active: undefined })
+ } else {
+ setStore("sessionTabs", sessionKey, "active", undefined)
+ }
+ return
+ }
+ const current = store.sessionTabs[sessionKey] ?? { all: [] }
+ if (tab !== "review") {
+ if (!current.all.includes(tab)) {
+ if (!store.sessionTabs[sessionKey]) {
+ setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
+ } else {
+ setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
+ setStore("sessionTabs", sessionKey, "active", tab)
+ }
+ return
+ }
+ }
+ if (!store.sessionTabs[sessionKey]) {
+ setStore("sessionTabs", sessionKey, { all: [], active: tab })
+ } else {
+ setStore("sessionTabs", sessionKey, "active", tab)
+ }
+ },
+ close(tab: string) {
+ const current = store.sessionTabs[sessionKey]
+ if (!current) return
+ batch(() => {
+ setStore(
+ "sessionTabs",
+ sessionKey,
+ "all",
+ current.all.filter((x) => x !== tab),
+ )
+ if (current.active === tab) {
+ const index = current.all.findIndex((f) => f === tab)
+ const previous = current.all[Math.max(0, index - 1)]
+ setStore("sessionTabs", sessionKey, "active", previous)
+ }
+ })
+ },
+ move(tab: string, to: number) {
+ const current = store.sessionTabs[sessionKey]
+ if (!current) return
+ const index = current.all.findIndex((f) => f === tab)
+ if (index === -1) return
+ setStore(
+ "sessionTabs",
+ sessionKey,
+ "all",
+ produce((opened) => {
+ opened.splice(to, 0, opened.splice(index, 1)[0])
+ }),
+ )
+ },
+ }
+ },
}
},
})
diff --git a/packages/desktop/src/context/prompt.tsx b/packages/desktop/src/context/prompt.tsx
new file mode 100644
index 000000000..c3b3bbace
--- /dev/null
+++ b/packages/desktop/src/context/prompt.tsx
@@ -0,0 +1,100 @@
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createMemo } from "solid-js"
+import { makePersisted } from "@solid-primitives/storage"
+import { useParams } from "@solidjs/router"
+import { TextSelection } from "./local"
+
+interface PartBase {
+ content: string
+ start: number
+ end: number
+}
+
+export interface TextPart extends PartBase {
+ type: "text"
+}
+
+export interface FileAttachmentPart extends PartBase {
+ type: "file"
+ path: string
+ selection?: TextSelection
+}
+
+export type ContentPart = TextPart | FileAttachmentPart
+export type Prompt = ContentPart[]
+
+export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
+ if (promptA.length !== promptB.length) return false
+ for (let i = 0; i < promptA.length; i++) {
+ const partA = promptA[i]
+ const partB = promptB[i]
+ if (partA.type !== partB.type) return false
+ if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
+ return false
+ }
+ if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
+ return false
+ }
+ }
+ return true
+}
+
+function cloneSelection(selection?: TextSelection) {
+ if (!selection) return undefined
+ return { ...selection }
+}
+
+function clonePart(part: ContentPart): ContentPart {
+ if (part.type === "text") return { ...part }
+ return {
+ ...part,
+ selection: cloneSelection(part.selection),
+ }
+}
+
+function clonePrompt(prompt: Prompt): Prompt {
+ return prompt.map(clonePart)
+}
+
+export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
+ name: "Prompt",
+ init: () => {
+ const params = useParams()
+ const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
+
+ const [store, setStore] = makePersisted(
+ createStore<{
+ prompt: Prompt
+ cursor?: number
+ }>({
+ prompt: clonePrompt(DEFAULT_PROMPT),
+ cursor: undefined,
+ }),
+ {
+ name: name(),
+ },
+ )
+
+ return {
+ current: createMemo(() => store.prompt),
+ cursor: createMemo(() => store.cursor),
+ dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
+ set(prompt: Prompt, cursorPosition?: number) {
+ const next = clonePrompt(prompt)
+ batch(() => {
+ setStore("prompt", next)
+ if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
+ })
+ },
+ reset() {
+ batch(() => {
+ setStore("prompt", clonePrompt(DEFAULT_PROMPT))
+ setStore("cursor", 0)
+ })
+ },
+ }
+ },
+})
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
deleted file mode 100644
index 860c1a14f..000000000
--- a/packages/desktop/src/context/session.tsx
+++ /dev/null
@@ -1,321 +0,0 @@
-import { createStore, produce } from "solid-js/store"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createEffect, createMemo } from "solid-js"
-import { useSync } from "./sync"
-import { makePersisted } from "@solid-primitives/storage"
-import { TextSelection } from "./local"
-import { pipe, sumBy } from "remeda"
-import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
-import { useParams } from "@solidjs/router"
-import { useSDK } from "./sdk"
-
-export type LocalPTY = {
- id: string
- title: string
- rows?: number
- cols?: number
- buffer?: string
- scrollY?: number
-}
-
-export const { use: useSession, provider: SessionProvider } = createSimpleContext({
- name: "Session",
- init: () => {
- const sdk = useSDK()
- const params = useParams()
- const sync = useSync()
- const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`)
-
- const [store, setStore] = makePersisted(
- createStore<{
- messageId?: string
- tabs: {
- active?: string
- all: string[]
- }
- prompt: Prompt
- cursor?: number
- terminals: {
- active?: string
- all: LocalPTY[]
- }
- }>({
- tabs: {
- all: [],
- },
- prompt: clonePrompt(DEFAULT_PROMPT),
- cursor: undefined,
- terminals: { all: [] },
- }),
- {
- name: name(),
- },
- )
-
- createEffect(() => {
- if (!params.id) return
- sync.session.sync(params.id)
- })
-
- const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
- const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
- const userMessages = createMemo(() =>
- messages()
- .filter((m) => m.role === "user")
- .sort((a, b) => a.id.localeCompare(b.id)),
- )
- const lastUserMessage = createMemo(() => {
- return userMessages()?.at(-1)
- })
- const activeMessage = createMemo(() => {
- if (!store.messageId) return lastUserMessage()
- return userMessages()?.find((m) => m.id === store.messageId)
- })
- const status = createMemo(
- () =>
- sync.data.session_status[params.id ?? ""] ?? {
- type: "idle",
- },
- )
- const working = createMemo(() => status()?.type !== "idle")
-
- const cost = createMemo(() => {
- const total = pipe(
- messages(),
- sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
- )
- return new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- }).format(total)
- })
-
- const last = createMemo(
- () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
- )
- const model = createMemo(() =>
- last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
- )
- const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
-
- const tokens = createMemo(() => {
- if (!last()) return
- const tokens = last().tokens
- return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
- })
-
- const context = createMemo(() => {
- const total = tokens()
- const limit = model()?.limit.context
- if (!total || !limit) return 0
- return Math.round((total / limit) * 100)
- })
-
- return {
- get id() {
- return params.id
- },
- info,
- status,
- working,
- diffs,
- prompt: {
- current: createMemo(() => store.prompt),
- cursor: createMemo(() => store.cursor),
- dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
- set(prompt: Prompt, cursorPosition?: number) {
- const next = clonePrompt(prompt)
- batch(() => {
- setStore("prompt", next)
- if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
- })
- },
- },
- messages: {
- all: messages,
- user: userMessages,
- last: lastUserMessage,
- active: activeMessage,
- setActive(message: UserMessage | undefined) {
- setStore("messageId", message?.id)
- },
- },
- usage: {
- tokens,
- cost,
- context,
- },
- layout: {
- tabs: store.tabs,
- setActiveTab(tab: string | undefined) {
- setStore("tabs", "active", tab)
- },
- setOpenedTabs(tabs: string[]) {
- setStore("tabs", "all", tabs)
- },
- async openTab(tab: string) {
- if (tab === "chat") {
- setStore("tabs", "active", undefined)
- return
- }
- if (tab !== "review") {
- if (!store.tabs.all.includes(tab)) {
- setStore("tabs", "all", [...store.tabs.all, tab])
- }
- }
- setStore("tabs", "active", tab)
- },
- closeTab(tab: string) {
- batch(() => {
- setStore(
- "tabs",
- "all",
- store.tabs.all.filter((x) => x !== tab),
- )
- if (store.tabs.active === tab) {
- const index = store.tabs.all.findIndex((f) => f === tab)
- const previous = store.tabs.all[Math.max(0, index - 1)]
- setStore("tabs", "active", previous)
- }
- })
- },
- moveTab(tab: string, to: number) {
- const index = store.tabs.all.findIndex((f) => f === tab)
- if (index === -1) return
- setStore(
- "tabs",
- "all",
- produce((opened) => {
- opened.splice(to, 0, opened.splice(index, 1)[0])
- }),
- )
- },
- },
- terminal: {
- all: createMemo(() => Object.values(store.terminals.all)),
- active: createMemo(() => store.terminals.active),
- new() {
- sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => {
- const id = pty.data?.id
- if (!id) return
- setStore("terminals", "all", [
- ...store.terminals.all,
- {
- id,
- title: pty.data?.title ?? "Terminal",
- },
- ])
- setStore("terminals", "active", id)
- })
- },
- update(pty: Partial<LocalPTY> & { id: string }) {
- setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
- sdk.client.pty.update({
- ptyID: pty.id,
- title: pty.title,
- size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
- })
- },
- async clone(id: string) {
- const index = store.terminals.all.findIndex((x) => x.id === id)
- const pty = store.terminals.all[index]
- if (!pty) return
- const clone = await sdk.client.pty.create({
- title: pty.title,
- })
- if (!clone.data) return
- setStore("terminals", "all", index, {
- ...pty,
- ...clone.data,
- })
- if (store.terminals.active === pty.id) {
- setStore("terminals", "active", clone.data.id)
- }
- },
- open(id: string) {
- setStore("terminals", "active", id)
- },
- async close(id: string) {
- batch(() => {
- setStore(
- "terminals",
- "all",
- store.terminals.all.filter((x) => x.id !== id),
- )
- if (store.terminals.active === id) {
- const index = store.terminals.all.findIndex((f) => f.id === id)
- const previous = store.tabs.all[Math.max(0, index - 1)]
- setStore("terminals", "active", previous)
- }
- })
- await sdk.client.pty.remove({ ptyID: id })
- },
- move(id: string, to: number) {
- const index = store.terminals.all.findIndex((f) => f.id === id)
- if (index === -1) return
- setStore(
- "terminals",
- "all",
- produce((all) => {
- all.splice(to, 0, all.splice(index, 1)[0])
- }),
- )
- },
- },
- }
- },
-})
-
-interface PartBase {
- content: string
- start: number
- end: number
-}
-
-export interface TextPart extends PartBase {
- type: "text"
-}
-
-export interface FileAttachmentPart extends PartBase {
- type: "file"
- path: string
- selection?: TextSelection
-}
-
-export type ContentPart = TextPart | FileAttachmentPart
-export type Prompt = ContentPart[]
-
-export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
-
-export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
- if (promptA.length !== promptB.length) return false
- for (let i = 0; i < promptA.length; i++) {
- const partA = promptA[i]
- const partB = promptB[i]
- if (partA.type !== partB.type) return false
- if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
- return false
- }
- if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
- return false
- }
- }
- return true
-}
-
-function cloneSelection(selection?: TextSelection) {
- if (!selection) return undefined
- return { ...selection }
-}
-
-function clonePart(part: ContentPart): ContentPart {
- if (part.type === "text") return { ...part }
- return {
- ...part,
- selection: cloneSelection(part.selection),
- }
-}
-
-function clonePrompt(prompt: Prompt): Prompt {
- return prompt.map(clonePart)
-}
diff --git a/packages/desktop/src/context/terminal.tsx b/packages/desktop/src/context/terminal.tsx
new file mode 100644
index 000000000..cf9b5a5b9
--- /dev/null
+++ b/packages/desktop/src/context/terminal.tsx
@@ -0,0 +1,106 @@
+import { createStore, produce } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createMemo } from "solid-js"
+import { makePersisted } from "@solid-primitives/storage"
+import { useParams } from "@solidjs/router"
+import { useSDK } from "./sdk"
+
+export type LocalPTY = {
+ id: string
+ title: string
+ rows?: number
+ cols?: number
+ buffer?: string
+ scrollY?: number
+}
+
+export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
+ name: "Terminal",
+ init: () => {
+ const sdk = useSDK()
+ const params = useParams()
+ const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
+
+ const [store, setStore] = makePersisted(
+ createStore<{
+ active?: string
+ all: LocalPTY[]
+ }>({
+ all: [],
+ }),
+ {
+ name: name(),
+ },
+ )
+
+ return {
+ all: createMemo(() => Object.values(store.all)),
+ active: createMemo(() => store.active),
+ new() {
+ sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
+ const id = pty.data?.id
+ if (!id) return
+ setStore("all", [
+ ...store.all,
+ {
+ id,
+ title: pty.data?.title ?? "Terminal",
+ },
+ ])
+ setStore("active", id)
+ })
+ },
+ update(pty: Partial<LocalPTY> & { id: string }) {
+ setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
+ sdk.client.pty.update({
+ ptyID: pty.id,
+ title: pty.title,
+ size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+ })
+ },
+ async clone(id: string) {
+ const index = store.all.findIndex((x) => x.id === id)
+ const pty = store.all[index]
+ if (!pty) return
+ const clone = await sdk.client.pty.create({
+ title: pty.title,
+ })
+ if (!clone.data) return
+ setStore("all", index, {
+ ...pty,
+ ...clone.data,
+ })
+ if (store.active === pty.id) {
+ setStore("active", clone.data.id)
+ }
+ },
+ open(id: string) {
+ setStore("active", id)
+ },
+ async close(id: string) {
+ batch(() => {
+ setStore(
+ "all",
+ store.all.filter((x) => x.id !== id),
+ )
+ if (store.active === id) {
+ const index = store.all.findIndex((f) => f.id === id)
+ const previous = store.all[Math.max(0, index - 1)]
+ setStore("active", previous?.id)
+ }
+ })
+ await sdk.client.pty.remove({ ptyID: id })
+ },
+ move(id: string, to: number) {
+ const index = store.all.findIndex((f) => f.id === id)
+ if (index === -1) return
+ setStore(
+ "all",
+ produce((all) => {
+ all.splice(to, 0, all.splice(index, 1)[0])
+ }),
+ )
+ },
+ }
+ },
+})
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index e3cac4842..48e01239c 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -27,22 +27,91 @@ import {
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
-import { useSession, type LocalPTY } from "@/context/session"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
+import { usePrompt } from "@/context/prompt"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Terminal } from "@/components/terminal"
import { checksum } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { useCommand } from "@/context/command"
+import { useParams } from "@solidjs/router"
+import { pipe, sumBy } from "remeda"
+import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
export default function Page() {
const layout = useLayout()
const local = useLocal()
const sync = useSync()
- const session = useSession()
+ const terminal = useTerminal()
+ const prompt = usePrompt()
const dialog = useDialog()
const command = useCommand()
+ const params = useParams()
+
+ // Session-specific derived state
+ 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 messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+ const userMessages = createMemo(() =>
+ messages()
+ .filter((m) => m.role === "user")
+ .sort((a, b) => a.id.localeCompare(b.id)),
+ )
+ const lastUserMessage = createMemo(() => userMessages()?.at(-1))
+
+ const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
+ const activeMessage = createMemo(() => {
+ if (!messageStore.messageId) return lastUserMessage()
+ return userMessages()?.find((m) => m.id === messageStore.messageId)
+ })
+ const setActiveMessage = (message: UserMessage | undefined) => {
+ setMessageStore("messageId", message?.id)
+ }
+
+ const status = createMemo(
+ () =>
+ sync.data.session_status[params.id ?? ""] ?? {
+ type: "idle",
+ },
+ )
+ const working = createMemo(() => status()?.type !== "idle")
+
+ const cost = createMemo(() => {
+ const total = pipe(
+ messages(),
+ sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
+ )
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(total)
+ })
+
+ const last = createMemo(
+ () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
+ )
+ const model = createMemo(() =>
+ last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
+ )
+ const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+
+ const tokens = createMemo(() => {
+ if (!last()) return
+ const t = last().tokens
+ return t.input + t.output + t.reasoning + t.cache.read + t.cache.write
+ })
+
+ const context = createMemo(() => {
+ const total = tokens()
+ const limit = model()?.limit.context
+ if (!total || !limit) return 0
+ return Math.round((total / limit) * 100)
+ })
+
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
activeDraggable: undefined as string | undefined,
@@ -51,9 +120,14 @@ export default function Page() {
let inputRef!: HTMLDivElement
createEffect(() => {
+ if (!params.id) return
+ sync.session.sync(params.id)
+ })
+
+ createEffect(() => {
if (layout.terminal.opened()) {
- if (session.terminal.all().length === 0) {
- session.terminal.new()
+ if (terminal.all().length === 0) {
+ terminal.new()
}
}
})
@@ -99,7 +173,7 @@ export default function Page() {
description: "Create a new terminal tab",
category: "Terminal",
keybind: "ctrl+shift+`",
- onSelect: () => session.terminal.new(),
+ onSelect: () => terminal.new(),
},
])
@@ -166,11 +240,11 @@ export default function Page() {
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
- const currentTabs = session.layout.tabs.all
+ const currentTabs = tabs().all()
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
const toIndex = currentTabs?.indexOf(droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== undefined) {
- session.layout.moveTab(draggable.id.toString(), toIndex)
+ tabs().move(draggable.id.toString(), toIndex)
}
}
}
@@ -188,11 +262,11 @@ export default function Page() {
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
- const terminals = session.terminal.all()
- const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString())
- const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString())
+ const terminals = terminal.all()
+ const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
+ const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
- session.terminal.move(draggable.id.toString(), toIndex)
+ terminal.move(draggable.id.toString(), toIndex)
}
}
}
@@ -210,8 +284,8 @@ export default function Page() {
<Tabs.Trigger
value={props.terminal.id}
closeButton={
- session.terminal.all().length > 1 && (
- <IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(props.terminal.id)} />
+ terminal.all().length > 1 && (
+ <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
)
}
>
@@ -326,7 +400,7 @@ export default function Page() {
return typeof draggable.id === "string" ? draggable.id : undefined
}
- const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
+ const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
return (
<div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
@@ -339,7 +413,7 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
- <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
+ <Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat">
@@ -349,15 +423,15 @@ export default function Page() {
value={`${new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
- }).format(session.usage.tokens() ?? 0)} Tokens`}
+ }).format(tokens() ?? 0)} Tokens`}
class="flex items-center gap-1.5"
>
- <ProgressCircle percentage={session.usage.context() ?? 0} />
- <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
+ <ProgressCircle percentage={context() ?? 0} />
+ <div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div>
</Tooltip>
</div>
</Tabs.Trigger>
- <Show when={layout.review.state() === "tab" && session.diffs().length}>
+ <Show when={layout.review.state() === "tab" && diffs().length}>
<Tabs.Trigger
value="review"
closeButton={
@@ -367,25 +441,23 @@ export default function Page() {
}
>
<div class="flex items-center gap-3">
- <Show when={session.diffs()}>
- <DiffChanges changes={session.diffs()} variant="bars" />
+ <Show when={diffs()}>
+ <DiffChanges changes={diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
- <Show when={session.info()?.summary?.files}>
+ <Show when={info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
- {session.info()?.summary?.files ?? 0}
+ {info()?.summary?.files ?? 0}
</div>
</Show>
</div>
</div>
</Tabs.Trigger>
</Show>
- <SortableProvider ids={session.layout.tabs.all ?? []}>
- <For each={session.layout.tabs.all ?? []}>
- {(tab) => (
- <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />
- )}
+ <SortableProvider ids={tabs().all() ?? []}>
+ <For each={tabs().all() ?? []}>
+ {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
@@ -415,27 +487,23 @@ export default function Page() {
}}
>
<Switch>
- <Match when={session.id}>
+ <Match when={params.id}>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
- messages={session.messages.user()}
- current={session.messages.active()}
- onMessageSelect={session.messages.setActive}
+ messages={userMessages()}
+ current={activeMessage()}
+ onMessageSelect={setActiveMessage}
wide={wide()}
/>
<SessionTurn
- sessionID={session.id!}
- messageID={session.messages.active()?.id!}
+ sessionID={params.id!}
+ messageID={activeMessage()?.id!}
classes={{
root: "pb-20 flex-1 min-w-0",
content: "pb-20",
container:
"w-full " +
- (wide()
- ? "max-w-146 mx-auto px-6"
- : session.messages.user().length > 1
- ? "pr-6 pl-18"
- : "px-6"),
+ (wide() ? "max-w-146 mx-auto px-6" : userMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
}}
/>
</div>
@@ -476,7 +544,7 @@ export default function Page() {
</div>
</div>
</div>
- <Show when={layout.review.state() === "pane" && session.diffs().length}>
+ <Show when={layout.review.state() === "pane" && diffs().length}>
<div
classList={{
"relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
@@ -488,7 +556,7 @@ export default function Page() {
header: "px-6",
container: "px-6",
}}
- diffs={session.diffs()}
+ diffs={diffs()}
actions={
<Tooltip value="Open in tab">
<IconButton
@@ -496,7 +564,7 @@ export default function Page() {
variant="ghost"
onClick={() => {
layout.review.tab()
- session.layout.setActiveTab("review")
+ tabs().setActive("review")
}}
/>
</Tooltip>
@@ -506,7 +574,7 @@ export default function Page() {
</Show>
</div>
</Tabs.Content>
- <Show when={layout.review.state() === "tab" && session.diffs().length}>
+ <Show when={layout.review.state() === "tab" && diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
<div
classList={{
@@ -519,13 +587,13 @@ export default function Page() {
header: "px-6",
container: "px-6",
}}
- diffs={session.diffs()}
+ diffs={diffs()}
split
/>
</div>
</Tabs.Content>
</Show>
- <For each={session.layout.tabs.all}>
+ <For each={tabs().all()}>
{(tab) => {
const [file] = createResource(
() => tab,
@@ -579,7 +647,7 @@ export default function Page() {
</Show>
</DragOverlay>
</DragDropProvider>
- <Show when={session.layout.tabs.active}>
+ <Show when={tabs().active()}>
<div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<PromptInput
ref={(el) => {
@@ -639,25 +707,21 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
- <Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}>
+ <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
<Tabs.List class="h-10">
- <SortableProvider ids={session.terminal.all().map((t) => t.id)}>
- <For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For>
+ <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
+ <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<Tooltip value="New Terminal" class="flex items-center">
- <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
+ <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
</Tooltip>
</div>
</Tabs.List>
- <For each={session.terminal.all()}>
- {(terminal) => (
- <Tabs.Content value={terminal.id}>
- <Terminal
- pty={terminal}
- onCleanup={session.terminal.update}
- onConnectError={() => session.terminal.clone(terminal.id)}
- />
+ <For each={terminal.all()}>
+ {(pty) => (
+ <Tabs.Content value={pty.id}>
+ <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Tabs.Content>
)}
</For>
@@ -665,9 +729,9 @@ export default function Page() {
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {
- const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId()))
+ const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
return (
- <Show when={terminal()}>
+ <Show when={pty()}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{t().title}