@@ -209,13 +249,8 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
- onClick={() =>
- props.onFileClick?.({
- ...row().node,
- path: row().node.originalPath,
- absolute: row().node.originalPath,
- })
- }
+ onClick={() => selectFile(row().node, props.onFileClick)}
+ onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
0}>
@@ -239,17 +274,13 @@ export default function FileTreeV2(props: {
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
- aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
- onClick={() =>
- file.tree.state(row().node.path)?.expanded === false
- ? file.tree.expand(row().node.path, { list: false })
- : file.tree.collapse(row().node.path)
- }
+ aria-expanded={expanded(row().node.path)}
+ onClick={() => toggleDirectory(row().node.path, row().node.originalPath)}
>
diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx
index dfc978d432..1747602a54 100644
--- a/packages/app/src/components/help-button.tsx
+++ b/packages/app/src/components/help-button.tsx
@@ -24,8 +24,6 @@ const triggerClass =
const showPopover = () => true
export function HelpButton() {
- if (import.meta.env.VITE_CODEV_CHANNEL !== "dev") return null
-
const platform = usePlatform()
return (
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 45310b7fa7..ebc0148042 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -19,6 +19,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/f
import {
ContentPart,
DEFAULT_PROMPT,
+ isCommentItem,
isPromptEqual,
Prompt,
usePrompt,
@@ -36,6 +37,8 @@ import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
@@ -631,7 +634,9 @@ export const PromptInput: Component
= (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => {
- savedCursor = currentCursor()
+ const cursor = currentCursor()
+ savedCursor = cursor
+ if (cursor !== null && cursor !== prompt.cursor()) prompt.set(prompt.current(), cursor)
closePopover()
setComposing(false)
}
@@ -1319,6 +1324,7 @@ export const PromptInput: Component = (props) => {
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
+ model: props.controls.model.selection,
})
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1609,7 +1615,7 @@ export const PromptInput: Component = (props) => {
)}
/>
!isCommentItem(item))}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@@ -1619,6 +1625,7 @@ export const PromptInput: Component = (props) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
+ newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters[0])}
/>
= (props) => {
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
+ newLayoutDesigns={props.controls.newLayoutDesigns}
+ comments={contextItems().filter(isCommentItem)}
+ commentActive={(item) => {
+ const active = comments.active()
+ return !!item.commentID && item.commentID === active?.id && item.path === active?.file
+ }}
+ onOpenComment={openComment}
+ onRemoveComment={(item) => {
+ if (item.commentID) comments.remove(item.path, item.commentID)
+ prompt.context.remove(item.key)
+ }}
/>
= (props) => {
>
}
+ variant="ghost-muted"
+ size="large"
style={buttons()}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.menu.addImagesAndFiles")}
/>
-
)
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts
index 9a65fa101d..bce06b4347 100644
--- a/packages/app/src/components/prompt-input/submit.test.ts
+++ b/packages/app/src/components/prompt-input/submit.test.ts
@@ -1,11 +1,12 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
+import type { ModelSelection } from "@/context/local"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
-const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
+const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{
directory?: string
sessionID?: string
@@ -26,6 +27,8 @@ let params: { id?: string } = {}
let search: { draftId?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
+let permissionServer = "server-a"
+let createSessionGate: Promise | undefined
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const prompt = {
@@ -33,6 +36,10 @@ const prompt = {
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
+ model: {
+ current: () => undefined,
+ set: () => undefined,
+ },
reset: () => undefined,
set: () => undefined,
context: {
@@ -51,6 +58,7 @@ const clientFor = (directory: string) => {
return {
session: {
create: async () => {
+ await createSessionGate
createdSessions.push(directory)
return {
data: {
@@ -117,13 +125,14 @@ beforeAll(async () => {
}),
}))
- mock.module("@/context/permission", () => ({
- usePermission: () => ({
+ mock.module("@/context/permission", () => {
+ const state = (server: string) => ({
enableAutoAccept(sessionID: string, directory: string) {
- enabledAutoAccept.push({ sessionID, directory })
+ enabledAutoAccept.push({ server, sessionID, directory })
},
- }),
- }))
+ })
+ return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
+ })
mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }),
@@ -246,6 +255,8 @@ beforeEach(() => {
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
+ permissionServer = "server-a"
+ createSessionGate = undefined
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
@@ -313,7 +324,40 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
- expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
+ expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
+ })
+
+ test("keeps auto-accept bound to the submission server", async () => {
+ let release = () => {}
+ createSessionGate = new Promise((resolve) => {
+ release = resolve
+ })
+ const submit = createPromptSubmit({
+ prompt,
+ info: () => undefined,
+ imageAttachments: () => [],
+ commentCount: () => 0,
+ autoAccept: () => true,
+ mode: () => "shell",
+ working: () => false,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ newSessionWorktree: () => selected,
+ onNewSessionWorktreeReset: () => undefined,
+ onSubmit: () => undefined,
+ })
+
+ const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ permissionServer = "server-b"
+ release()
+ await result
+
+ expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("promotes drafts using the selected project's server", async () => {
@@ -378,6 +422,39 @@ describe("prompt submit worktree selection", () => {
})
})
+ test("uses an injected model selection", async () => {
+ params = { id: "session-1" }
+ const model = {
+ current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
+ variant: { current: () => "draft-variant" },
+ } as unknown as ModelSelection
+ const submit = createPromptSubmit({
+ prompt,
+ info: () => ({ id: "session-1" }),
+ imageAttachments: () => [],
+ commentCount: () => 0,
+ autoAccept: () => false,
+ mode: () => "normal",
+ working: () => false,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ model,
+ })
+
+ await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+
+ expect(optimistic[0]).toMatchObject({
+ message: {
+ model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
+ },
+ })
+ })
+
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 05d978a947..203722fc6c 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -3,12 +3,12 @@ import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
-import { batch, type Accessor } from "solid-js"
+import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
-import { useLocal } from "@/context/local"
+import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
import { useSDK, type DirectorySDK } from "@/context/sdk"
@@ -191,6 +191,7 @@ type PromptSubmitInput = {
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
+ model?: ModelSelection
}
export function createPromptSubmit(input: PromptSubmitInput) {
@@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
- const currentModel = local.model.current()
+ const modelSelection = input.model ?? local.model
+ const currentModel = modelSelection.current()
const currentAgent = local.agent.current()
- const variant = local.model.variant.current()
+ const variant = modelSelection.variant.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -313,6 +315,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation()
const projectDirectory = sdk().directory
+ const permissionState = permission.currentServerState()
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
@@ -374,13 +377,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (created) {
seed(sessionDirectory, created)
session = created
- if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
- local.session.promote(sessionDirectory, session.id)
- layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
- const draftID = search.draftId
- if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
- else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
- submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
+ await startTransition(() => {
+ if (!session) return
+ if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
+ local.session.promote(sessionDirectory, session.id, {
+ agent: currentAgent.name,
+ model: { providerID: currentModel.provider.id, modelID: currentModel.id },
+ variant: variant ?? null,
+ })
+ layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
+ const draftID = search.draftId
+ if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
+ else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
+ submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
+ })
}
}
if (!session) {
diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx
index 312e02df12..45d0371fda 100644
--- a/packages/app/src/components/prompt-project-selector.tsx
+++ b/packages/app/src/components/prompt-project-selector.tsx
@@ -1,4 +1,13 @@
-import { createEffect, For, onCleanup, Show, splitProps, type Accessor, type ComponentProps } from "solid-js"
+import {
+ createEffect,
+ createSignal,
+ For,
+ onCleanup,
+ Show,
+ splitProps,
+ type Accessor,
+ type ComponentProps,
+} from "solid-js"
import { createStore } from "solid-js/store"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
@@ -184,9 +193,28 @@ export function PromptProjectSelector(props: {
controller: PromptProjectController
placement?: "bottom" | "bottom-start"
}) {
+ const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined
+ let triggerFrame: number | undefined
let restoreTrigger = true
+ // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
+ const setTriggerRef = (element: HTMLButtonElement) => {
+ const ready = () => {
+ if (!element.isConnected) {
+ triggerFrame = requestAnimationFrame(ready)
+ return
+ }
+ triggerFrame = undefined
+ setTriggerReady(true)
+ }
+ ready()
+ }
+
+ onCleanup(() => {
+ if (triggerFrame !== undefined) cancelAnimationFrame(triggerFrame)
+ })
+
const activeItem = () =>
props.controller.active()
? contentRef?.querySelector(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
@@ -257,13 +285,13 @@ export function PromptProjectSelector(props: {
return (
props.controller.setOpen(open)}
>
-
+
(
<>
/
-
-
- {branch()}
-
+
+
+
+ {branch()}
+
+
>
)}
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index fd2d1dd1a3..c1e0ccde05 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -12,7 +12,7 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
-import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
+import { getSessionContext } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSettings } from "@/context/settings"
@@ -74,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
)
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
- const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => {
return usd().format(info()?.cost ?? 0)
})
@@ -132,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
)
diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts
index 8e424f0f36..9e44cea17b 100644
--- a/packages/app/src/components/session/index.ts
+++ b/packages/app/src/components/session/index.ts
@@ -1,6 +1,7 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
+export { SortableTabV2 } from "./session-sortable-tab-v2"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
diff --git a/packages/app/src/components/session/open-in-app-v2.tsx b/packages/app/src/components/session/open-in-app-v2.tsx
new file mode 100644
index 0000000000..e26ff2e0ea
--- /dev/null
+++ b/packages/app/src/components/session/open-in-app-v2.tsx
@@ -0,0 +1,98 @@
+import { For, Show } from "solid-js"
+import { AppIcon } from "@opencode-ai/ui/app-icon"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { useLanguage } from "@/context/language"
+import { type OpenApp, useOpenInApp } from "@/components/session/open-in-app"
+
+export function OpenInAppV2(props: { directory: () => string }) {
+ const language = useLanguage()
+ const state = useOpenInApp(props)
+
+ return (
+
+ event.stopPropagation()}>
+
+ event.stopPropagation()}
+ onClick={(event) => {
+ event.stopPropagation()
+ if (state.opening()) return
+ state.openDir(state.current().id)
+ }}
+ disabled={state.opening()}
+ aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })}
+ >
+ }>
+
+
+
+
+ state.setMenu("open", open)}
+ >
+ event.stopPropagation()}
+ >
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/components/session/open-in-app.tsx b/packages/app/src/components/session/open-in-app.tsx
new file mode 100644
index 0000000000..cd363d00c4
--- /dev/null
+++ b/packages/app/src/components/session/open-in-app.tsx
@@ -0,0 +1,229 @@
+import { createEffect, createMemo } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { useServer } from "@/context/server"
+import { Persist, persisted } from "@/utils/persist"
+import { showToast } from "@/utils/toast"
+
+export const OPEN_APPS = [
+ "vscode",
+ "cursor",
+ "zed",
+ "textmate",
+ "antigravity",
+ "finder",
+ "terminal",
+ "iterm2",
+ "ghostty",
+ "warp",
+ "xcode",
+ "android-studio",
+ "powershell",
+ "sublime-text",
+] as const
+
+export type OpenApp = (typeof OPEN_APPS)[number]
+export type OpenAppOS = "macos" | "windows" | "linux" | "unknown"
+
+export const MAC_OPEN_APPS = [
+ {
+ id: "vscode",
+ label: "session.header.open.app.vscode",
+ icon: "vscode",
+ openWith: "Visual Studio Code",
+ },
+ { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
+ { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
+ { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
+ {
+ id: "antigravity",
+ label: "session.header.open.app.antigravity",
+ icon: "antigravity",
+ openWith: "Antigravity",
+ },
+ { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
+ { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
+ { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
+ { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
+ { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
+ {
+ id: "android-studio",
+ label: "session.header.open.app.androidStudio",
+ icon: "android-studio",
+ openWith: "Android Studio",
+ },
+ {
+ id: "sublime-text",
+ label: "session.header.open.app.sublimeText",
+ icon: "sublime-text",
+ openWith: "Sublime Text",
+ },
+] as const
+
+export const WINDOWS_OPEN_APPS = [
+ { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
+ { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
+ { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
+ {
+ id: "powershell",
+ label: "session.header.open.app.powershell",
+ icon: "powershell",
+ openWith: "powershell",
+ },
+ {
+ id: "sublime-text",
+ label: "session.header.open.app.sublimeText",
+ icon: "sublime-text",
+ openWith: "Sublime Text",
+ },
+] as const
+
+export const LINUX_OPEN_APPS = [
+ { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
+ { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
+ { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
+ {
+ id: "sublime-text",
+ label: "session.header.open.app.sublimeText",
+ icon: "sublime-text",
+ openWith: "Sublime Text",
+ },
+] as const
+
+export function detectOpenAppOS(platform: ReturnType
): OpenAppOS {
+ if (platform.platform === "desktop" && platform.os) return platform.os
+ if (typeof navigator !== "object") return "unknown"
+ const value = navigator.platform || navigator.userAgent
+ if (/Mac/i.test(value)) return "macos"
+ if (/Win/i.test(value)) return "windows"
+ if (/Linux/i.test(value)) return "linux"
+ return "unknown"
+}
+
+export function openAppFileManager(os: OpenAppOS) {
+ if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
+ if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
+ return { label: "session.header.open.fileManager", icon: "finder" as const }
+}
+
+export function openAppsForOS(os: OpenAppOS) {
+ if (os === "macos") return MAC_OPEN_APPS
+ if (os === "windows") return WINDOWS_OPEN_APPS
+ return LINUX_OPEN_APPS
+}
+
+const showRequestError = (language: ReturnType, err: unknown) => {
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ })
+}
+
+export function useOpenInApp(input: { directory: () => string }) {
+ const platform = usePlatform()
+ const server = useServer()
+ const language = useLanguage()
+
+ const os = createMemo(() => detectOpenAppOS(platform))
+ const apps = createMemo(() => openAppsForOS(os()))
+ const fileManager = createMemo(() => openAppFileManager(os()))
+
+ const [exists, setExists] = createStore>>({
+ finder: true,
+ })
+
+ createEffect(() => {
+ if (platform.platform !== "desktop") return
+ if (!platform.checkAppExists) return
+
+ const list = apps()
+
+ setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>)
+
+ void Promise.all(
+ list.map((app) =>
+ Promise.resolve(platform.checkAppExists?.(app.openWith))
+ .then((value) => Boolean(value))
+ .catch(() => false)
+ .then((ok) => [app.id, ok] as const),
+ ),
+ ).then((entries) => {
+ setExists(Object.fromEntries(entries) as Partial>)
+ })
+ })
+
+ const options = createMemo(() => {
+ return [
+ { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
+ ...apps()
+ .filter((app) => exists[app.id])
+ .map((app) => ({ ...app, label: language.t(app.label) })),
+ ] as const
+ })
+
+ const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp | "finder" }))
+ const [menu, setMenu] = createStore({ open: false })
+ const [openRequest, setOpenRequest] = createStore({
+ app: undefined as OpenApp | undefined,
+ })
+
+ const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
+ const current = createMemo(
+ () =>
+ options().find((o) => o.id === prefs.app) ??
+ options()[0] ??
+ ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
+ )
+ const opening = createMemo(() => openRequest.app !== undefined)
+
+ const selectApp = (app: OpenApp | "finder") => {
+ if (!options().some((item) => item.id === app)) return
+ setPrefs("app", app)
+ }
+
+ const openDir = (app: OpenApp | "finder") => {
+ if (opening() || !canOpen() || !platform.openPath) return
+ const directory = input.directory()
+ if (!directory) return
+
+ const item = options().find((o) => o.id === app)
+ const openWith = item && "openWith" in item ? item.openWith : undefined
+ setOpenRequest("app", app)
+ platform
+ .openPath(directory, openWith)
+ .catch((err: unknown) => showRequestError(language, err))
+ .finally(() => {
+ setOpenRequest("app", undefined)
+ })
+ }
+
+ const copyPath = () => {
+ const directory = input.directory()
+ if (!directory) return
+ navigator.clipboard
+ .writeText(directory)
+ .then(() => {
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: language.t("session.share.copy.copied"),
+ description: directory,
+ })
+ })
+ .catch((err: unknown) => showRequestError(language, err))
+ }
+
+ return {
+ canOpen,
+ opening,
+ current,
+ options,
+ menu,
+ setMenu,
+ openDir,
+ selectApp,
+ copyPath,
+ }
+}
diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts
index 5b11bf1433..37cc0d5b5d 100644
--- a/packages/app/src/components/session/session-context-metrics.test.ts
+++ b/packages/app/src/components/session/session-context-metrics.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
-import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
+import { getSessionContext } from "./session-context-metrics"
const assistant = (
id: string,
@@ -38,10 +38,10 @@ const user = (id: string) => {
}
describe("getSessionContext", () => {
- test("computes usage from latest assistant with tokens", () => {
+ test("computes token totals and usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
- assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
+ assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
]
const providers = [
@@ -60,6 +60,8 @@ describe("getSessionContext", () => {
const ctx = getSessionContext(messages, providers)
expect(ctx?.message.id).toBe("a2")
+ expect(ctx?.total).toBe(500)
+ expect(ctx?.input).toBe(300)
expect(ctx?.usage).toBe(50)
expect(ctx?.providerLabel).toBe("OpenAI")
expect(ctx?.modelLabel).toBe("GPT-4.1")
@@ -94,15 +96,4 @@ describe("getSessionContext", () => {
expect(ctx).toBeUndefined()
})
-
- test("computes stored session token totals", () => {
- expect(
- getSessionTokenTotal({
- input: 10,
- output: 20,
- reasoning: 30,
- cache: { read: 40, write: 50 },
- }),
- ).toBe(150)
- })
})
diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts
index 84cc984abf..30dc9e958b 100644
--- a/packages/app/src/components/session/session-context-metrics.ts
+++ b/packages/app/src/components/session/session-context-metrics.ts
@@ -1,4 +1,4 @@
-import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
+import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
type Provider = {
id: string
@@ -21,6 +21,7 @@ type Context = {
modelLabel: string
limit: number | undefined
input: number
+ total: number
usage: number | null
}
@@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
modelLabel: model?.name ?? message.modelID,
limit,
input: message.tokens.input,
+ total,
usage: limit ? Math.round((total / limit) * 100) : null,
}
}
@@ -61,8 +63,3 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context |
export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
-
-export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
- if (!tokens) return undefined
- return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
-}
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 8757c69367..25ad5ab230 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout"
-import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
+import { getSessionContext } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
@@ -135,7 +135,6 @@ export function SessionContextTab() {
)
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
- const tokens = createMemo(() => info()?.tokens)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => {
@@ -204,14 +203,15 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
- { label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
+ { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
- { label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
- { label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
- { label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
+ { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
+ { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
+ { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
{
label: "context.stats.cacheTokens",
- value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
+ value: () =>
+ `${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx
index 6993b8f5a8..a324c64fa6 100644
--- a/packages/app/src/components/session/session-new-design-view.tsx
+++ b/packages/app/src/components/session/session-new-design-view.tsx
@@ -7,7 +7,7 @@ export function NewSessionDesignView(props: { children: JSX.Element }) {
diff --git a/packages/app/src/components/session/session-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx
new file mode 100644
index 0000000000..158eba9f0b
--- /dev/null
+++ b/packages/app/src/components/session/session-sortable-tab-v2.tsx
@@ -0,0 +1,66 @@
+import { createMemo, Show } from "solid-js"
+import type { JSX } from "solid-js"
+import { useSortable } from "@dnd-kit/solid/sortable"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useFile } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useCommand } from "@/context/command"
+import { FileVisual } from "./session-sortable-tab"
+
+export function SortableTabV2(props: {
+ tab: string
+ index: () => number
+ temporary?: boolean
+ onTabClose: (tab: string) => void
+ onTabDoubleClick?: (tab: string) => void
+}): JSX.Element {
+ const file = useFile()
+ const language = useLanguage()
+ const command = useCommand()
+ const sortable = useSortable({
+ get id() {
+ return props.tab
+ },
+ get index() {
+ return props.index()
+ },
+ })
+ const path = createMemo(() => file.pathFromTab(props.tab))
+ const content = createMemo(() => {
+ const value = path()
+ if (!value) return
+ return
+ })
+ return (
+
+
+
+ props.onTabClose(props.tab)}
+ aria-label={language.t("common.closeTab")}
+ />
+
+ }
+ hideCloseButton
+ onMiddleClick={() => props.onTabClose(props.tab)}
+ onDblClick={() => props.onTabDoubleClick?.(props.tab)}
+ >
+ {(value) => value()}
+
+
+
+ )
+}
diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx
index f0cdf41ac1..a27b29668e 100644
--- a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx
+++ b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx
@@ -65,9 +65,12 @@ export function SortableTerminalTabV2(props: {
const focus = () => {
if (store.editing) return
+ terminal.requestFocus(props.terminal.id)
terminal.open(props.terminal.id)
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
focusTerminalById(props.terminal.id)
+ const input = document.getElementById(`terminal-wrapper-${props.terminal.id}`)?.querySelector("textarea")
+ if (input === document.activeElement) terminal.consumeFocus(props.terminal.id)
}
const edit = (e?: Event) => {
@@ -211,7 +214,17 @@ export function SortableTerminalTabV2(props: {
{
+ // Switch on mousedown to shave the press-release delay off tab switches.
+ if (e.button !== 0) return
+ if (store.editing) return
+ focus()
+ }}
+ onClick={(e) => {
+ // Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
+ if (e.detail > 0) return
+ focus()
+ }}
closeButton={
{
triggerVariant: "settings" as const,
})
+ const InterfaceSection = () => (
+
+
+
+ {language.t("settings.general.row.newInterface.title")}
+ {language.t("settings.general.row.newInterface.badge")}
+
+ }
+ description={language.t("settings.general.row.newInterface.description")}
+ >
+
+ {
+ settings.general.setNewLayoutDesigns(checked)
+ if (!checked) return
+ void import("@/components/settings-v2").then((module) => {
+ void dialog.show(() => )
+ })
+ }}
+ />
+
+
+
+
+ )
+
+ const InterfaceNoticeSection = () => (
+
+
+
+
+ {language.t("settings.general.row.newInterfaceNotice.dismiss")}
+
+
+
+
+ )
+
const GeneralSection = () => (
@@ -334,24 +379,6 @@ export const SettingsGeneral: Component = () => {
/>
-
-
-
- {
- settings.general.setNewLayoutDesigns(checked)
- if (!checked) return
- void import("@/components/settings-v2").then((module) => {
- dialog.show(() => )
- })
- }}
- />
-
-
)
@@ -718,6 +745,14 @@ export const SettingsGeneral: Component = () => {