From e9b95b2e9199bfd94d6ad2f67a12ef5ae60f4f21 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 04:09:57 -0600
Subject: wip(desktop): progress

---
 packages/desktop/src/context/command.tsx | 255 +++++++++++++++++++++++++++++++
 1 file changed, 255 insertions(+)
 create mode 100644 packages/desktop/src/context/command.tsx

(limited to 'packages/desktop/src/context/command.tsx')

diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx
new file mode 100644
index 000000000..b17a98270
--- /dev/null
+++ b/packages/desktop/src/context/command.tsx
@@ -0,0 +1,255 @@
+import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+
+const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+
+/**
+ * Keybind configuration type.
+ * Format: "mod+key" where mod can be ctrl, alt, shift, meta (or cmd on mac)
+ * Multiple keybinds can be separated by comma: "mod+p,ctrl+shift+p"
+ * Use "mod" for platform-appropriate modifier (cmd on mac, ctrl elsewhere)
+ */
+export type KeybindConfig = string
+
+export interface Keybind {
+  key: string
+  ctrl: boolean
+  meta: boolean
+  shift: boolean
+  alt: boolean
+}
+
+export interface CommandOption {
+  /** Unique identifier for the command */
+  id: string
+  /** Display title in the command palette */
+  title: string
+  /** Optional description */
+  description?: string
+  /** Category for grouping in the palette */
+  category?: string
+  /** Keybind string (e.g., "mod+p", "ctrl+shift+t") */
+  keybind?: KeybindConfig
+  /** Slash command trigger (e.g., "models" for /models) */
+  slash?: string
+  /** Whether to show in the "Suggested" section */
+  suggested?: boolean
+  /** Whether the command is disabled */
+  disabled?: boolean
+  /** Handler when command is selected */
+  onSelect?: (source?: "palette" | "keybind" | "slash") => void
+}
+
+export function parseKeybind(config: string): Keybind[] {
+  if (!config || config === "none") return []
+
+  return config.split(",").map((combo) => {
+    const parts = combo.trim().toLowerCase().split("+")
+    const keybind: Keybind = {
+      key: "",
+      ctrl: false,
+      meta: false,
+      shift: false,
+      alt: false,
+    }
+
+    for (const part of parts) {
+      switch (part) {
+        case "ctrl":
+        case "control":
+          keybind.ctrl = true
+          break
+        case "meta":
+        case "cmd":
+        case "command":
+          keybind.meta = true
+          break
+        case "mod":
+          if (IS_MAC) keybind.meta = true
+          else keybind.ctrl = true
+          break
+        case "alt":
+        case "option":
+          keybind.alt = true
+          break
+        case "shift":
+          keybind.shift = true
+          break
+        default:
+          keybind.key = part
+          break
+      }
+    }
+
+    return keybind
+  })
+}
+
+export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
+  const eventKey = event.key.toLowerCase()
+
+  for (const kb of keybinds) {
+    const keyMatch = kb.key === eventKey
+    const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
+    const metaMatch = kb.meta === (event.metaKey || false)
+    const shiftMatch = kb.shift === (event.shiftKey || false)
+    const altMatch = kb.alt === (event.altKey || false)
+
+    if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
+      return true
+    }
+  }
+
+  return false
+}
+
+export function formatKeybind(config: string): string {
+  if (!config || config === "none") return ""
+
+  const keybinds = parseKeybind(config)
+  if (keybinds.length === 0) return ""
+
+  const kb = keybinds[0]
+  const parts: string[] = []
+
+  if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
+  if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
+  if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
+  if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
+
+  if (kb.key) {
+    const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
+    parts.push(displayKey)
+  }
+
+  return IS_MAC ? parts.join("") : parts.join("+")
+}
+
+function DialogCommand(props: { options: CommandOption[] }) {
+  const dialog = useDialog()
+
+  return (
+    <Dialog title="Commands">
+      <List
+        class="px-2.5"
+        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}
+        groupBy={(x) => x.category ?? ""}
+        onSelect={(option) => {
+          if (option) {
+            dialog.clear()
+            option.onSelect?.("palette")
+          }
+        }}
+      >
+        {(option) => (
+          <div class="w-full flex items-center justify-between gap-4">
+            <div class="flex items-center gap-2 min-w-0">
+              <span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
+              <Show when={option.description}>
+                <span class="text-14-regular text-text-weak truncate">{option.description}</span>
+              </Show>
+            </div>
+            <Show when={option.keybind}>
+              <span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
+            </Show>
+          </div>
+        )}
+      </List>
+    </Dialog>
+  )
+}
+
+export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
+  name: "Command",
+  init: () => {
+    const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
+    const [suspendCount, setSuspendCount] = createSignal(0)
+    const dialog = useDialog()
+
+    const options = createMemo(() => {
+      const all = registrations().flatMap((x) => x())
+      const suggested = all.filter((x) => x.suggested && !x.disabled)
+      return [
+        ...suggested.map((x) => ({
+          ...x,
+          id: "suggested." + x.id,
+          category: "Suggested",
+        })),
+        ...all,
+      ]
+    })
+
+    const suspended = () => suspendCount() > 0
+
+    const showPalette = () => {
+      if (dialog.stack.length === 0) {
+        dialog.replace(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
+      }
+    }
+
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (suspended()) return
+
+      // Check for command palette keybind (mod+shift+p)
+      const paletteKeybinds = parseKeybind("mod+shift+p")
+      if (matchKeybind(paletteKeybinds, event)) {
+        event.preventDefault()
+        showPalette()
+        return
+      }
+
+      // Check registered command keybinds
+      for (const option of options()) {
+        if (option.disabled) continue
+        if (!option.keybind) continue
+
+        const keybinds = parseKeybind(option.keybind)
+        if (matchKeybind(keybinds, event)) {
+          event.preventDefault()
+          option.onSelect?.("keybind")
+          return
+        }
+      }
+    }
+
+    onMount(() => {
+      document.addEventListener("keydown", handleKeyDown)
+    })
+
+    onCleanup(() => {
+      document.removeEventListener("keydown", handleKeyDown)
+    })
+
+    return {
+      register(cb: () => CommandOption[]) {
+        const results = createMemo(cb)
+        setRegistrations((arr) => [results, ...arr])
+        onCleanup(() => {
+          setRegistrations((arr) => arr.filter((x) => x !== results))
+        })
+      },
+      trigger(id: string, source?: "palette" | "keybind" | "slash") {
+        for (const option of options()) {
+          if (option.id === id || option.id === "suggested." + id) {
+            option.onSelect?.(source)
+            return
+          }
+        }
+      },
+      show: showPalette,
+      keybinds(enabled: boolean) {
+        setSuspendCount((count) => count + (enabled ? -1 : 1))
+      },
+      suspended,
+      get options() {
+        return options()
+      },
+    }
+  },
+})
-- 
cgit v1.2.3


From d66d806700de9ad9fb0f3997a076ad23a815e6ea Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 04:37:14 -0600
Subject: wip(desktop): progress

---
 packages/desktop/src/app.tsx                       |  11 +-
 .../desktop/src/components/dialog-select-file.tsx  |  11 +-
 packages/desktop/src/components/prompt-input.tsx   |  78 +++--
 packages/desktop/src/components/terminal.tsx       |   2 +-
 packages/desktop/src/context/command.tsx           |   2 +-
 packages/desktop/src/context/layout.tsx            |  92 +++++-
 packages/desktop/src/context/prompt.tsx            | 100 +++++++
 packages/desktop/src/context/session.tsx           | 321 ---------------------
 packages/desktop/src/context/terminal.tsx          | 106 +++++++
 packages/desktop/src/pages/session.tsx             | 184 ++++++++----
 10 files changed, 483 insertions(+), 424 deletions(-)
 create mode 100644 packages/desktop/src/context/prompt.tsx
 delete mode 100644 packages/desktop/src/context/session.tsx
 create mode 100644 packages/desktop/src/context/terminal.tsx

(limited to 'packages/desktop/src/context/command.tsx')

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,
@@ -50,10 +119,15 @@ 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}
-- 
cgit v1.2.3


From 5fbcb203f5a2cab13c2f7468430b25be4989063b Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 06:34:08 -0600
Subject: wip(desktop): progress

---
 packages/desktop/src/context/command.tsx |   1 +
 packages/desktop/src/pages/layout.tsx    | 150 +++++++++++++++++++++++++++----
 2 files changed, 134 insertions(+), 17 deletions(-)

(limited to 'packages/desktop/src/context/command.tsx')

diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx
index 26b03f980..dcc4b6007 100644
--- a/packages/desktop/src/context/command.tsx
+++ b/packages/desktop/src/context/command.tsx
@@ -139,6 +139,7 @@ function DialogCommand(props: { options: CommandOption[] }) {
         emptyMessage="No commands found"
         items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
         key={(x) => x?.id}
+        filterKeys={["title", "description", "category"]}
         groupBy={(x) => x.category ?? ""}
         onSelect={(option) => {
           if (option) {
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index df162c187..bb2302503 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -36,6 +36,7 @@ import { Binary } from "@opencode-ai/util/binary"
 import { Header } from "@/components/header"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectProvider } from "@/components/dialog-select-provider"
+import { useCommand } from "@/context/command"
 
 export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
@@ -52,6 +53,137 @@ export default function Layout(props: ParentProps) {
   const navigate = useNavigate()
   const providers = useProviders()
   const dialog = useDialog()
+  const command = useCommand()
+
+  const currentSessions = createMemo(() => {
+    if (!params.dir) return []
+    const directory = base64Decode(params.dir)
+    return globalSync.child(directory)[0].session ?? []
+  })
+
+  function navigateSessionByOffset(offset: number) {
+    const projects = layout.projects.list()
+    if (projects.length === 0) return
+
+    const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
+    const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
+
+    // If we're not in any project, navigate to the first/last project based on direction
+    if (projectIndex === -1) {
+      const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
+      if (targetProject) navigateToProject(targetProject.worktree)
+      return
+    }
+
+    const sessions = currentSessions()
+    const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
+
+    // Calculate target index within current project
+    let targetIndex: number
+    if (sessionIndex === -1) {
+      // Not on a session - go to first session for "next", last session for "prev"
+      targetIndex = offset > 0 ? 0 : sessions.length - 1
+    } else {
+      targetIndex = sessionIndex + offset
+    }
+
+    // If target is within bounds, navigate to that session
+    if (targetIndex >= 0 && targetIndex < sessions.length) {
+      navigateToSession(sessions[targetIndex])
+      return
+    }
+
+    // Navigate to adjacent project
+    const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1)
+    const nextProject = projects[nextProjectIndex]
+    if (!nextProject) return
+
+    const nextProjectSessions = globalSync.child(nextProject.worktree)[0].session ?? []
+    if (nextProjectSessions.length === 0) {
+      // Navigate to the project's new session page if no sessions
+      navigateToProject(nextProject.worktree)
+      return
+    }
+
+    // If going down (offset > 0), go to first session; if going up (offset < 0), go to last session
+    const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
+    navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
+  }
+
+  async function archiveSession(session: Session) {
+    const [store, setStore] = globalSync.child(session.directory)
+    const sessions = store.session ?? []
+    const index = sessions.findIndex((s) => s.id === session.id)
+    // Get next session (prefer next, then prev) before removing
+    const nextSession = sessions[index + 1] ?? sessions[index - 1]
+
+    await globalSDK.client.session.update({
+      directory: session.directory,
+      sessionID: session.id,
+      time: { archived: Date.now() },
+    })
+    setStore(
+      produce((draft) => {
+        const match = Binary.search(draft.session, session.id, (s) => s.id)
+        if (match.found) draft.session.splice(match.index, 1)
+      }),
+    )
+    if (session.id === params.id) {
+      if (nextSession) {
+        navigate(`/${params.dir}/session/${nextSession.id}`)
+      } else {
+        navigate(`/${params.dir}/session`)
+      }
+    }
+  }
+
+  command.register(() => [
+    {
+      id: "sidebar.toggle",
+      title: "Toggle sidebar",
+      category: "View",
+      keybind: "mod+b",
+      onSelect: () => layout.sidebar.toggle(),
+    },
+    ...(platform.openDirectoryPickerDialog
+      ? [
+          {
+            id: "project.open",
+            title: "Open project",
+            category: "Project",
+            keybind: "mod+o",
+            onSelect: () => chooseProject(),
+          },
+        ]
+      : []),
+    {
+      id: "session.previous",
+      title: "Previous session",
+      category: "Session",
+      keybind: "alt+arrowup",
+      disabled: !params.dir,
+      onSelect: () => navigateSessionByOffset(-1),
+    },
+    {
+      id: "session.next",
+      title: "Next session",
+      category: "Session",
+      keybind: "alt+arrowdown",
+      disabled: !params.dir,
+      onSelect: () => navigateSessionByOffset(1),
+    },
+    {
+      id: "session.archive",
+      title: "Archive session",
+      category: "Session",
+      keybind: "mod+shift+backspace",
+      disabled: !params.dir || !params.id,
+      onSelect: () => {
+        const session = currentSessions().find((s) => s.id === params.id)
+        if (session) archiveSession(session)
+      },
+    },
+  ])
 
   function connectProvider() {
     dialog.replace(() => <DialogSelectProvider />)
@@ -293,22 +425,6 @@ export default function Layout(props: ParentProps) {
                           session.id !== params.id &&
                           globalSync.child(props.project.worktree)[0].session_status[session.id]?.type === "busy",
                       )
-                      async function archive(session: Session) {
-                        await globalSDK.client.session.update({
-                          directory: session.directory,
-                          sessionID: session.id,
-                          time: { archived: Date.now() },
-                        })
-                        setStore(
-                          produce((draft) => {
-                            const match = Binary.search(draft.session, session.id, (s) => s.id)
-                            if (match.found) draft.session.splice(match.index, 1)
-                          }),
-                        )
-                        if (session.id === params.id) {
-                          navigate(`/${params.dir}/session`)
-                        }
-                      }
                       return (
                         <div
                           class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
@@ -363,7 +479,7 @@ export default function Layout(props: ParentProps) {
                           <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
                             {/* <IconButton icon="dot-grid" variant="ghost" /> */}
                             <Tooltip placement="right" value="Archive session">
-                              <IconButton icon="archive" variant="ghost" onClick={() => archive(session)} />
+                              <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(session)} />
                             </Tooltip>
                           </div>
                         </div>
-- 
cgit v1.2.3


From df2713a6c263a006539efad84e64103caee2d3f5 Mon Sep 17 00:00:00 2001
From: Adam <2363879+adamdotdevin@users.noreply.github.com>
Date: Mon, 15 Dec 2025 07:14:03 -0600
Subject: chore: cleanup

---
 packages/desktop/src/components/prompt-input.tsx | 21 ++++++---------------
 packages/desktop/src/context/command.tsx         | 17 -----------------
 packages/desktop/src/pages/session.tsx           | 19 +++----------------
 packages/ui/src/components/session-turn.tsx      |  1 -
 4 files changed, 9 insertions(+), 49 deletions(-)

(limited to 'packages/desktop/src/context/command.tsx')

diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 87f91104c..9be09507a 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -77,7 +77,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   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))
@@ -183,10 +182,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   onMount(() => {
-    editorRef?.addEventListener("paste", handlePaste)
+    editorRef.addEventListener("paste", handlePaste)
   })
   onCleanup(() => {
-    editorRef?.removeEventListener("paste", handlePaste)
+    editorRef.removeEventListener("paste", handlePaste)
   })
 
   createEffect(() => {
@@ -208,7 +207,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     onSelect: handleFileSelect,
   })
 
-  // Get slash commands from registered commands (only those with explicit slash trigger)
   const slashCommands = createMemo<SlashCommand[]>(() => {
     const builtin = command.options
       .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
@@ -237,12 +235,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setStore("popover", null)
 
     if (cmd.type === "custom") {
-      // For custom commands, insert the command text so user can add arguments
       const text = `/${cmd.trigger} `
       editorRef.innerHTML = ""
       editorRef.textContent = text
       prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
-      // Set cursor at end
       requestAnimationFrame(() => {
         editorRef.focus()
         const range = document.createRange()
@@ -255,7 +251,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       return
     }
 
-    // For built-in commands, clear input and execute immediately
     editorRef.innerHTML = ""
     prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
     command.trigger(cmd.id, "slash")
@@ -287,7 +282,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         }
 
         editorRef.innerHTML = ""
-        currentParts.forEach((part: ContentPart) => {
+        currentParts.forEach((part) => {
           if (part.type === "text") {
             editorRef.appendChild(document.createTextNode(part.content))
           } else if (part.type === "file") {
@@ -374,7 +369,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     const cursorPosition = getCursorPosition(editorRef)
     const currentPrompt = prompt.current()
-    const rawText = currentPrompt.map((p: ContentPart) => p.content).join("")
+    const rawText = currentPrompt.map((p) => p.content).join("")
     const textBeforeCursor = rawText.substring(0, cursorPosition)
     const atMatch = textBeforeCursor.match(/@(\S*)$/)
 
@@ -498,7 +493,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const handleKeyDown = (event: KeyboardEvent) => {
-    // Handle popover navigation
     if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
       if (store.popover === "file") {
         onKeyDown(event)
@@ -510,7 +504,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
 
     if (event.key === "ArrowUp" || event.key === "ArrowDown") {
-      // Skip history navigation when modifier keys are pressed (used for other commands)
       if (event.altKey || event.ctrlKey || event.metaKey) return
       const { collapsed, onFirstLine, onLastLine } = getCaretLineState()
       if (!collapsed) return
@@ -554,7 +547,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const handleSubmit = async (event: Event) => {
     event.preventDefault()
     const currentPrompt = prompt.current()
-    const text = currentPrompt.map((part: ContentPart) => part.content).join("")
+    const text = currentPrompt.map((part) => part.content).join("")
     if (text.trim().length === 0) {
       if (working()) abort()
       return
@@ -574,7 +567,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
     const attachments = currentPrompt.filter(
-      (part: ContentPart) => part.type === "file",
+      (part) => part.type === "file",
     ) as import("@/context/prompt").FileAttachmentPart[]
 
     const attachmentParts = attachments.map((attachment) => {
@@ -603,7 +596,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     editorRef.innerHTML = ""
     prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
 
-    // Check if this is a custom command
     if (text.startsWith("/")) {
       const [cmdName, ...args] = text.split(" ")
       const commandName = cmdName.slice(1) // Remove leading "/"
@@ -639,7 +631,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
-      {/* Popover for file mentions and slash commands */}
       <Show when={store.popover}>
         <div
           class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx
index dcc4b6007..d4ef8e166 100644
--- a/packages/desktop/src/context/command.tsx
+++ b/packages/desktop/src/context/command.tsx
@@ -6,12 +6,6 @@ import { List } from "@opencode-ai/ui/list"
 
 const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
 
-/**
- * Keybind configuration type.
- * Format: "mod+key" where mod can be ctrl, alt, shift, meta (or cmd on mac)
- * Multiple keybinds can be separated by comma: "mod+p,ctrl+shift+p"
- * Use "mod" for platform-appropriate modifier (cmd on mac, ctrl elsewhere)
- */
 export type KeybindConfig = string
 
 export interface Keybind {
@@ -23,23 +17,14 @@ export interface Keybind {
 }
 
 export interface CommandOption {
-  /** Unique identifier for the command */
   id: string
-  /** Display title in the command palette */
   title: string
-  /** Optional description */
   description?: string
-  /** Category for grouping in the palette */
   category?: string
-  /** Keybind string (e.g., "mod+p", "ctrl+shift+t") */
   keybind?: KeybindConfig
-  /** Slash command trigger (e.g., "models" for /models) */
   slash?: string
-  /** Whether to show in the "Suggested" section */
   suggested?: boolean
-  /** Whether the command is disabled */
   disabled?: boolean
-  /** Handler when command is selected */
   onSelect?: (source?: "palette" | "keybind" | "slash") => void
 }
 
@@ -197,7 +182,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     const handleKeyDown = (event: KeyboardEvent) => {
       if (suspended()) return
 
-      // Check for command palette keybind (mod+shift+p)
       const paletteKeybinds = parseKeybind("mod+shift+p")
       if (matchKeybind(paletteKeybinds, event)) {
         event.preventDefault()
@@ -205,7 +189,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
         return
       }
 
-      // Check registered command keybinds
       for (const option of options()) {
         if (option.disabled) continue
         if (!option.keybind) continue
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index d49779587..9e743e48f 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -49,7 +49,6 @@ export default function Page() {
   const params = useParams()
   const navigate = useNavigate()
 
-  // Session-specific derived state
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey()))
 
@@ -132,7 +131,6 @@ export default function Page() {
     }
   })
 
-  // Register commands for this page
   command.register(() => [
     {
       id: "session.new",
@@ -230,28 +228,17 @@ export default function Page() {
     },
   ])
 
-  // Handle keyboard events that aren't commands
   const handleKeyDown = (event: KeyboardEvent) => {
-    // Don't interfere with terminal
     // @ts-expect-error
-    if (document.activeElement?.dataset?.component === "terminal") {
-      return
-    }
-
-    // Don't interfere with dialogs
-    if (dialog.stack.length > 0) {
-      return
-    }
+    if (document.activeElement?.dataset?.component === "terminal") return
+    if (dialog.stack.length > 0) return
 
     const focused = document.activeElement === inputRef
     if (focused) {
-      if (event.key === "Escape") {
-        inputRef?.blur()
-      }
+      if (event.key === "Escape") inputRef?.blur()
       return
     }
 
-    // Focus input when typing characters
     if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
       inputRef?.focus()
     }
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 807092d03..f905abbd1 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -228,7 +228,6 @@ export function SessionTurn(
                 duration: duration(),
               })
 
-              // Sync with controlled prop
               createEffect(() => {
                 if (props.stepsExpanded !== undefined) {
                   setStore("stepsExpanded", props.stepsExpanded)
-- 
cgit v1.2.3


From 112c58abf516e4523fb698b868a710c40b271559 Mon Sep 17 00:00:00 2001
From: Dax Raad <d@ironbay.co>
Date: Mon, 15 Dec 2025 19:33:17 -0500
Subject: tui: refactor dialog system to use single active dialog instead of
 stack

---
 .../src/components/dialog-connect-provider.tsx     |  22 ++--
 .../desktop/src/components/dialog-select-file.tsx  |   2 +-
 .../src/components/dialog-select-model-unpaid.tsx  |   6 +-
 .../desktop/src/components/dialog-select-model.tsx |   6 +-
 .../src/components/dialog-select-provider.tsx      |   2 +-
 packages/desktop/src/components/prompt-input.tsx   |   4 +-
 packages/desktop/src/context/command.tsx           |   6 +-
 packages/desktop/src/context/notification.tsx      |   2 +-
 packages/desktop/src/pages/layout.tsx              |   2 +-
 packages/desktop/src/pages/session.tsx             |   8 +-
 packages/ui/src/context/dialog.tsx                 | 122 +++++++++------------
 11 files changed, 78 insertions(+), 104 deletions(-)

(limited to 'packages/desktop/src/context/command.tsx')

diff --git a/packages/desktop/src/components/dialog-connect-provider.tsx b/packages/desktop/src/components/dialog-connect-provider.tsx
index 4660e1398..0d6737815 100644
--- a/packages/desktop/src/components/dialog-connect-provider.tsx
+++ b/packages/desktop/src/components/dialog-connect-provider.tsx
@@ -108,20 +108,18 @@ export function DialogConnectProvider(props: { provider: string }) {
 
   async function complete() {
     await globalSDK.client.global.dispose()
-    setTimeout(() => {
-      showToast({
-        variant: "success",
-        icon: "circle-check",
-        title: `${provider().name} connected`,
-        description: `${provider().name} models are now available to use.`,
-      })
-      dialog.replace(() => <DialogSelectModel provider={props.provider} />)
-    }, 1000)
+    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.replace(() => <DialogSelectProvider />)
+      dialog.show(() => <DialogSelectProvider />)
       return
     }
     if (store.authorization) {
@@ -133,7 +131,7 @@ export function DialogConnectProvider(props: { provider: string }) {
       setStore("methodIndex", undefined)
       return
     }
-    dialog.replace(() => <DialogSelectProvider />)
+    dialog.show(() => <DialogSelectProvider />)
   }
 
   return (
@@ -352,7 +350,7 @@ export function DialogConnectProvider(props: { provider: string }) {
                       })
                       if (result.error) {
                         // TODO: show error
-                        dialog.clear()
+                        dialog.close()
                         return
                       }
                       await complete()
diff --git a/packages/desktop/src/components/dialog-select-file.tsx b/packages/desktop/src/components/dialog-select-file.tsx
index b719e15d2..61c518719 100644
--- a/packages/desktop/src/components/dialog-select-file.tsx
+++ b/packages/desktop/src/components/dialog-select-file.tsx
@@ -27,7 +27,7 @@ export function DialogSelectFile() {
           if (path) {
             tabs().open("file://" + path)
           }
-          dialog.clear()
+          dialog.close()
         }}
       >
         {(i) => (
diff --git a/packages/desktop/src/components/dialog-select-model-unpaid.tsx b/packages/desktop/src/components/dialog-select-model-unpaid.tsx
index 7cdb24915..77e493d3c 100644
--- a/packages/desktop/src/components/dialog-select-model-unpaid.tsx
+++ b/packages/desktop/src/components/dialog-select-model-unpaid.tsx
@@ -42,7 +42,7 @@ export const DialogSelectModelUnpaid: Component = () => {
             local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
               recent: true,
             })
-            dialog.clear()
+            dialog.close()
           }}
         >
           {(i) => (
@@ -75,7 +75,7 @@ export const DialogSelectModelUnpaid: Component = () => {
                 }}
                 onSelect={(x) => {
                   if (!x) return
-                  dialog.replace(() => <DialogConnectProvider provider={x.id} />)
+                  dialog.show(() => <DialogConnectProvider provider={x.id} />)
                 }}
               >
                 {(i) => (
@@ -105,7 +105,7 @@ export const DialogSelectModelUnpaid: Component = () => {
                 class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
                 icon="dot-grid"
                 onClick={() => {
-                  dialog.replace(() => <DialogSelectProvider />)
+                  dialog.show(() => <DialogSelectProvider />)
                 }}
               >
                 View all providers
diff --git a/packages/desktop/src/components/dialog-select-model.tsx b/packages/desktop/src/components/dialog-select-model.tsx
index f0b2e6db9..622ab15fb 100644
--- a/packages/desktop/src/components/dialog-select-model.tsx
+++ b/packages/desktop/src/components/dialog-select-model.tsx
@@ -28,7 +28,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
           class="h-7 -my-1 text-14-medium"
           icon="plus-small"
           tabIndex={-1}
-          onClick={() => dialog.replace(() => <DialogSelectProvider />)}
+          onClick={() => dialog.show(() => <DialogSelectProvider />)}
         >
           Connect provider
         </Button>
@@ -57,7 +57,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
           local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
             recent: true,
           })
-          dialog.clear()
+          dialog.close()
         }}
       >
         {(i) => (
@@ -75,7 +75,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
       <Button
         variant="ghost"
         class="ml-3 mt-5 mb-6 text-text-base self-start"
-        onClick={() => dialog.replace(() => <DialogManageModels />)}
+        onClick={() => dialog.show(() => <DialogManageModels />)}
       >
         Manage models
       </Button>
diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx
index 8da10b1d5..52fac7073 100644
--- a/packages/desktop/src/components/dialog-select-provider.tsx
+++ b/packages/desktop/src/components/dialog-select-provider.tsx
@@ -34,7 +34,7 @@ export const DialogSelectProvider: Component = () => {
         }}
         onSelect={(x) => {
           if (!x) return
-          dialog.replace(() => <DialogConnectProvider provider={x.id} />)
+          dialog.show(() => <DialogConnectProvider provider={x.id} />)
         }}
       >
         {(i) => (
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index f2821c3c7..6e147242d 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -864,9 +864,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               as="div"
               variant="ghost"
               onClick={() =>
-                dialog.replace(() =>
-                  providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
-                )
+                dialog.show(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
               }
             >
               {local.model.current()?.name ?? "Select model"}
diff --git a/packages/desktop/src/context/command.tsx b/packages/desktop/src/context/command.tsx
index d4ef8e166..8fd76ee21 100644
--- a/packages/desktop/src/context/command.tsx
+++ b/packages/desktop/src/context/command.tsx
@@ -128,7 +128,7 @@ function DialogCommand(props: { options: CommandOption[] }) {
         groupBy={(x) => x.category ?? ""}
         onSelect={(option) => {
           if (option) {
-            dialog.clear()
+            dialog.close()
             option.onSelect?.("palette")
           }
         }}
@@ -174,8 +174,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
     const suspended = () => suspendCount() > 0
 
     const showPalette = () => {
-      if (dialog.stack.length === 0) {
-        dialog.replace(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
+      if (!dialog.active) {
+        dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
       }
     }
 
diff --git a/packages/desktop/src/context/notification.tsx b/packages/desktop/src/context/notification.tsx
index 045361630..ee15bc34a 100644
--- a/packages/desktop/src/context/notification.tsx
+++ b/packages/desktop/src/context/notification.tsx
@@ -59,7 +59,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
         time: Date.now(),
         viewed: false,
       }
-      switch (event?.type) {
+      switch (event.type) {
         case "session.idle": {
           const sessionID = event.properties.sessionID
           const [syncStore] = globalSync.child(directory)
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 2f0c6e050..27852967a 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/packages/desktop/src/pages/layout.tsx
@@ -217,7 +217,7 @@ export default function Layout(props: ParentProps) {
   ])
 
   function connectProvider() {
-    dialog.replace(() => <DialogSelectProvider />)
+    dialog.show(() => <DialogSelectProvider />)
   }
 
   function navigateToProject(directory: string | undefined) {
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 9167ef7b7..390872d36 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/packages/desktop/src/pages/session.tsx
@@ -176,7 +176,7 @@ export default function Page() {
       category: "File",
       keybind: "mod+p",
       slash: "open",
-      onSelect: () => dialog.replace(() => <DialogSelectFile />),
+      onSelect: () => dialog.show(() => <DialogSelectFile />),
     },
     // {
     //   id: "theme.toggle",
@@ -245,7 +245,7 @@ export default function Page() {
       category: "Model",
       keybind: "mod+'",
       slash: "model",
-      onSelect: () => dialog.replace(() => <DialogSelectModel />),
+      onSelect: () => dialog.show(() => <DialogSelectModel />),
     },
     {
       id: "agent.cycle",
@@ -320,7 +320,7 @@ export default function Page() {
 
   const handleKeyDown = (event: KeyboardEvent) => {
     if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
-    if (dialog.stack.length > 0) return
+    if (dialog.active) return
 
     if (event.key === "PageUp" || event.key === "PageDown") {
       const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement
@@ -613,7 +613,7 @@ export default function Page() {
                       icon="plus-small"
                       variant="ghost"
                       iconSize="large"
-                      onClick={() => dialog.replace(() => <DialogSelectFile />)}
+                      onClick={() => dialog.show(() => <DialogSelectFile />)}
                     />
                   </Tooltip>
                 </div>
diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx
index b15d96991..71fc63806 100644
--- a/packages/ui/src/context/dialog.tsx
+++ b/packages/ui/src/context/dialog.tsx
@@ -1,9 +1,7 @@
 import {
   createContext,
   createEffect,
-  createMemo,
   createSignal,
-  For,
   getOwner,
   Owner,
   ParentProps,
@@ -13,73 +11,58 @@ import {
   type JSX,
 } from "solid-js"
 import { Dialog as Kobalte } from "@kobalte/core/dialog"
-import { iife } from "@opencode-ai/util/iife"
 
 type DialogElement = () => JSX.Element
 
 const Context = createContext<ReturnType<typeof init>>()
 
 function init() {
-  const [store, setStore] = createSignal<
-    {
-      id: string
-      element: DialogElement
-      onClose?: () => void
-      owner: Owner
-    }[]
-  >([])
+  const [active, setActive] = createSignal<
+    | {
+        id: string
+        element: DialogElement
+        onClose?: () => void
+        owner: Owner
+      }
+    | undefined
+  >()
 
   const result = {
-    get stack() {
-      return store()
-    },
-    pop() {
-      const current = store().at(-1)
-      if (!current) return
-      current?.onClose?.()
-      setStore((stack) => {
-        stack.pop()
-        return [...stack]
-      })
+    get active() {
+      return active()
     },
-    replace(element: DialogElement, owner: Owner, onClose?: () => void) {
-      for (const item of store()) {
-        item.onClose?.()
-      }
-      const id = Math.random().toString(36)
-      setStore([
-        {
-          id,
-          element: () =>
-            runWithOwner(owner, () => (
-              <Show when={result.stack.at(-1)?.id === id}>
-                <Kobalte
-                  modal
-                  defaultOpen
-                  onOpenChange={(open) => {
-                    if (!open) {
-                      onClose?.()
-                      result.pop()
-                    }
-                  }}
-                >
-                  <Kobalte.Portal>
-                    <Kobalte.Overlay data-component="dialog-overlay" />
-                    {element()}
-                  </Kobalte.Portal>
-                </Kobalte>
-              </Show>
-            )),
-          onClose,
-          owner,
-        },
-      ])
+    close() {
+      active()?.onClose?.()
+      setActive(undefined)
     },
-    clear() {
-      for (const item of store()) {
-        item.onClose?.()
-      }
-      setStore([])
+    show(element: DialogElement, owner: Owner, onClose?: () => void) {
+      active()?.onClose?.()
+      const id = Math.random().toString(36).slice(2)
+      setActive({
+        id,
+        element: () =>
+          runWithOwner(owner, () => (
+            <Show when={active()?.id === id}>
+              <Kobalte
+                modal
+                open={true}
+                onOpenChange={(open) => {
+                  if (!open) {
+                    console.log("closing")
+                    result.close()
+                  }
+                }}
+              >
+                <Kobalte.Portal>
+                  <Kobalte.Overlay data-component="dialog-overlay" />
+                  {element()}
+                </Kobalte.Portal>
+              </Kobalte>
+            </Show>
+          )),
+        onClose,
+        owner,
+      })
     },
   }
 
@@ -89,14 +72,12 @@ function init() {
 export function DialogProvider(props: ParentProps) {
   const ctx = init()
   createEffect(() => {
-    console.log("store", ctx.stack.length)
+    console.log("active", ctx.active)
   })
   return (
     <Context.Provider value={ctx}>
       {props.children}
-      <div data-component="dialog-stack">
-        <For each={ctx.stack}>{(item) => <>{item.element()}</>}</For>
-      </div>
+      <div data-component="dialog-stack">{ctx.active?.element?.()}</div>
     </Context.Provider>
   )
 }
@@ -111,17 +92,14 @@ export function useDialog() {
     throw new Error("useDialog must be used within a DialogProvider")
   }
   return {
-    get stack() {
-      return ctx.stack
-    },
-    replace(element: DialogElement, onClose?: () => void) {
-      ctx.replace(element, owner, onClose)
+    get active() {
+      return ctx.active
     },
-    pop() {
-      ctx.pop()
+    show(element: DialogElement, onClose?: () => void) {
+      ctx.show(element, owner, onClose)
     },
-    clear() {
-      ctx.clear()
+    close() {
+      ctx.close()
     },
   }
 }
-- 
cgit v1.2.3

