Compare commits

..
Author SHA1 Message Date
Aiden Cline 8b85247c75 docs: update variants docs
test / test (push) Has been cancelled
2025-12-31 01:16:06 -06:00
Aiden Cline 812b1ccb3b Merge branch 'dev' into variants-docs 2025-12-31 01:12:03 -06:00
Aiden Cline 636fa3527f tweak: minimax m2.1 topK and topP to match recommended values 2025-12-30 22:23:30 -06:00
David Hill 00e2ed04e7 fix: cleaner view subagents 2025-12-30 12:03:17 +00:00
170 changed files with 4095 additions and 8535 deletions
+4 -2
View File
@@ -2,9 +2,11 @@ name: test
on: on:
push: push:
branches: branches-ignore:
- dev - production
pull_request: pull_request:
branches-ignore:
- production
workflow_dispatch: workflow_dispatch:
jobs: jobs:
test: test:
+10
View File
@@ -0,0 +1,10 @@
---
description: Use this agent when you are asked to commit and push code changes to a git repository.
mode: subagent
---
You commit and push to git
Commit messages should be brief since they are used to generate release notes.
Messages should say WHY the change was made and not WHAT was changed.
+1 -11
View File
@@ -10,17 +10,7 @@
"options": {}, "options": {},
}, },
}, },
"permission": { "mcp": {},
"bash": {
"ls foo": "ask",
},
},
"mcp": {
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
},
},
"tools": { "tools": {
"github-triage": false, "github-triage": false,
}, },
+9 -2
View File
@@ -1,4 +1,11 @@
## Debugging
- To test opencode in the `packages/opencode` directory you can run `bun dev` - To test opencode in the `packages/opencode` directory you can run `bun dev`
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
## SDK
To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- the default branch in this repo is `dev`
-3
View File
@@ -186,6 +186,3 @@
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) | | 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) | | 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) | | 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
+17 -17
View File
@@ -22,7 +22,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -173,7 +173,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
@@ -201,7 +201,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -230,7 +230,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -246,7 +246,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.0.224", "version": "1.0.218",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -294,7 +294,7 @@
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
"bonjour-service": "1.3.0", "bonjour-service": "1.3.0",
"bun-pty": "0.4.4", "bun-pty": "0.4.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
@@ -348,7 +348,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -368,7 +368,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.0.224", "version": "1.0.218",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.88.1", "@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -379,7 +379,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -392,7 +392,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -430,7 +430,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -441,7 +441,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -2044,7 +2044,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="], "bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="],
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
-4
View File
@@ -1,6 +1,2 @@
[install] [install]
exact = true exact = true
[test]
root = "./do-not-run-tests-from-root"
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1767242400, "lastModified": 1767026758,
"narHash": "sha256-knFaYjeg7swqG1dljj1hOxfg39zrIy8pfGuicjm9s+o=", "narHash": "sha256-7fsac/f7nh/VaKJ/qm3I338+wAJa/3J57cOGpXi0Sbg=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "c04833a1e584401bb63c1a63ddc51a71e6aa457a", "rev": "346dd96ad74dc4457a9db9de4f4f57dab2e5731d",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -1
View File
@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
connected = true connected = true
break break
} catch (e) {} } catch (e) {}
await Bun.sleep(300) await new Promise((resolve) => setTimeout(resolve, 300))
} while (retry++ < 30) } while (retry++ < 30)
if (!connected) { if (!connected) {
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw=" "nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
} }
+1 -2
View File
@@ -10,8 +10,7 @@
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
"hello": "echo 'Hello World!'", "hello": "echo 'Hello World!'"
"test": "echo 'do not run tests from root' && exit 1"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
+23 -8
View File
@@ -1,13 +1,28 @@
## Debugging # Agent Guidelines for @opencode/app
- To test the opencode app, use the playwrite mcp server, the app is already ## Build/Test Commands
running at http://localhost:3000
- NEVER try to restart the app, or the server process, EVER.
## SolidJS - **Development**: `bun run dev` (starts Vite dev server on port 3000)
- **Build**: `bun run build` (production build)
- **Preview**: `bun run serve` (preview production build)
- **Validation**: Use `bun run typecheck` only - do not build or run project for validation
- **Testing**: Do not create or run automated tests
- Always prefer `createStore` over multiple `createSignal` calls ## Code Style
## Tool Calling - **Framework**: SolidJS with TypeScript
- **Imports**: Use `@/` alias for src/ directory (e.g., `import Button from "@/ui/button"`)
- **Formatting**: Prettier configured with semicolons disabled, 120 character line width
- **Components**: Use function declarations, splitProps for component props
- **Types**: Define interfaces for component props, avoid `any` type
- **CSS**: TailwindCSS with custom CSS variables theme system
- **Naming**: PascalCase for components, camelCase for variables/functions, snake_case for file names
- **File Structure**: UI primitives in `/ui/`, higher-level components in `/components/`, pages in `/pages/`, providers in `/providers/`
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. ## Key Dependencies
- SolidJS, @solidjs/router, @kobalte/core (UI primitives)
- TailwindCSS 4.x with @tailwindcss/vite
- Custom theme system with CSS variables
No special rules files found.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.0.224", "version": "1.0.218",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+27 -33
View File
@@ -10,13 +10,11 @@ import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code" import { Code } from "@opencode-ai/ui/code"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync" import { GlobalSyncProvider } from "@/context/global-sync"
import { PermissionProvider } from "@/context/permission"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
import { ServerProvider, useServer } from "@/context/server" import { ServerProvider, useServer } from "@/context/server"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command" import { CommandProvider } from "@/context/command"
@@ -68,38 +66,34 @@ export function App() {
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
<Router <LayoutProvider>
root={(props) => ( <NotificationProvider>
<PermissionProvider> <Router
<LayoutProvider> root={(props) => (
<NotificationProvider> <CommandProvider>
<CommandProvider> <Layout>{props.children}</Layout>
<Layout>{props.children}</Layout> </CommandProvider>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Session />
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)} )}
/> >
</Route> <Route path="/" component={Home} />
</Router> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<PromptProvider>
<Session />
</PromptProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</NotificationProvider>
</LayoutProvider>
</GlobalSyncProvider> </GlobalSyncProvider>
</GlobalSDKProvider> </GlobalSDKProvider>
</ServerKey> </ServerKey>
@@ -6,11 +6,11 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file" import { useLocal } from "@/context/local"
export function DialogSelectFile() { export function DialogSelectFile() {
const layout = useLayout() const layout = useLayout()
const file = useFile() const local = useLocal()
const dialog = useDialog() const dialog = useDialog()
const params = useParams() const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
@@ -20,13 +20,11 @@ export function DialogSelectFile() {
<List <List
search={{ placeholder: "Search files", autofocus: true }} search={{ placeholder: "Search files", autofocus: true }}
emptyMessage="No files found" emptyMessage="No files found"
items={file.searchFiles} items={local.file.searchFiles}
key={(x) => x} key={(x) => x}
onSelect={(path) => { onSelect={(path) => {
if (path) { if (path) {
const value = file.tab(path) tabs().open("file://" + path)
tabs().open(value)
file.load(path)
} }
dialog.close() dialog.close()
}} }}
@@ -1,5 +1,4 @@
import { Popover as Kobalte } from "@kobalte/core/popover" import { Component, createMemo, Show } from "solid-js"
import { Component, createMemo, createSignal, JSX, Show } from "solid-js"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
@@ -10,12 +9,9 @@ import { List } from "@opencode-ai/ui/list"
import { DialogSelectProvider } from "./dialog-select-provider" import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogManageModels } from "./dialog-manage-models" import { DialogManageModels } from "./dialog-manage-models"
const ModelList: Component<{ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
provider?: string
class?: string
onSelect: () => void
}> = (props) => {
const local = useLocal() const local = useLocal()
const dialog = useDialog()
const models = createMemo(() => const models = createMemo(() =>
local.model local.model
@@ -24,70 +20,6 @@ const ModelList: Component<{
.filter((m) => (props.provider ? m.provider.id === props.provider : true)), .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
) )
return (
<List
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
search={{ placeholder: "Search models", autofocus: true }}
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
props.onSelect()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
)
}
export const ModelSelectorPopover: Component<{
provider?: string
children: JSX.Element
}> = (props) => {
const [open, setOpen] = createSignal(false)
return (
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
const dialog = useDialog()
return ( return (
<Dialog <Dialog
title="Select model" title="Select model"
@@ -102,7 +34,43 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
</Button> </Button>
} }
> >
<ModelList provider={props.provider} onSelect={() => dialog.close()} /> <List
search={{ placeholder: "Search models", autofocus: true }}
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-3">
<span>{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
<Button <Button
variant="ghost" variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start" class="ml-3 mt-5 mb-6 text-text-base self-start"
+215
View File
@@ -0,0 +1,215 @@
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLayout } from "@/context/layout"
import { Session } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { Popover } from "@opencode-ai/ui/popover"
import { Select } from "@opencode-ai/ui/select"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Decode } from "@opencode-ai/util/encode"
import { useCommand } from "@/context/command"
import { getFilename } from "@opencode-ai/util/path"
import { A, useParams } from "@solidjs/router"
import { createMemo, createResource, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { iife } from "@opencode-ai/util/iife"
export function Header(props: {
navigateToProject: (directory: string) => void
navigateToSession: (session: Session | undefined) => void
onMobileMenuToggle?: () => void
}) {
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
onClick={props.onMobileMenuToggle}
>
<Icon name="menu" size="small" />
</button>
<A
href="/"
classList={{
"hidden xl:flex": true,
"w-12 shrink-0 px-4 py-3.5": true,
"items-center justify-start self-stretch": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
data-tauri-drag-region
>
<Mark class="shrink-0" />
</A>
<div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
<Show when={layout.projects.list().length > 0 && params.dir}>
{(directory) => {
const currentDirectory = createMemo(() => base64Decode(directory()))
const store = createMemo(() => globalSync.child(currentDirectory())[0])
const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => store().config.share !== "disabled")
return (
<>
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={currentDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={props.navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<Tooltip
class="hidden xl:block"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
New session
</Button>
</Tooltip>
</Show>
</div>
<div class="flex items-center gap-4">
<Show when={currentSession()?.summary?.files}>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle review</span>
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
</Show>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle terminal</span>
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: currentDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
)
return (
<Show when={url()}>
{(url) => <TextField value={url()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</div>
</>
)
}}
</Show>
</div>
</header>
)
}
+126 -367
View File
@@ -3,16 +3,7 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element" import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useFile, type FileSelection } from "@/context/file" import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
import {
ContentPart,
DEFAULT_PROMPT,
isPromptEqual,
Prompt,
usePrompt,
ImageAttachmentPart,
AgentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
@@ -20,19 +11,18 @@ import { useSync } from "@/context/sync"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission"
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -84,14 +74,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const local = useLocal() const local = useLocal()
const files = useFile()
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
const params = useParams() const params = useParams()
const dialog = useDialog() const dialog = useDialog()
const providers = useProviders() const providers = useProviders()
const command = useCommand() const command = useCommand()
const permission = usePermission()
let editorRef!: HTMLDivElement let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement let fileInputRef!: HTMLInputElement
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
@@ -128,11 +116,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
const activeFile = createMemo(() => {
const tab = tabs().active()
if (!tab) return
return files.pathFromTab(tab)
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const status = createMemo( const status = createMemo(
() => () =>
@@ -143,7 +126,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const working = createMemo(() => status()?.type !== "idle") const working = createMemo(() => status()?.type !== "idle")
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
popover: "at" | "slash" | null popover: "file" | "slash" | null
historyIndex: number historyIndex: number
savedPrompt: Prompt | null savedPrompt: Prompt | null
placeholder: number placeholder: number
@@ -186,7 +169,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
prompt.map((part) => { prompt.map((part) => {
if (part.type === "text") return { ...part } if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part } if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return { return {
...part, ...part,
selection: part.selection ? { ...part.selection } : undefined, selection: part.selection ? { ...part.selection } : undefined,
@@ -310,10 +292,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
event.preventDefault() event.preventDefault()
setStore("dragging", false) setStore("dragging", false)
const dropped = event.dataTransfer?.files const files = event.dataTransfer?.files
if (!dropped) return if (!files) return
for (const file of Array.from(dropped)) { for (const file of Array.from(files)) {
if (ACCEPTED_FILE_TYPES.includes(file.type)) { if (ACCEPTED_FILE_TYPES.includes(file.type)) {
await addImageAttachment(file) await addImageAttachment(file)
} }
@@ -337,43 +319,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!isFocused()) setStore("popover", null) if (!isFocused()) setStore("popover", null)
}) })
type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string } const handleFileSelect = (path: string | undefined) => {
if (!path) return
const agentList = createMemo(() => addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
)
const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
if (option.type === "agent") {
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
} else {
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
}
} }
const atKey = (x: AtOption | undefined) => { const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
if (!x) return "" items: local.file.searchFilesAndDirectories,
return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` key: (x) => x,
} onSelect: handleFileSelect,
const {
flat: atFlat,
active: atActive,
onInput: atOnInput,
onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({
items: async (query) => {
const agents = agentList()
const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths.map((path) => ({ type: "file", path, display: path }))
return [...agents, ...fileOptions]
},
key: atKey,
filterKeys: ["display"],
onSelect: handleAtSelect,
}) })
const slashCommands = createMemo<SlashCommand[]>(() => { const slashCommands = createMemo<SlashCommand[]>(() => {
@@ -459,7 +413,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (node.nodeType !== Node.ELEMENT_NODE) return false if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement const el = node as HTMLElement
if (el.dataset.type === "file") return true if (el.dataset.type === "file") return true
if (el.dataset.type === "agent") return true
return el.tagName === "BR" return el.tagName === "BR"
}) })
if (normalized && isPromptEqual(currentParts, domParts)) return if (normalized && isPromptEqual(currentParts, domParts)) return
@@ -483,15 +436,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pill.style.userSelect = "text" pill.style.userSelect = "text"
pill.style.cursor = "default" pill.style.cursor = "default"
editorRef.appendChild(pill) editorRef.appendChild(pill)
} else if (part.type === "agent") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "agent")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
} }
}) })
@@ -527,18 +471,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
position += content.length position += content.length
} }
const pushAgent = (agent: HTMLElement) => {
const content = agent.textContent ?? ""
parts.push({
type: "agent",
name: agent.dataset.name!,
content,
start: position,
end: position + content.length,
})
position += content.length
}
const visit = (node: Node) => { const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
buffer += node.textContent ?? "" buffer += node.textContent ?? ""
@@ -552,11 +484,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pushFile(el) pushFile(el)
return return
} }
if (el.dataset.type === "agent") {
flushText()
pushAgent(el)
return
}
if (el.tagName === "BR") { if (el.tagName === "BR") {
buffer += "\n" buffer += "\n"
return return
@@ -610,8 +537,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const slashMatch = rawText.match(/^\/(\S*)$/) const slashMatch = rawText.match(/^\/(\S*)$/)
if (atMatch) { if (atMatch) {
atOnInput(atMatch[1]) onInput(atMatch[1])
setStore("popover", "at") setStore("popover", "file")
} else if (slashMatch) { } else if (slashMatch) {
slashOnInput(slashMatch[1]) slashOnInput(slashMatch[1])
setStore("popover", "slash") setStore("popover", "slash")
@@ -631,36 +558,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
queueScroll() queueScroll()
} }
const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => {
let remaining = offset
const nodes = Array.from(editorRef.childNodes)
for (const node of nodes) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isPill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
if (edge === "end") range.setEnd(node, remaining)
return
}
if ((isPill || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
}
}
const addPart = (part: ContentPart) => { const addPart = (part: ContentPart) => {
const selection = window.getSelection() const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return if (!selection || selection.rangeCount === 0) return
@@ -683,35 +580,38 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const gap = document.createTextNode(" ") const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0) const range = selection.getRangeAt(0)
if (atMatch) { const setEdge = (edge: "start" | "end", offset: number) => {
const start = atMatch.index ?? cursorPosition - atMatch[0].length let remaining = offset
setRangeEdge(range, "start", start) const nodes = Array.from(editorRef.childNodes)
setRangeEdge(range, "end", cursorPosition)
for (const node of nodes) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
if (edge === "end") range.setEnd(node, remaining)
return
}
if ((isFile || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
}
} }
range.deleteContents()
range.insertNode(gap)
range.insertNode(pill)
range.setStartAfter(gap)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
} else if (part.type === "agent") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "agent")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
if (atMatch) { if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length const start = atMatch.index ?? cursorPosition - atMatch[0].length
setRangeEdge(range, "start", start) setEdge("start", start)
setRangeEdge(range, "end", cursorPosition) setEdge("end", cursorPosition)
} }
range.deleteContents() range.deleteContents()
@@ -932,8 +832,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
if (store.popover === "at") { if (store.popover === "file") {
atOnKeyDown(event) onKeyDown(event)
} else { } else {
slashOnKeyDown(event) slashOnKeyDown(event)
} }
@@ -1173,12 +1073,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!existing) return if (!existing) return
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const fileAttachments = currentPrompt.filter( const attachments = currentPrompt.filter(
(part) => part.type === "file", (part) => part.type === "file",
) as import("@/context/prompt").FileAttachmentPart[] ) as import("@/context/prompt").FileAttachmentPart[]
const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
const fileAttachmentParts = fileAttachments.map((attachment) => { const fileAttachmentParts = attachments.map((attachment) => {
const absolute = toAbsolutePath(attachment.path) const absolute = toAbsolutePath(attachment.path)
const query = attachment.selection const query = attachment.selection
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
@@ -1201,52 +1100,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
}) })
const agentAttachmentParts = agentAttachments.map((attachment) => ({
id: Identifier.ascending("part"),
type: "agent" as const,
name: attachment.name,
source: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
}))
const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
const contextFileParts: Array<{
id: string
type: "file"
mime: string
url: string
filename?: string
}> = []
const addContextFile = (path: string, selection?: FileSelection) => {
const absolute = toAbsolutePath(path)
const query = selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const url = `file://${absolute}${query}`
if (usedUrls.has(url)) return
usedUrls.add(url)
contextFileParts.push({
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(path),
})
}
const activePath = activeFile()
if (activePath && prompt.context.activeTab()) {
addContextFile(activePath)
}
for (const item of prompt.context.items()) {
if (item.type !== "file") continue
addContextFile(item.path, item.selection)
}
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
type: "file" as const, type: "file" as const,
@@ -1256,6 +1109,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})) }))
const isShellMode = store.mode === "shell" const isShellMode = store.mode === "shell"
tabs().setActive(undefined)
editorRef.innerHTML = "" editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
setStore("imageAttachments", []) setStore("imageAttachments", [])
@@ -1315,13 +1169,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "text" as const, type: "text" as const,
text, text,
} }
const requestParts = [ const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
textPart,
...fileAttachmentParts,
...contextFileParts,
...agentAttachmentParts,
...imageAttachmentParts,
]
const optimisticParts = requestParts.map((part) => ({ const optimisticParts = requestParts.map((part) => ({
...part, ...part,
sessionID: existing.id, sessionID: existing.id,
@@ -1359,46 +1207,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
> >
<Switch> <Switch>
<Match when={store.popover === "at"}> <Match when={store.popover === "file"}>
<Show <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
when={atFlat().length > 0} <For each={flat()}>
fallback={<div class="text-text-weak px-2 py-1">No matching results</div>} {(i) => (
>
<For each={atFlat().slice(0, 10)}>
{(item) => (
<button <button
classList={{ classList={{
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true, "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
"bg-surface-raised-base-hover": atActive() === atKey(item), "bg-surface-raised-base-hover": active() === i,
}} }}
onClick={() => handleAtSelect(item)} onClick={() => handleFileSelect(i)}
> >
<Show <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
when={item.type === "agent"} <div class="flex items-center text-14-regular min-w-0">
fallback={ <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span>
<> <Show when={!i.endsWith("/")}>
<FileIcon <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
node={{ path: (item as { type: "file"; path: string }).path, type: "file" }} </Show>
class="shrink-0 size-4" </div>
/>
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
{getDirectory((item as { type: "file"; path: string }).path)}
</span>
<Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">
{getFilename((item as { type: "file"; path: string }).path)}
</span>
</Show>
</div>
</>
}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">
@{(item as { type: "agent"; name: string }).name}
</span>
</Show>
</button> </button>
)} )}
</For> </For>
@@ -1445,7 +1271,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
classList={{ classList={{
"group/prompt-input": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-md overflow-clip focus-within:shadow-xs-border": true, "rounded-md overflow-clip focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.dragging, "border-icon-info-active border-dashed": store.dragging,
@@ -1460,66 +1285,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div> </div>
</div> </div>
</Show> </Show>
<Show when={false && (prompt.context.items().length > 0 || !!activeFile())}>
<div class="flex flex-wrap items-center gap-2 px-3 pt-3">
<Show when={prompt.context.activeTab() ? activeFile() : undefined}>
{(path) => (
<div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full">
<FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-12-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span>
<span class="text-text-weak whitespace-nowrap ml-1">active</span>
</div>
<IconButton
type="button"
icon="close"
variant="ghost"
class="h-6 w-6"
onClick={() => prompt.context.removeActive()}
/>
</div>
)}
</Show>
<Show when={!prompt.context.activeTab() && !!activeFile()}>
<button
type="button"
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-weak hover:bg-surface-raised-base-hover"
onClick={() => prompt.context.addActive()}
>
<Icon name="plus-small" size="small" />
<span>Include active file</span>
</button>
</Show>
<For each={prompt.context.items()}>
{(item) => (
<div class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base max-w-full">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-12-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap ml-1">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
</div>
<IconButton
type="button"
icon="close"
variant="ghost"
class="h-6 w-6"
onClick={() => prompt.context.remove(item.key)}
/>
</div>
)}
</For>
</div>
</Show>
<Show when={store.imageAttachments.length > 0}> <Show when={store.imageAttachments.length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3"> <div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={store.imageAttachments}> <For each={store.imageAttachments}>
@@ -1567,8 +1332,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
classList={{ classList={{
"select-text": true, "select-text": true,
"w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=file]]:text-icon-info-active": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell", "font-mono!": store.mode === "shell",
}} }}
/> />
@@ -1579,9 +1343,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`} : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
</div> </div>
</Show> </Show>
<div class="absolute top-4.5 right-4">
<SessionContextUsage />
</div>
</div> </div>
<div class="relative p-3 flex items-center justify-between"> <div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-0.5"> <div class="flex items-center justify-start gap-1">
<Switch> <Switch>
<Match when={store.mode === "shell"}> <Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6"> <div class="flex items-center gap-2 px-2 h-6">
@@ -1591,7 +1358,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div> </div>
</Match> </Match>
<Match when={store.mode === "normal"}> <Match when={store.mode === "normal"}>
<TooltipKeybind placement="top" title="Cycle agent" keybind={command.keybind("agent.cycle")}> <Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>Cycle agent</span>
<span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
</div>
}
>
<Select <Select
options={local.agent.list().map((agent) => agent.name)} options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current()?.name ?? ""} current={local.agent.current()?.name ?? ""}
@@ -1599,69 +1374,54 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="capitalize" class="capitalize"
variant="ghost" variant="ghost"
/> />
</TooltipKeybind> </Tooltip>
<Show <Tooltip
when={providers.paid().length > 0} placement="top"
fallback={ value={
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}> <div class="flex flex-col gap-1">
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}> <div class="flex items-center gap-2">
{local.model.current()?.name ?? "Select model"} <span>Choose model</span>
<Icon name="chevron-down" size="small" /> <span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
</Button> </div>
</TooltipKeybind> <Show when={local.model.current()?.provider.name}>
<span class="text-text-weak">{local.model.current()?.provider.name}</span>
</Show>
</div>
} }
> >
<ModelSelectorPopover> <Button
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}> as="div"
<Button as="div" variant="ghost"> variant="ghost"
{local.model.current()?.name ?? "Select model"} onClick={() =>
<Icon name="chevron-down" size="small" /> dialog.show(() =>
</Button> providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
</TooltipKeybind> )
</ModelSelectorPopover> }
</Show> >
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
</Tooltip>
<Show when={local.model.variant.list().length > 0}> <Show when={local.model.variant.list().length > 0}>
<TooltipKeybind <Tooltip placement="top" value="Cycle effort level">
placement="top"
title="Thinking effort"
keybind={command.keybind("model.variant.cycle")}
>
<Button <Button
variant="ghost" variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block"
onClick={() => local.model.variant.cycle()} onClick={() => local.model.variant.cycle()}
>
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
</Button>
</TooltipKeybind>
</Show>
<Show when={permission.permissionsEnabled() && params.id}>
<TooltipKeybind
placement="top"
title="Auto-accept edits"
keybind={command.keybind("permissions.autoaccept")}
>
<Button
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{ classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, "text-icon-warning": !!local.model.variant.current(),
"text-text-base": !permission.isAutoAccepting(params.id!),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
}} }}
> >
<Icon <Icon name="brain" size="small" />
name="chevron-double-right" <Show when={local.model.variant.current()}>
size="small" <span class="text-12-regular">{local.model.variant.current()}</span>
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }} </Show>
/>
</Button> </Button>
</TooltipKeybind> </Tooltip>
</Show> </Show>
</Match> </Match>
</Switch> </Switch>
</div> </div>
<div class="flex items-center gap-3 absolute right-2 bottom-2"> <div class="flex items-center gap-1 absolute right-2 bottom-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -1673,16 +1433,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
<div class="flex items-center gap-2"> <Show when={store.mode === "normal"}>
<SessionContextUsage /> <Tooltip placement="top" value="Attach image">
<Show when={store.mode === "normal"}> <IconButton
<Tooltip placement="top" value="Attach image"> type="button"
<Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}> icon="photo"
<Icon name="photo" class="size-4.5" /> variant="ghost"
</Button> class="h-10 w-8"
</Tooltip> onClick={() => fileInputRef.click()}
</Show> />
</div> </Tooltip>
</Show>
<Tooltip <Tooltip
placement="top" placement="top"
inactive={!prompt.dirty() && !working()} inactive={!prompt.dirty() && !working()}
@@ -1708,7 +1469,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()} disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
icon={working() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="h-6 w-4.5" class="h-10 w-8"
/> />
</Tooltip> </Tooltip>
</div> </div>
@@ -1766,9 +1527,7 @@ function setCursorPosition(parent: HTMLElement, position: number) {
while (node) { while (node) {
const length = getNodeLength(node) const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE const isText = node.nodeType === Node.TEXT_NODE
const isPill = const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) { if (isText && remaining <= length) {
@@ -1781,13 +1540,13 @@ function setCursorPosition(parent: HTMLElement, position: number) {
return return
} }
if ((isPill || isBreak) && remaining <= length) { if ((isFile || isBreak) && remaining <= length) {
const range = document.createRange() const range = document.createRange()
const selection = window.getSelection() const selection = window.getSelection()
if (remaining === 0) { if (remaining === 0) {
range.setStartBefore(node) range.setStartBefore(node)
} }
if (remaining > 0 && isPill) { if (remaining > 0 && isFile) {
range.setStartAfter(node) range.setStartAfter(node)
} }
if (remaining > 0 && isBreak) { if (remaining > 0 && isBreak) {
@@ -1,25 +1,13 @@
import { Match, Show, Switch, createMemo } from "solid-js" import { createMemo, Show } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button" import { useSync } from "@/context/sync"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { AssistantMessage } from "@opencode-ai/sdk/v2/client" import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
import { useLayout } from "@/context/layout" export function SessionContextUsage() {
import { useSync } from "@/context/sync"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
}
export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync() const sync = useSync()
const params = useParams() const params = useParams()
const layout = useLayout()
const variant = createMemo(() => props.variant ?? "button")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const cost = createMemo(() => { const cost = createMemo(() => {
@@ -31,11 +19,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}) })
const context = createMemo(() => { const context = createMemo(() => {
const last = messages().findLast((x) => { const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
}) as AssistantMessage
if (!last) return if (!last) return
const total = const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
@@ -46,57 +30,28 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
} }
}) })
const openContext = () => {
if (!params.id) return
layout.review.open()
tabs().open("context")
tabs().setActive("context")
}
const circle = () => (
<div class="p-1">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
</div>
)
const tooltipValue = () => (
<div>
<Show when={context()}>
{(ctx) => (
<>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().tokens}</span>
<span class="text-text-invert-base">Tokens</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
<span class="text-text-invert-base">Usage</span>
</div>
</>
)}
</Show>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{cost()}</span>
<span class="text-text-invert-base">Cost</span>
</div>
<Show when={variant() === "button"}>
<div class="text-11-regular text-text-invert-base mt-1">Click to view context</div>
</Show>
</div>
)
return ( return (
<Show when={params.id}> <Show when={context?.()}>
<Tooltip value={tooltipValue()} placement="top"> {(ctx) => (
<Switch> <Tooltip
<Match when={variant() === "indicator"}>{circle()}</Match> value={
<Match when={true}> <div class="grid grid-cols-2 gap-x-3 gap-y-1">
<Button type="button" variant="ghost" class="size-6" onClick={openContext}> <span class="opacity-70 text-right">Tokens</span>
{circle()} <span class="text-left">{ctx().tokens}</span>
</Button> <span class="opacity-70 text-right">Usage</span>
</Match> <span class="text-left">{ctx().percentage ?? 0}%</span>
</Switch> <span class="opacity-70 text-right">Cost</span>
</Tooltip> <span class="text-left">{cost()}</span>
</div>
}
placement="top"
>
<div class="flex items-center gap-1.5">
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
{/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
</div>
</Tooltip>
)}
</Show> </Show>
) )
} }
@@ -1,5 +0,0 @@
export { SessionHeader } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
@@ -1,419 +0,0 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown"
import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
interface SessionContextTabProps {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
export function SessionContextTab(props: SessionContextTabProps) {
const params = useParams()
const sync = useSync()
const ctx = createMemo(() => {
const last = props.messages().findLast((x) => {
if (x.role !== "assistant") return false
const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
return total > 0
}) as AssistantMessage
if (!last) return
const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
const model = provider?.models[last.modelID]
const limit = model?.limit.context
const input = last.tokens.input
const output = last.tokens.output
const reasoning = last.tokens.reasoning
const cacheRead = last.tokens.cache.read
const cacheWrite = last.tokens.cache.write
const total = input + output + reasoning + cacheRead + cacheWrite
const usage = limit ? Math.round((total / limit) * 100) : null
return {
message: last,
provider,
model,
limit,
input,
output,
reasoning,
cacheRead,
cacheWrite,
total,
usage,
}
})
const cost = createMemo(() => {
const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const counts = createMemo(() => {
const all = props.messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return {
all: all.length,
user,
assistant,
}
})
const systemPrompt = createMemo(() => {
const msg = props.visibleUserMessages().findLast((m) => !!m.system)
const system = msg?.system
if (!system) return
const trimmed = system.trim()
if (!trimmed) return
return trimmed
})
const number = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString()
}
const percent = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toString() + "%"
}
const time = (value: number | undefined) => {
if (!value) return "—"
return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED)
}
const providerLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
return c.provider?.name ?? c.message.providerID
})
const modelLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
if (c.model?.name) return c.model.name
return c.message.modelID
})
const breakdown = createMemo(
on(
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c) return []
const input = c.input
if (!input) return []
const out = {
system: systemPrompt()?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
}
for (const msg of props.messages()) {
const parts = (sync.data.part[msg.id] ?? []) as Part[]
if (msg.role === "user") {
for (const part of parts) {
if (part.type === "text") out.user += part.text.length
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
if (part.type === "agent") out.user += part.source?.value.length ?? 0
}
continue
}
if (msg.role === "assistant") {
for (const part of parts) {
if (part.type === "text") out.assistant += part.text.length
if (part.type === "reasoning") out.assistant += part.text.length
if (part.type === "tool") {
out.tool += Object.keys(part.state.input).length * 16
if (part.state.status === "pending") out.tool += part.state.raw.length
if (part.state.status === "completed") out.tool += part.state.output.length
if (part.state.status === "error") out.tool += part.state.error.length
}
}
}
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const system = estimateTokens(out.system)
const user = estimateTokens(out.user)
const assistant = estimateTokens(out.assistant)
const tool = estimateTokens(out.tool)
const estimated = system + user + assistant + tool
const pct = (tokens: number) => (tokens / input) * 100
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
return [
{
key: "system",
label: "System",
tokens: tokens.system,
width: pct(tokens.system),
percent: pctLabel(tokens.system),
color: "var(--syntax-info)",
},
{
key: "user",
label: "User",
tokens: tokens.user,
width: pct(tokens.user),
percent: pctLabel(tokens.user),
color: "var(--syntax-success)",
},
{
key: "assistant",
label: "Assistant",
tokens: tokens.assistant,
width: pct(tokens.assistant),
percent: pctLabel(tokens.assistant),
color: "var(--syntax-property)",
},
{
key: "tool",
label: "Tool Calls",
tokens: tokens.tool,
width: pct(tokens.tool),
percent: pctLabel(tokens.tool),
color: "var(--syntax-warning)",
},
{
key: "other",
label: "Other",
tokens: tokens.other,
width: pct(tokens.other),
percent: pctLabel(tokens.other),
color: "var(--syntax-comment)",
},
].filter((x) => x.tokens > 0)
}
if (estimated <= input) {
return build({ system, user, assistant, tool, other: input - estimated })
}
const scale = input / estimated
const scaled = {
system: Math.floor(system * scale),
user: Math.floor(user * scale),
assistant: Math.floor(assistant * scale),
tool: Math.floor(tool * scale),
}
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
},
),
)
function Stat(statProps: { label: string; value: JSX.Element }) {
return (
<div class="flex flex-col gap-1">
<div class="text-12-regular text-text-weak">{statProps.label}</div>
<div class="text-12-medium text-text-strong">{statProps.value}</div>
</div>
)
}
const stats = createMemo(() => {
const c = ctx()
const count = counts()
return [
{ label: "Session", value: props.info()?.title ?? params.id ?? "—" },
{ label: "Messages", value: count.all.toLocaleString() },
{ label: "Provider", value: providerLabel() },
{ label: "Model", value: modelLabel() },
{ label: "Context Limit", value: number(c?.limit) },
{ label: "Total Tokens", value: number(c?.total) },
{ label: "Usage", value: percent(c?.usage) },
{ label: "Input Tokens", value: number(c?.input) },
{ label: "Output Tokens", value: number(c?.output) },
{ label: "Reasoning Tokens", value: number(c?.reasoning) },
{ label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` },
{ label: "User Messages", value: count.user.toLocaleString() },
{ label: "Assistant Messages", value: count.assistant.toLocaleString() },
{ label: "Total Cost", value: cost() },
{ label: "Session Created", value: time(props.info()?.time.created) },
{ label: "Last Activity", value: time(c?.message.time.created) },
] satisfies { label: string; value: JSX.Element }[]
})
function RawMessageContent(msgProps: { message: Message }) {
const file = createMemo(() => {
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
return {
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
contents,
cacheKey: checksum(contents),
}
})
return <Code file={file()} overflow="wrap" class="select-text" />
}
function RawMessage(msgProps: { message: Message }) {
return (
<Accordion.Item value={msgProps.message.id}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate">
{msgProps.message.role} <span class="text-text-base"> {msgProps.message.id}</span>
</div>
<div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="bg-background-base">
<div class="p-3">
<RawMessageContent message={msgProps.message} />
</div>
</Accordion.Content>
</Accordion.Item>
)
}
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = props.view()?.scroll("context")
if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
pending = {
x: event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
}
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
const next = pending
pending = undefined
if (!next) return
props.view().setScroll("context", next)
})
}
createEffect(
on(
() => props.messages().length,
() => {
requestAnimationFrame(restoreScroll)
},
{ defer: true },
),
)
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
return (
<div
class="@container h-full overflow-y-auto no-scrollbar pb-10"
ref={(el) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll}
>
<div class="px-6 pt-4 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
</div>
<Show when={breakdown().length > 0}>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">Context Breakdown</div>
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
<For each={breakdown()}>
{(segment) => (
<div
class="h-full"
style={{
width: `${segment.width}%`,
"background-color": segment.color,
}}
/>
)}
</For>
</div>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<For each={breakdown()}>
{(segment) => (
<div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
<div>{segment.label}</div>
<div class="text-text-weaker">{segment.percent}</div>
</div>
)}
</For>
</div>
<div class="hidden text-11-regular text-text-weaker">
Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.
</div>
</div>
</Show>
<Show when={systemPrompt()}>
{(prompt) => (
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">System Prompt</div>
<div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
<Markdown text={prompt()} class="text-12-regular" />
</div>
</div>
)}
</Show>
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">Raw messages</div>
<Accordion multiple>
<For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
</Accordion>
</div>
</div>
</div>
)
}
@@ -1,212 +0,0 @@
import { createMemo, createResource, Show } from "solid-js"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { base64Encode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Select } from "@opencode-ai/ui/select"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const command = useCommand()
const server = useServer()
const dialog = useDialog()
const sync = useSync()
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const branch = createMemo(() => sync.data.vcs?.branch)
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
}
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session.id}`)
}
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
onClick={layout.mobileSidebar.toggle}
>
<Icon name="menu" size="small" />
</button>
<div class="px-4 flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={sync.directory}
label={(x) => {
const name = getFilename(x)
const b = x === sync.directory ? branch() : undefined
return b ? `${name}:${b}` : name
}}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind>
</Show>
</div>
<div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: sync.directory })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
)
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
})}
</Popover>
</Show>
</div>
</div>
</header>
)
}
@@ -1,35 +0,0 @@
import { Show } from "solid-js"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
export function NewSessionView() {
const sync = useSync()
return (
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
<div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
)
}
@@ -1,48 +0,0 @@
import { createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { getFilename } from "@opencode-ai/util/path"
import { useFile } from "@/context/file"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
return (
<div class="flex items-center gap-x-1.5">
<FileIcon
node={{ path: props.path, type: "file" }}
classList={{
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
"grayscale-0": props.active,
}}
/>
<span class="text-14-medium">{getFilename(props.path)}</span>
</div>
)
}
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
const file = useFile()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
value={props.tab}
closeButton={
<Tooltip value="Close tab" placement="bottom">
<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
</Tooltip>
}
hideCloseButton
>
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</Tabs.Trigger>
</div>
</div>
)
}
@@ -1,27 +0,0 @@
import type { JSX } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useTerminal, type LocalPTY } from "@/context/terminal"
export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element {
const terminal = useTerminal()
const sortable = createSortable(props.terminal.id)
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
value={props.terminal.id}
closeButton={
terminal.all().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
)
}
>
{props.terminal.title}
</Tabs.Trigger>
</div>
</div>
)
}
@@ -0,0 +1,53 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Button } from "@opencode-ai/ui/button"
import { DialogSelectServer } from "@/components/dialog-select-server"
export function StatusBar(props: ParentProps) {
const dialog = useDialog()
const server = useServer()
const sync = useSync()
const globalSync = useGlobalSync()
const directoryDisplay = createMemo(() => {
const directory = sync.data.path.directory || ""
const home = globalSync.data.path.home || ""
const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
const branch = sync.data.vcs?.branch
return branch ? `${short}:${branch}` : short
})
return (
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<span class="text-12-regular text-text-weak">{server.name}</span>
</Button>
</div>
<Show when={directoryDisplay()}>
<span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
</Show>
</div>
<div class="flex items-center">{props.children}</div>
</div>
)
}
-282
View File
@@ -1,282 +0,0 @@
import { createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { persisted } from "@/utils/persist"
export type FileSelection = {
startLine: number
startChar: number
endLine: number
endChar: number
}
export type SelectedLineRange = {
start: number
end: number
side?: "additions" | "deletions"
endSide?: "additions" | "deletions"
}
export type FileViewState = {
scrollTop?: number
scrollLeft?: number
selectedLines?: SelectedLineRange | null
}
export type FileState = {
path: string
name: string
loaded?: boolean
loading?: boolean
error?: string
content?: FileContent
}
function stripFileProtocol(input: string) {
if (!input.startsWith("file://")) return input
return input.slice("file://".length)
}
function stripQueryAndHash(input: string) {
const hashIndex = input.indexOf("#")
const queryIndex = input.indexOf("?")
if (hashIndex !== -1 && queryIndex !== -1) {
return input.slice(0, Math.min(hashIndex, queryIndex))
}
if (hashIndex !== -1) return input.slice(0, hashIndex)
if (queryIndex !== -1) return input.slice(0, queryIndex)
return input
}
export function selectionFromLines(range: SelectedLineRange): FileSelection {
const startLine = Math.min(range.start, range.end)
const endLine = Math.max(range.start, range.end)
return {
startLine,
endLine,
startChar: 0,
endChar: 0,
}
}
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
if (range.start <= range.end) return range
const startSide = range.side
const endSide = range.endSide ?? startSide
return {
...range,
start: range.end,
end: range.start,
side: endSide,
endSide: startSide !== endSide ? startSide : undefined,
}
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File",
init: () => {
const sdk = useSDK()
const sync = useSync()
const params = useParams()
const directory = createMemo(() => sync.data.path.directory)
function normalize(input: string) {
const root = directory()
const prefix = root.endsWith("/") ? root : root + "/"
let path = stripQueryAndHash(stripFileProtocol(input))
if (path.startsWith(prefix)) {
path = path.slice(prefix.length)
}
if (path.startsWith(root)) {
path = path.slice(root.length)
}
if (path.startsWith("./")) {
path = path.slice(2)
}
if (path.startsWith("/")) {
path = path.slice(1)
}
return path
}
function tab(input: string) {
const path = normalize(input)
return `file://${path}`
}
function pathFromTab(tabValue: string) {
if (!tabValue.startsWith("file://")) return
return normalize(tabValue)
}
const inflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{
file: Record<string, FileState>
}>({
file: {},
})
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const [view, setView, _, ready] = persisted(
viewKey(),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
function ensure(path: string) {
if (!path) return
if (store.file[path]) return
setStore("file", path, { path, name: getFilename(path) })
}
function load(input: string, options?: { force?: boolean }) {
const path = normalize(input)
if (!path) return Promise.resolve()
ensure(path)
const current = store.file[path]
if (!options?.force && current?.loaded) return Promise.resolve()
const pending = inflight.get(path)
if (pending) return pending
setStore(
"file",
path,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
const promise = sdk.client.file
.read({ path })
.then((x) => {
setStore(
"file",
path,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.content = x.data
}),
)
})
.catch((e) => {
setStore(
"file",
path,
produce((draft) => {
draft.loading = false
draft.error = e.message
}),
)
showToast({
variant: "error",
title: "Failed to load file",
description: e.message,
})
})
.finally(() => {
inflight.delete(path)
})
inflight.set(path, promise)
return promise
}
const stop = sdk.event.listen((e) => {
const event = e.details
if (event.type !== "file.watcher.updated") return
const path = normalize(event.properties.file)
if (!path) return
if (path.startsWith(".git/")) return
if (!store.file[path]) return
load(path, { force: true })
})
const get = (input: string) => store.file[normalize(input)]
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
const setScrollTop = (input: string, top: number) => {
const path = normalize(input)
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
}
const setScrollLeft = (input: string, left: number) => {
const path = normalize(input)
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
const path = normalize(input)
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
}
onCleanup(() => stop())
return {
ready,
normalize,
tab,
pathFromTab,
get,
load,
scrollTop,
scrollLeft,
setScrollTop,
setScrollLeft,
selectedLines,
setSelectedLines,
searchFiles: (query: string) =>
sdk.client.find.files({ query, dirs: "false" }).then((x) => (x.data ?? []).map(normalize)),
searchFilesAndDirectories: (query: string) =>
sdk.client.find.files({ query, dirs: "true" }).then((x) => (x.data ?? []).map(normalize)),
}
},
})
+50 -64
View File
@@ -15,7 +15,7 @@ import {
type McpStatus, type McpStatus,
type LspStatus, type LspStatus,
type VcsInfo, type VcsInfo,
type PermissionRequest, type Permission,
createOpencodeClient, createOpencodeClient,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
@@ -28,7 +28,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
type State = { type State = {
status: "loading" | "partial" | "complete" ready: boolean
agent: Agent[] agent: Agent[]
command: Command[] command: Command[]
project: string project: string
@@ -46,7 +46,7 @@ type State = {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
} }
permission: { permission: {
[sessionID: string]: PermissionRequest[] [sessionID: string]: Permission[]
} }
mcp: { mcp: {
[name: string]: McpStatus [name: string]: McpStatus
@@ -88,7 +88,7 @@ function createGlobalSync() {
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
config: {}, config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" }, path: { state: "", config: "", worktree: "", directory: "", home: "" },
status: "loading" as const, ready: false,
agent: [], agent: [],
command: [], command: [],
session: [], session: [],
@@ -115,14 +115,13 @@ function createGlobalSync() {
.then((x) => { .then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? []) const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.slice() .slice()
.filter((s) => !s.time.archived)
.sort((a, b) => a.id.localeCompare(b.id)) .sort((a, b) => a.id.localeCompare(b.id))
// Include up to the limit, plus any updated in the last 4 hours // Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => { const sessions = nonArchived.filter((s, i) => {
if (i < store.limit) return true if (i < store.limit) return true
const updated = new Date(s.time?.updated ?? s.time?.created).getTime() const updated = new Date(s.time.updated).getTime()
return updated > fourHoursAgo return updated > fourHoursAgo
}) })
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
@@ -142,8 +141,7 @@ function createGlobalSync() {
directory, directory,
throwOnError: true, throwOnError: true,
}) })
const load = {
const blockingRequests = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)), project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () => provider: () =>
sdk.provider.list().then((x) => { sdk.provider.list().then((x) => {
@@ -158,57 +156,47 @@ function createGlobalSync() {
})), })),
}) })
}), }),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
} mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
await Promise.all(Object.values(blockingRequests).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
.then(() => { vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
if (store.status !== "complete") setStore("status", "partial") permission: () =>
// non-blocking sdk.permission.list().then((x) => {
Promise.all([ const grouped: Record<string, Permission[]> = {}
sdk.path.get().then((x) => setStore("path", x.data!)), for (const perm of x.data ?? []) {
sdk.command.list().then((x) => setStore("command", x.data ?? [])), const existing = grouped[perm.sessionID]
sdk.session.status().then((x) => setStore("session_status", x.data!)), if (existing) {
loadSessions(directory), existing.push(perm)
sdk.mcp.status().then((x) => setStore("mcp", x.data!)), continue
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.vcs.get().then((x) => setStore("vcs", x.data)),
sdk.permission.list().then((x) => {
const grouped: Record<string, PermissionRequest[]> = {}
for (const perm of x.data ?? []) {
if (!perm?.id || !perm.sessionID) continue
const existing = grouped[perm.sessionID]
if (existing) {
existing.push(perm)
continue
}
grouped[perm.sessionID] = [perm]
} }
grouped[perm.sessionID] = [perm]
}
batch(() => { batch(() => {
for (const sessionID of Object.keys(store.permission)) { for (const sessionID of Object.keys(store.permission)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
setStore("permission", sessionID, []) setStore("permission", sessionID, [])
} }
for (const [sessionID, permissions] of Object.entries(grouped)) { for (const [sessionID, permissions] of Object.entries(grouped)) {
setStore( setStore(
"permission", "permission",
sessionID, sessionID,
reconcile( reconcile(
permissions permissions.slice().sort((a, b) => a.id.localeCompare(b.id)),
.filter((p) => !!p?.id) { key: "id" },
.slice() ),
.sort((a, b) => a.id.localeCompare(b.id)), )
{ key: "id" }, }
), })
) }),
} }
}) await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
}), .then(() => setStore("ready", true))
]).then(() => {
setStore("status", "complete")
})
})
.catch((e) => setGlobalStore("error", e)) .catch((e) => setGlobalStore("error", e))
} }
@@ -356,7 +344,7 @@ function createGlobalSync() {
setStore("vcs", { branch: event.properties.branch }) setStore("vcs", { branch: event.properties.branch })
break break
} }
case "permission.asked": { case "permission.updated": {
const sessionID = event.properties.sessionID const sessionID = event.properties.sessionID
const permissions = store.permission[sessionID] const permissions = store.permission[sessionID]
if (!permissions) { if (!permissions) {
@@ -382,7 +370,7 @@ function createGlobalSync() {
case "permission.replied": { case "permission.replied": {
const permissions = store.permission[event.properties.sessionID] const permissions = store.permission[event.properties.sessionID]
if (!permissions) break if (!permissions) break
const result = Binary.search(permissions, event.properties.requestID, (p) => p.id) const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
if (!result.found) break if (!result.found) break
setStore( setStore(
"permission", "permission",
@@ -426,12 +414,10 @@ function createGlobalSync() {
), ),
retry(() => retry(() =>
globalSDK.client.project.list().then(async (x) => { globalSDK.client.project.list().then(async (x) => {
const projects = (x.data ?? []) setGlobalStore(
.filter((p) => !!p?.id) "project",
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
.slice() )
.sort((a, b) => a.id.localeCompare(b.id))
setGlobalStore("project", projects)
}), }),
), ),
retry(() => retry(() =>
+25 -118
View File
@@ -23,32 +23,13 @@ export function getAvatarColors(key?: string) {
} }
} }
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type SessionTabs = { type SessionTabs = {
active?: string active?: string
all: string[] all: string[]
} }
type SessionScroll = {
x: number
y: number
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean } export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type ReviewDiffStyle = "unified" | "split"
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout", name: "Layout",
init: () => { init: () => {
@@ -56,7 +37,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const server = useServer() const server = useServer()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"layout.v6", "layout.v4",
createStore({ createStore({
sidebar: { sidebar: {
opened: false, opened: false,
@@ -68,16 +49,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
review: { review: {
opened: true, opened: true,
diffStyle: "split" as ReviewDiffStyle,
}, },
session: { session: {
width: 600, width: 600,
}, },
mobileSidebar: {
opened: false,
},
sessionTabs: {} as Record<string, SessionTabs>, sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
}), }),
) )
@@ -180,14 +156,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
review: { review: {
opened: createMemo(() => store.review?.opened ?? true), opened: createMemo(() => store.review?.opened ?? true),
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) {
setStore("review", { opened: true, diffStyle })
return
}
setStore("review", "diffStyle", diffStyle)
},
open() { open() {
setStore("review", "opened", true) setStore("review", "opened", true)
}, },
@@ -203,55 +171,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
resize(width: number) { resize(width: number) {
if (!store.session) { if (!store.session) {
setStore("session", { width }) setStore("session", { width })
return } else {
setStore("session", "width", width)
} }
setStore("session", "width", width)
}, },
}, },
mobileSidebar: {
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
show() {
setStore("mobileSidebar", "opened", true)
},
hide() {
setStore("mobileSidebar", "opened", false)
},
toggle() {
setStore("mobileSidebar", "opened", (x) => !x)
},
},
view(sessionKey: string) {
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
return {
scroll(tab: string) {
return s().scroll?.[tab]
},
setScroll(tab: string, pos: SessionScroll) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
return
}
const prev = current.scroll?.[tab]
if (prev?.x === pos.x && prev?.y === pos.y) return
setStore("sessionView", sessionKey, "scroll", tab, pos)
},
review: {
open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
return
}
if (same(current.reviewOpen, open)) return
setStore("sessionView", sessionKey, "reviewOpen", open)
},
},
}
},
tabs(sessionKey: string) { tabs(sessionKey: string) {
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return { return {
@@ -274,55 +198,38 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
async open(tab: string) { async open(tab: string) {
const current = store.sessionTabs[sessionKey] ?? { all: [] } const current = store.sessionTabs[sessionKey] ?? { all: [] }
if (tab !== "review") {
if (tab === "review") { if (!current.all.includes(tab)) {
if (!store.sessionTabs[sessionKey]) { if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: tab }) setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
} else {
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
setStore("sessionTabs", sessionKey, "active", tab)
}
return return
} }
setStore("sessionTabs", sessionKey, "active", tab)
return
} }
if (tab === "context") {
const all = [tab, ...current.all.filter((x) => x !== tab)]
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all, active: tab })
return
}
setStore("sessionTabs", sessionKey, "all", all)
setStore("sessionTabs", sessionKey, "active", tab)
return
}
if (!current.all.includes(tab)) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
return
}
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
setStore("sessionTabs", sessionKey, "active", tab)
return
}
if (!store.sessionTabs[sessionKey]) { if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: current.all, active: tab }) setStore("sessionTabs", sessionKey, { all: [], active: tab })
return } else {
setStore("sessionTabs", sessionKey, "active", tab)
} }
setStore("sessionTabs", sessionKey, "active", tab)
}, },
close(tab: string) { close(tab: string) {
const current = store.sessionTabs[sessionKey] const current = store.sessionTabs[sessionKey]
if (!current) return if (!current) return
const all = current.all.filter((x) => x !== tab)
batch(() => { batch(() => {
setStore("sessionTabs", sessionKey, "all", all) setStore(
if (current.active !== tab) return "sessionTabs",
sessionKey,
const index = current.all.findIndex((f) => f === tab) "all",
const next = all[index - 1] ?? all[0] current.all.filter((x) => x !== tab),
setStore("sessionTabs", sessionKey, "active", next) )
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) { move(tab: string, to: number) {
+56 -1
View File
@@ -430,7 +430,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
// ] // ]
// }) // })
// setStore("active", relativePath) // setStore("active", relativePath)
// context.addActive() context.addActive()
if (options?.pinned) setStore("node", path, "pinned", true) if (options?.pinned) setStore("node", path, "pinned", true)
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view) if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
if (store.node[relativePath]?.loaded) return if (store.node[relativePath]?.loaded) return
@@ -538,11 +538,66 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
})() })()
const context = (() => {
const [store, setStore] = createStore<{
activeTab: boolean
files: string[]
activeFile?: string
items: (ContextItem & { key: string })[]
}>({
activeTab: true,
files: [],
items: [],
})
const files = createMemo(() => store.files.map((x) => file.node(x)))
const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
return {
all() {
return store.items
},
// active() {
// return store.activeTab ? file.active() : undefined
// },
addActive() {
setStore("activeTab", true)
},
removeActive() {
setStore("activeTab", false)
},
add(item: ContextItem) {
let key = item.type
switch (item.type) {
case "file":
key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
break
}
if (store.items.find((x) => x.key === key)) return
setStore("items", (x) => [...x, { key, ...item }])
},
remove(key: string) {
setStore("items", (x) => x.filter((x) => x.key !== key))
},
files,
openFile(path: string) {
file.init(path).then(() => {
setStore("files", (x) => [...x, path])
setStore("activeFile", path)
})
},
activeFile,
setActiveFile(path: string | undefined) {
setStore("activeFile", path)
},
}
})()
const result = { const result = {
slug: createMemo(() => base64Encode(sdk.directory)), slug: createMemo(() => base64Encode(sdk.directory)),
model, model,
agent, agent,
file, file,
context,
} }
return result return result
}, },
+78 -70
View File
@@ -1,122 +1,130 @@
import { createMemo, onCleanup } from "solid-js" import { createEffect, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" import type { Permission } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync" type PermissionsBySession = {
import { useParams } from "@solidjs/router" [sessionID: string]: Permission[]
import { base64Decode } from "@opencode-ai/util/encode" }
type PermissionRespondFn = (input: { type PermissionRespondFn = (input: {
sessionID: string sessionID: string
permissionID: string permissionID: string
response: "once" | "always" | "reject" response: "once" | "always" | "reject"
directory?: string
}) => void }) => void
function shouldAutoAccept(perm: PermissionRequest) { const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
return perm.permission === "edit"
function shouldAutoAccept(perm: Permission) {
return AUTO_ACCEPT_TYPES.has(perm.type)
} }
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission", name: "Permission",
init: () => { init: (props: { permissions: PermissionsBySession; onRespond: PermissionRespondFn }) => {
const params = useParams()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => {
if (!params.dir || !base64Decode(params.dir)) return false
const [store] = globalSync.child(base64Decode(params.dir))
return store.config.permission !== undefined
})
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"permission.v3", "permission.v1",
createStore({ createStore({
autoAcceptEdits: {} as Record<string, boolean>, autoAcceptEdits: {} as Record<string, boolean>,
}), }),
) )
const responded = new Set<string>() const responded = new Set<string>()
const watches = new Map<string, () => void>()
const respond: PermissionRespondFn = (input) => { function respond(perm: Permission) {
globalSDK.client.permission.respond(input).catch(() => { if (responded.has(perm.id)) return
responded.delete(input.permissionID) responded.add(perm.id)
}) props.onRespond({
} sessionID: perm.sessionID,
permissionID: perm.id,
function respondOnce(permission: PermissionRequest, directory?: string) {
if (responded.has(permission.id)) return
responded.add(permission.id)
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once", response: "once",
directory,
}) })
} }
function isAutoAccepting(sessionID: string) { function watch(sessionID: string) {
return store.autoAcceptEdits[sessionID] ?? false if (watches.has(sessionID)) return
}
const unsubscribe = globalSDK.event.listen((e) => { const dispose = createRoot((dispose) => {
const event = e.details createEffect(() => {
if (event?.type !== "permission.asked") return if (!store.autoAcceptEdits[sessionID]) return
const perm = event.properties const permissions = props.permissions[sessionID] ?? []
if (!isAutoAccepting(perm.sessionID)) return permissions.length
if (!shouldAutoAccept(perm)) return
respondOnce(perm, e.name) for (const perm of permissions) {
})
onCleanup(unsubscribe)
function enable(sessionID: string, directory: string) {
setStore("autoAcceptEdits", sessionID, true)
globalSDK.client.permission
.list({ directory })
.then((x) => {
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (perm.sessionID !== sessionID) continue
if (!shouldAutoAccept(perm)) continue if (!shouldAutoAccept(perm)) continue
respondOnce(perm, directory) respond(perm)
} }
}) })
.catch(() => undefined)
return dispose
})
watches.set(sessionID, dispose)
}
function unwatch(sessionID: string) {
const dispose = watches.get(sessionID)
if (!dispose) return
dispose()
watches.delete(sessionID)
}
createEffect(() => {
if (!ready()) return
for (const sessionID in store.autoAcceptEdits) {
if (!store.autoAcceptEdits[sessionID]) continue
watch(sessionID)
}
})
onCleanup(() => {
for (const dispose of watches.values()) dispose()
watches.clear()
})
function enable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, true)
watch(sessionID)
const permissions = props.permissions[sessionID] ?? []
for (const perm of permissions) {
if (!shouldAutoAccept(perm)) continue
respond(perm)
}
} }
function disable(sessionID: string) { function disable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, false) setStore("autoAcceptEdits", sessionID, false)
unwatch(sessionID)
} }
return { return {
ready, get permissions() {
respond, return props.permissions
autoResponds(permission: PermissionRequest) {
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
}, },
isAutoAccepting, respond: props.onRespond,
toggleAutoAccept(sessionID: string, directory: string) { isAutoAccepting(sessionID: string) {
if (isAutoAccepting(sessionID)) { return store.autoAcceptEdits[sessionID] ?? false
},
toggleAutoAccept(sessionID: string) {
if (store.autoAcceptEdits[sessionID]) {
disable(sessionID) disable(sessionID)
return return
} }
enable(sessionID, directory) enable(sessionID)
}, },
enableAutoAccept(sessionID: string, directory: string) { enableAutoAccept(sessionID: string) {
if (isAutoAccepting(sessionID)) return if (store.autoAcceptEdits[sessionID]) return
enable(sessionID, directory) enable(sessionID)
}, },
disableAutoAccept(sessionID: string) { disableAutoAccept(sessionID: string) {
disable(sessionID) disable(sessionID)
}, },
permissionsEnabled,
} }
}, },
}) })
+6 -67
View File
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js" import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file" import { TextSelection } from "./local"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
interface PartBase { interface PartBase {
@@ -18,12 +18,7 @@ export interface TextPart extends PartBase {
export interface FileAttachmentPart extends PartBase { export interface FileAttachmentPart extends PartBase {
type: "file" type: "file"
path: string path: string
selection?: FileSelection selection?: TextSelection
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
} }
export interface ImageAttachmentPart { export interface ImageAttachmentPart {
@@ -34,27 +29,11 @@ export interface ImageAttachmentPart {
dataUrl: string dataUrl: string
} }
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
export type Prompt = ContentPart[] export type Prompt = ContentPart[]
export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
}
export type ContextItem = FileContextItem
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
if (!a && !b) return true
if (!a || !b) return false
return (
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
)
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) { for (let i = 0; i < promptA.length; i++) {
@@ -64,13 +43,7 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (partA.type === "text" && partA.content !== (partB as TextPart).content) { if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
return false return false
} }
if (partA.type === "file") { if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
const fileA = partA as FileAttachmentPart
const fileB = partB as FileAttachmentPart
if (fileA.path !== fileB.path) return false
if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
}
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
return false return false
} }
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
@@ -80,7 +53,7 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
return true return true
} }
function cloneSelection(selection?: FileSelection) { function cloneSelection(selection?: TextSelection) {
if (!selection) return undefined if (!selection) return undefined
return { ...selection } return { ...selection }
} }
@@ -88,7 +61,6 @@ function cloneSelection(selection?: FileSelection) {
function clonePart(part: ContentPart): ContentPart { function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part } if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part } if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return { return {
...part, ...part,
selection: cloneSelection(part.selection), selection: cloneSelection(part.selection),
@@ -103,57 +75,24 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
name: "Prompt", name: "Prompt",
init: () => { init: () => {
const params = useParams() const params = useParams()
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`) const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
name(), name(),
createStore<{ createStore<{
prompt: Prompt prompt: Prompt
cursor?: number cursor?: number
context: {
activeTab: boolean
items: (ContextItem & { key: string })[]
}
}>({ }>({
prompt: clonePrompt(DEFAULT_PROMPT), prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined, cursor: undefined,
context: {
activeTab: true,
items: [],
},
}), }),
) )
function keyForItem(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
return `${item.type}:${item.path}:${start}:${end}`
}
return { return {
ready, ready,
current: createMemo(() => store.prompt), current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursor), cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
context: {
activeTab: createMemo(() => store.context.activeTab),
items: createMemo(() => store.context.items),
addActive() {
setStore("context", "activeTab", true)
},
removeActive() {
setStore("context", "activeTab", false)
},
add(item: ContextItem) {
const key = keyForItem(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
set(prompt: Prompt, cursorPosition?: number) { set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt) const next = clonePrompt(prompt)
batch(() => { batch(() => {
+4 -16
View File
@@ -18,11 +18,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return { return {
data: store, data: store,
set: setStore, set: setStore,
get status() {
return store.status
},
get ready() { get ready() {
return store.status !== "loading" return store.ready
}, },
get project() { get project() {
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id) const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
@@ -59,17 +56,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id) const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message) messages.splice(result.index, 0, message)
} }
draft.part[input.messageID] = input.parts draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
}), }),
) )
}, },
async sync(sessionID: string, _isRetry = false) { async sync(sessionID: string, _isRetry = false) {
const [session, messages, todo, diff] = await Promise.all([ const [session, messages, todo, diff] = await Promise.all([
retry(() => sdk.client.session.get({ sessionID })), retry(() => sdk.client.session.get({ sessionID })),
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })), retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
retry(() => sdk.client.session.todo({ sessionID })), retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })), retry(() => sdk.client.session.diff({ sessionID })),
]) ])
@@ -94,7 +88,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
reconcile( reconcile(
(messages.data ?? []) (messages.data ?? [])
.map((x) => x.info) .map((x) => x.info)
.filter((m) => !!m?.id)
.slice() .slice()
.sort((a, b) => a.id.localeCompare(b.id)), .sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" }, { key: "id" },
@@ -102,15 +95,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
) )
for (const message of messages.data ?? []) { for (const message of messages.data ?? []) {
if (!message?.info?.id) continue
setStore( setStore(
"part", "part",
message.info.id, message.info.id,
reconcile( reconcile(
message.parts message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" }, { key: "id" },
), ),
) )
@@ -123,7 +112,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setStore("limit", (x) => x + count) setStore("limit", (x) => x + count)
await sdk.client.session.list().then((x) => { await sdk.client.session.list().then((x) => {
const sessions = (x.data ?? []) const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.slice() .slice()
.sort((a, b) => a.id.localeCompare(b.id)) .sort((a, b) => a.id.localeCompare(b.id))
.slice(0, store.limit) .slice(0, store.limit)
+6 -4
View File
@@ -3,7 +3,7 @@ import { useParams } from "@solidjs/router"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { PermissionProvider } from "@/context/permission"
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context" import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife" import { iife } from "@opencode-ai/util/iife"
@@ -27,9 +27,11 @@ export default function Layout(props: ParentProps) {
}) => sdk.client.permission.respond(input) }) => sdk.client.permission.respond(input)
return ( return (
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}> <PermissionProvider permissions={sync.data.permission} onRespond={respond}>
<LocalProvider>{props.children}</LocalProvider> <DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
</DataProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider>
</PermissionProvider>
) )
})} })}
</SyncProvider> </SyncProvider>
+72 -78
View File
@@ -22,11 +22,10 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible" import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { Mark } from "@opencode-ai/ui/logo"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client" import { Session } from "@opencode-ai/sdk/v2/client"
@@ -45,9 +44,8 @@ import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider" import { DialogSelectProvider } from "@/components/dialog-select-provider"
@@ -62,9 +60,17 @@ export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
lastSession: {} as { [directory: string]: string }, lastSession: {} as { [directory: string]: string },
activeDraggable: undefined as string | undefined, activeDraggable: undefined as string | undefined,
mobileSidebarOpen: false,
mobileProjectsExpanded: {} as Record<string, boolean>, mobileProjectsExpanded: {} as Record<string, boolean>,
}) })
const mobileSidebar = {
open: () => store.mobileSidebarOpen,
show: () => setStore("mobileSidebarOpen", true),
hide: () => setStore("mobileSidebarOpen", false),
toggle: () => setStore("mobileSidebarOpen", (x) => !x),
}
const mobileProjects = { const mobileProjects = {
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true, expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true), expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
@@ -85,7 +91,6 @@ export default function Layout(props: ParentProps) {
const platform = usePlatform() const platform = usePlatform()
const server = useServer() const server = useServer()
const notification = useNotification() const notification = useNotification()
const permission = usePermission()
const navigate = useNavigate() const navigate = useNavigate()
const providers = useProviders() const providers = useProviders()
const dialog = useDialog() const dialog = useDialog()
@@ -126,15 +131,11 @@ export default function Layout(props: ParentProps) {
}) })
} }
onMount(() => { onMount(async () => {
if (!platform.checkUpdate || !platform.update || !platform.restart) return if (platform.checkUpdate && platform.update && platform.restart) {
const { updateAvailable, version } = await platform.checkUpdate()
let toastId: number | undefined if (updateAvailable) {
showToast({
async function pollUpdate() {
const { updateAvailable, version } = await platform.checkUpdate!()
if (updateAvailable && toastId === undefined) {
toastId = showToast({
persistent: true, persistent: true,
icon: "download", icon: "download",
title: "Update available", title: "Update available",
@@ -155,48 +156,31 @@ export default function Layout(props: ParentProps) {
}) })
} }
} }
pollUpdate()
const interval = setInterval(pollUpdate, 10 * 60 * 1000)
onCleanup(() => clearInterval(interval))
}) })
onMount(() => { onMount(() => {
const seenSessions = new Set<string>()
const toastBySession = new Map<string, number>() const toastBySession = new Map<string, number>()
const alertedAtBySession = new Map<string, number>()
const permissionAlertCooldownMs = 5000
const unsub = globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
if (e.details?.type !== "permission.asked") return if (e.details?.type !== "permission.updated") return
const directory = e.name const directory = e.name
const perm = e.details.properties const permission = e.details.properties
if (permission.autoResponds(perm)) return const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
const sessionKey = `${directory}:${perm.sessionID}`
const [store] = globalSync.child(directory) const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === perm.sessionID) const session = store.session.find((s) => s.id === permission.sessionID)
const sessionTitle = session?.title ?? "New session" const sessionTitle = session?.title ?? "New session"
const projectName = getFilename(directory) const projectName = getFilename(directory)
const description = `${sessionTitle} in ${projectName} needs permission` const description = `${sessionTitle} in ${projectName} needs permission`
const href = `/${base64Encode(directory)}/session/${perm.sessionID}` const href = `/${base64Encode(directory)}/session/${permission.sessionID}`
const now = Date.now()
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
if (now - lastAlerted < permissionAlertCooldownMs) return
alertedAtBySession.set(sessionKey, now)
void platform.notify("Permission required", description, href) void platform.notify("Permission required", description, href)
const currentDir = params.dir ? base64Decode(params.dir) : undefined if (directory === currentDir && permission.sessionID === currentSession) return
const currentSession = params.id
if (directory === currentDir && perm.sessionID === currentSession) return
if (directory === currentDir && session?.parentID === currentSession) return if (directory === currentDir && session?.parentID === currentSession) return
const existingToastId = toastBySession.get(sessionKey) const sessionKey = `${directory}:${permission.sessionID}`
if (existingToastId !== undefined) { if (seenSessions.has(sessionKey)) return
toaster.dismiss(existingToastId) seenSessions.add(sessionKey)
}
const toastId = showToast({ const toastId = showToast({
persistent: true, persistent: true,
@@ -229,7 +213,7 @@ export default function Layout(props: ParentProps) {
if (toastId !== undefined) { if (toastId !== undefined) {
toaster.dismiss(toastId) toaster.dismiss(toastId)
toastBySession.delete(sessionKey) toastBySession.delete(sessionKey)
alertedAtBySession.delete(sessionKey) seenSessions.delete(sessionKey)
} }
const [store] = globalSync.child(currentDir) const [store] = globalSync.child(currentDir)
const childSessions = store.session.filter((s) => s.parentID === currentSession) const childSessions = store.session.filter((s) => s.parentID === currentSession)
@@ -239,7 +223,7 @@ export default function Layout(props: ParentProps) {
if (childToastId !== undefined) { if (childToastId !== undefined) {
toaster.dismiss(childToastId) toaster.dismiss(childToastId)
toastBySession.delete(childKey) toastBySession.delete(childKey)
alertedAtBySession.delete(childKey) seenSessions.delete(childKey)
} }
} }
}) })
@@ -460,13 +444,13 @@ export default function Layout(props: ParentProps) {
if (!directory) return if (!directory) return
const lastSession = store.lastSession[directory] const lastSession = store.lastSession[directory]
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`) navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
layout.mobileSidebar.hide() mobileSidebar.hide()
} }
function navigateToSession(session: Session | undefined) { function navigateToSession(session: Session | undefined) {
if (!session) return if (!session) return
navigate(`/${params.dir}/session/${session?.id}`) navigate(`/${params.dir}/session/${session?.id}`)
layout.mobileSidebar.hide() mobileSidebar.hide()
} }
function openProject(directory: string, navigate = true) { function openProject(directory: string, navigate = true) {
@@ -724,13 +708,17 @@ export default function Layout(props: ParentProps) {
</A> </A>
</Tooltip> </Tooltip>
<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"> <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">
<TooltipKeybind <Tooltip
placement={props.mobile ? "bottom" : "right"} placement={props.mobile ? "bottom" : "right"}
title="Archive session" value={
keybind={command.keybind("session.archive")} <div class="flex items-center gap-2">
<span>Archive session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
</div>
}
> >
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} /> <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</TooltipKeybind> </Tooltip>
</div> </div>
</div> </div>
</> </>
@@ -798,9 +786,17 @@ export default function Layout(props: ParentProps) {
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
</DropdownMenu> </DropdownMenu>
<TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}> <Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" /> <IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
</TooltipKeybind> </Tooltip>
</div> </div>
</Button> </Button>
<Collapsible.Content> <Collapsible.Content>
@@ -878,16 +874,15 @@ export default function Layout(props: ParentProps) {
<> <>
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden"> <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
<Show when={!sidebarProps.mobile}> <Show when={!sidebarProps.mobile}>
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region> <Tooltip
<Mark class="shrink-0" />
</A>
</Show>
<Show when={!sidebarProps.mobile}>
<TooltipKeybind
class="shrink-0" class="shrink-0"
placement="right" placement="right"
title="Toggle sidebar" value={
keybind={command.keybind("sidebar.toggle")} <div class="flex items-center gap-2">
<span>Toggle sidebar</span>
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
</div>
}
inactive={expanded()} inactive={expanded()}
> >
<Button <Button
@@ -919,7 +914,7 @@ export default function Layout(props: ParentProps) {
</div> </div>
</Show> </Show>
</Button> </Button>
</TooltipKeybind> </Tooltip>
</Show> </Show>
<DragDropProvider <DragDropProvider
onDragStart={handleDragStart} onDragStart={handleDragStart}
@@ -1023,23 +1018,21 @@ export default function Layout(props: ParentProps) {
return ( return (
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"> <div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Header
navigateToProject={navigateToProject}
navigateToSession={navigateToSession}
onMobileMenuToggle={mobileSidebar.toggle}
/>
<div class="flex-1 min-h-0 flex"> <div class="flex-1 min-h-0 flex">
<div <div
classList={{ classList={{
"hidden xl:block": true, "hidden xl:flex": true,
"relative shrink-0": true, "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
"flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
}} }}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }} style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
> >
<div
classList={{
"@container w-full h-full pb-5 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
}}
>
<SidebarContent />
</div>
<Show when={layout.sidebar.opened()}> <Show when={layout.sidebar.opened()}>
<ResizeHandle <ResizeHandle
direction="horizontal" direction="horizontal"
@@ -1051,23 +1044,24 @@ export default function Layout(props: ParentProps) {
onCollapse={layout.sidebar.close} onCollapse={layout.sidebar.close}
/> />
</Show> </Show>
<SidebarContent />
</div> </div>
<div class="xl:hidden"> <div class="xl:hidden">
<div <div
classList={{ classList={{
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true, "fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(), "opacity-100 pointer-events-auto": mobileSidebar.open(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(), "opacity-0 pointer-events-none": !mobileSidebar.open(),
}} }}
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide() if (e.target === e.currentTarget) mobileSidebar.hide()
}} }}
/> />
<div <div
classList={{ classList={{
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true, "@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(), "translate-x-0": mobileSidebar.open(),
"-translate-x-full": !layout.mobileSidebar.opened(), "-translate-x-full": !mobileSidebar.open(),
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.0.224", "version": "1.0.218",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
+5 -5
View File
@@ -9,8 +9,8 @@ export const config = {
github: { github: {
repoUrl: "https://github.com/sst/opencode", repoUrl: "https://github.com/sst/opencode",
starsFormatted: { starsFormatted: {
compact: "45K", compact: "41K",
full: "45,000", full: "41,000",
}, },
}, },
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page) // Static stats (used on landing page)
stats: { stats: {
contributors: "500", contributors: "450",
commits: "6,500", commits: "6,000",
monthlyUsers: "650,000", monthlyUsers: "400,000",
}, },
} as const } as const
@@ -1,5 +1,5 @@
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router" import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, Show, createMemo } from "solid-js" import { createEffect, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js" import { Billing } from "@opencode-ai/console-core/billing.js"
@@ -68,12 +68,6 @@ export function ReloadSection() {
reloadTrigger: "", reloadTrigger: "",
}) })
const processingFee = createMemo(() => {
const reloadAmount = billingInfo()?.reloadAmount
if (!reloadAmount) return "0.00"
return (((reloadAmount + 0.3) / 0.956) * 0.044 + 0.3).toFixed(2)
})
createEffect(() => { createEffect(() => {
if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) { if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
setStore("show", false) setStore("show", false)
@@ -110,8 +104,8 @@ export function ReloadSection() {
} }
> >
<p> <p>
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "} Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+$1.23 processing fee)
processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>. when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
</p> </p>
</Show> </Show>
<button data-color="primary" type="button" onClick={() => show()}> <button data-color="primary" type="button" onClick={() => show()}>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.224", "version": "1.0.218",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.224", "version": "1.0.218",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.224", "version": "1.0.218",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.0.224", "version": "1.0.218",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo -b", "typecheck": "tsgo -b",
+1 -1
View File
@@ -6,7 +6,7 @@ const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE
const sidecarConfig = getCurrentSidecar(RUST_TARGET) const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}` const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`
await $`cd ../opencode && bun run build --single` await $`cd ../opencode && bun run build --single`
+16 -73
View File
@@ -57,71 +57,19 @@ const platform: Platform = {
}, },
openLink(url: string) { openLink(url: string) {
void shellOpen(url).catch(() => undefined) shellOpen(url)
}, },
storage: (name = "default.dat") => { storage: (name = "default.dat") => {
type StoreLike = { const api: AsyncStorage = {
get(key: string): Promise<string | null | undefined>
set(key: string, value: string): Promise<unknown>
delete(key: string): Promise<unknown>
clear(): Promise<unknown>
keys(): Promise<string[]>
length(): Promise<number>
}
const memory = () => {
const data = new Map<string, string>()
const store: StoreLike = {
get: async (key) => data.get(key),
set: async (key, value) => {
data.set(key, value)
},
delete: async (key) => {
data.delete(key)
},
clear: async () => {
data.clear()
},
keys: async () => Array.from(data.keys()),
length: async () => data.size,
}
return store
}
const api: AsyncStorage & { _store: Promise<StoreLike> | null; _getStore: () => Promise<StoreLike> } = {
_store: null, _store: null,
_getStore: async () => { _getStore: async () => api._store || (api._store = Store.load(name)),
if (api._store) return api._store getItem: async (key: string) => (await (await api._getStore()).get(key)) ?? null,
api._store = Store.load(name).catch(() => memory()) setItem: async (key: string, value: string) => await (await api._getStore()).set(key, value),
return api._store removeItem: async (key: string) => await (await api._getStore()).delete(key),
}, clear: async () => await (await api._getStore()).clear(),
getItem: async (key: string) => { key: async (index: number) => (await (await api._getStore()).keys())[index],
const store = await api._getStore() getLength: async () => (await api._getStore()).length(),
const value = await store.get(key).catch(() => null)
if (value === undefined) return null
return value
},
setItem: async (key: string, value: string) => {
const store = await api._getStore()
await store.set(key, value).catch(() => undefined)
},
removeItem: async (key: string) => {
const store = await api._getStore()
await store.delete(key).catch(() => undefined)
},
clear: async () => {
const store = await api._getStore()
await store.clear().catch(() => undefined)
},
key: async (index: number) => {
const store = await api._getStore()
return (await store.keys().catch(() => []))[index]
},
getLength: async () => {
const store = await api._getStore()
return await store.length().catch(() => 0)
},
get length() { get length() {
return api.getLength() return api.getLength()
}, },
@@ -131,25 +79,20 @@ const platform: Platform = {
checkUpdate: async () => { checkUpdate: async () => {
if (!UPDATER_ENABLED) return { updateAvailable: false } if (!UPDATER_ENABLED) return { updateAvailable: false }
const next = await check().catch(() => null) update = await check()
if (!next) return { updateAvailable: false } if (!update) return { updateAvailable: false }
const ok = await next await update.download()
.download() return { updateAvailable: true, version: update.version }
.then(() => true)
.catch(() => false)
if (!ok) return { updateAvailable: false }
update = next
return { updateAvailable: true, version: next.version }
}, },
update: async () => { update: async () => {
if (!UPDATER_ENABLED || !update) return if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined) if (ostype() === "windows") await invoke("kill_sidecar")
await update.install().catch(() => undefined) await update.install()
}, },
restart: async () => { restart: async () => {
await invoke("kill_sidecar").catch(() => undefined) await invoke("kill_sidecar")
await relaunch() await relaunch()
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.0.224", "version": "1.0.218",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -33,7 +33,7 @@ const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m)
const ClientOnlyWorkerPoolProvider = clientOnly(() => const ClientOnlyWorkerPoolProvider = clientOnly(() =>
import("@opencode-ai/ui/pierre/worker").then((m) => ({ import("@opencode-ai/ui/pierre/worker").then((m) => ({
default: (props: { children: any }) => ( default: (props: { children: any }) => (
<WorkerPoolProvider pools={m.getWorkerPools()}>{props.children}</WorkerPoolProvider> <WorkerPoolProvider pool={m.workerPool}>{props.children}</WorkerPoolProvider>
), ),
})), })),
) )
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.0.224" version = "1.0.218"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/sst/opencode" repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-darwin-arm64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-darwin-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-linux-arm64.tar.gz" archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-linux-x64.tar.gz" archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.224/opencode-windows-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.218/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.224", "version": "1.0.218",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.0.224", "version": "1.0.218",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -89,7 +89,7 @@
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
"bonjour-service": "1.3.0", "bonjour-service": "1.3.0",
"bun-pty": "0.4.4", "bun-pty": "0.4.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
@@ -58,7 +58,7 @@ if (!Script.preview) {
"provides=('opencode')", "provides=('opencode')",
"conflicts=('opencode-bin')", "conflicts=('opencode-bin')",
"depends=('ripgrep')", "depends=('ripgrep')",
"makedepends=('git' 'bun' 'go')", "makedepends=('git' 'bun-bin' 'go')",
"", "",
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`, `source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`,
`sha256sums=('SKIP')`, `sha256sums=('SKIP')`,
+17 -14
View File
@@ -71,19 +71,19 @@ export namespace ACP {
this.config.sdk.event.subscribe({ directory }).then(async (events) => { this.config.sdk.event.subscribe({ directory }).then(async (events) => {
for await (const event of events.stream) { for await (const event of events.stream) {
switch (event.type) { switch (event.type) {
case "permission.asked": case "permission.updated":
try { try {
const permission = event.properties const permission = event.properties
const res = await this.connection const res = await this.connection
.requestPermission({ .requestPermission({
sessionId, sessionId,
toolCall: { toolCall: {
toolCallId: permission.tool?.callID ?? permission.id, toolCallId: permission.callID ?? permission.id,
status: "pending", status: "pending",
title: permission.permission, title: permission.title,
rawInput: permission.metadata, rawInput: permission.metadata,
kind: toToolKind(permission.permission), kind: toToolKind(permission.type),
locations: toLocations(permission.permission, permission.metadata), locations: toLocations(permission.type, permission.metadata),
}, },
options, options,
}) })
@@ -93,25 +93,28 @@ export namespace ACP {
permissionID: permission.id, permissionID: permission.id,
sessionID: permission.sessionID, sessionID: permission.sessionID,
}) })
await this.config.sdk.permission.reply({ await this.config.sdk.permission.respond({
requestID: permission.id, sessionID: permission.sessionID,
reply: "reject", permissionID: permission.id,
response: "reject",
directory, directory,
}) })
return return
}) })
if (!res) return if (!res) return
if (res.outcome.outcome !== "selected") { if (res.outcome.outcome !== "selected") {
await this.config.sdk.permission.reply({ await this.config.sdk.permission.respond({
requestID: permission.id, sessionID: permission.sessionID,
reply: "reject", permissionID: permission.id,
response: "reject",
directory, directory,
}) })
return return
} }
await this.config.sdk.permission.reply({ await this.config.sdk.permission.respond({
requestID: permission.id, sessionID: permission.sessionID,
reply: res.outcome.optionId as "once" | "always" | "reject", permissionID: permission.id,
response: res.outcome.optionId as "once" | "always" | "reject",
directory, directory,
}) })
} catch (err) { } catch (err) {
+237 -85
View File
@@ -4,14 +4,16 @@ import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai" import { generateObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system" import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { mergeDeep } from "remeda"
import { Log } from "../util/log"
const log = Log.create({ service: "agent" })
import PROMPT_GENERATE from "./generate.txt" import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt" import PROMPT_TITLE from "./prompt/title.txt"
import { PermissionNext } from "@/permission/next"
import { mergeDeep, pipe, sortBy, values } from "remeda"
export namespace Agent { export namespace Agent {
export const Info = z export const Info = z
@@ -21,10 +23,18 @@ export namespace Agent {
mode: z.enum(["subagent", "primary", "all"]), mode: z.enum(["subagent", "primary", "all"]),
native: z.boolean().optional(), native: z.boolean().optional(),
hidden: z.boolean().optional(), hidden: z.boolean().optional(),
default: z.boolean().optional(),
topP: z.number().optional(), topP: z.number().optional(),
temperature: z.number().optional(), temperature: z.number().optional(),
color: z.string().optional(), color: z.string().optional(),
permission: PermissionNext.Ruleset, permission: z.object({
edit: Config.Permission,
bash: z.record(z.string(), Config.Permission),
skill: z.record(z.string(), Config.Permission),
webfetch: Config.Permission.optional(),
doom_loop: Config.Permission.optional(),
external_directory: Config.Permission.optional(),
}),
model: z model: z
.object({ .object({
modelID: z.string(), modelID: z.string(),
@@ -32,8 +42,9 @@ export namespace Agent {
}) })
.optional(), .optional(),
prompt: z.string().optional(), prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()),
options: z.record(z.string(), z.any()), options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(), maxSteps: z.number().int().positive().optional(),
}) })
.meta({ .meta({
ref: "Agent", ref: "Agent",
@@ -42,74 +53,113 @@ export namespace Agent {
const state = Instance.state(async () => { const state = Instance.state(async () => {
const cfg = await Config.get() const cfg = await Config.get()
const defaultTools = cfg.tools ?? {}
const defaults = PermissionNext.fromConfig({ const defaultPermission: Info["permission"] = {
"*": "allow", edit: "allow",
bash: {
"*": "allow",
},
skill: {
"*": "allow",
},
webfetch: "allow",
doom_loop: "ask", doom_loop: "ask",
external_directory: "ask", external_directory: "ask",
}) }
const user = PermissionNext.fromConfig(cfg.permission ?? {}) const agentPermission = mergeAgentPermissions(defaultPermission, cfg.permission ?? {})
const planPermission = mergeAgentPermissions(
{
edit: "deny",
bash: {
"cut*": "allow",
"diff*": "allow",
"du*": "allow",
"file *": "allow",
"find * -delete*": "ask",
"find * -exec*": "ask",
"find * -fprint*": "ask",
"find * -fls*": "ask",
"find * -fprintf*": "ask",
"find * -ok*": "ask",
"find *": "allow",
"git diff*": "allow",
"git log*": "allow",
"git show*": "allow",
"git status*": "allow",
"git branch": "allow",
"git branch -v": "allow",
"grep*": "allow",
"head*": "allow",
"less*": "allow",
"ls*": "allow",
"more*": "allow",
"pwd*": "allow",
"rg*": "allow",
"sort --output=*": "ask",
"sort -o *": "ask",
"sort*": "allow",
"stat*": "allow",
"tail*": "allow",
"tree -o *": "ask",
"tree*": "allow",
"uniq*": "allow",
"wc*": "allow",
"whereis*": "allow",
"which*": "allow",
"*": "ask",
},
webfetch: "allow",
},
cfg.permission ?? {},
)
const result: Record<string, Info> = { const result: Record<string, Info> = {
build: { build: {
name: "build", name: "build",
tools: { ...defaultTools },
options: {}, options: {},
permission: PermissionNext.merge(defaults, user), permission: agentPermission,
mode: "primary", mode: "primary",
native: true, native: true,
}, },
plan: { plan: {
name: "plan", name: "plan",
options: {}, options: {},
permission: PermissionNext.merge( permission: planPermission,
defaults, tools: {
PermissionNext.fromConfig({ ...defaultTools,
edit: { },
"*": "deny",
".opencode/plan/*.md": "allow",
},
}),
user,
),
mode: "primary", mode: "primary",
native: true, native: true,
}, },
general: { general: {
name: "general", name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: PermissionNext.merge( tools: {
defaults, todoread: false,
PermissionNext.fromConfig({ todowrite: false,
todoread: "deny", ...defaultTools,
todowrite: "deny", },
}),
user,
),
options: {}, options: {},
permission: agentPermission,
mode: "subagent", mode: "subagent",
native: true, native: true,
hidden: true, hidden: true,
}, },
explore: { explore: {
name: "explore", name: "explore",
permission: PermissionNext.merge( tools: {
defaults, todoread: false,
PermissionNext.fromConfig({ todowrite: false,
"*": "deny", edit: false,
grep: "allow", write: false,
glob: "allow", ...defaultTools,
list: "allow", },
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE, prompt: PROMPT_EXPLORE,
options: {}, options: {},
permission: agentPermission,
mode: "subagent", mode: "subagent",
native: true, native: true,
}, },
@@ -119,14 +169,11 @@ export namespace Agent {
native: true, native: true,
hidden: true, hidden: true,
prompt: PROMPT_COMPACTION, prompt: PROMPT_COMPACTION,
permission: PermissionNext.merge( tools: {
defaults, "*": false,
PermissionNext.fromConfig({ },
"*": "deny",
}),
user,
),
options: {}, options: {},
permission: agentPermission,
}, },
title: { title: {
name: "title", name: "title",
@@ -134,14 +181,9 @@ export namespace Agent {
options: {}, options: {},
native: true, native: true,
hidden: true, hidden: true,
permission: PermissionNext.merge( permission: agentPermission,
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE, prompt: PROMPT_TITLE,
tools: {},
}, },
summary: { summary: {
name: "summary", name: "summary",
@@ -149,17 +191,11 @@ export namespace Agent {
options: {}, options: {},
native: true, native: true,
hidden: true, hidden: true,
permission: PermissionNext.merge( permission: agentPermission,
defaults,
PermissionNext.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY, prompt: PROMPT_SUMMARY,
tools: {},
}, },
} }
for (const [key, value] of Object.entries(cfg.agent ?? {})) { for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) { if (value.disable) {
delete result[key] delete result[key]
@@ -170,22 +206,74 @@ export namespace Agent {
item = result[key] = { item = result[key] = {
name: key, name: key,
mode: "all", mode: "all",
permission: PermissionNext.merge(defaults, user), permission: agentPermission,
options: {}, options: {},
tools: {},
native: false, native: false,
} }
if (value.model) item.model = Provider.parseModel(value.model) const {
item.prompt = value.prompt ?? item.prompt name,
item.description = value.description ?? item.description model,
item.temperature = value.temperature ?? item.temperature prompt,
item.topP = value.top_p ?? item.topP tools,
item.mode = value.mode ?? item.mode description,
item.color = value.color ?? item.color temperature,
item.name = value.options?.name ?? item.name top_p,
item.steps = value.steps ?? item.steps mode,
item.options = mergeDeep(item.options, value.options ?? {}) permission,
item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {})) color,
maxSteps,
...extra
} = value
item.options = {
...item.options,
...extra,
}
if (model) item.model = Provider.parseModel(model)
if (prompt) item.prompt = prompt
if (tools)
item.tools = {
...item.tools,
...tools,
}
item.tools = {
...defaultTools,
...item.tools,
}
if (description) item.description = description
if (temperature != undefined) item.temperature = temperature
if (top_p != undefined) item.topP = top_p
if (mode) item.mode = mode
if (color) item.color = color
// just here for consistency & to prevent it from being added as an option
if (name) item.name = name
if (maxSteps != undefined) item.maxSteps = maxSteps
if (permission ?? cfg.permission) {
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
}
} }
// Mark the default agent
const defaultName = cfg.default_agent ?? "build"
const defaultCandidate = result[defaultName]
if (defaultCandidate && defaultCandidate.mode !== "subagent") {
defaultCandidate.default = true
} else {
// Fall back to "build" if configured default is invalid
if (result["build"]) {
result["build"].default = true
}
}
const hasPrimaryAgents = Object.values(result).filter((a) => a.mode !== "subagent" && !a.hidden).length > 0
if (!hasPrimaryAgents) {
throw new Config.InvalidError({
path: "config",
message: "No primary agents are available. Please configure at least one agent with mode 'primary' or 'all'.",
})
}
return result return result
}) })
@@ -194,16 +282,13 @@ export namespace Agent {
} }
export async function list() { export async function list() {
const cfg = await Config.get() return state().then((x) => Object.values(x))
return pipe(
await state(),
values(),
sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
)
} }
export async function defaultAgent() { export async function defaultAgent(): Promise<string> {
return state().then((x) => Object.keys(x)[0]) const agents = await state()
const defaultCandidate = Object.values(agents).find((a) => a.default)
return defaultCandidate?.name ?? "build"
} }
export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
@@ -244,3 +329,70 @@ export namespace Agent {
return result.object return result.object
} }
} }
function mergeAgentPermissions(basePermission: any, overridePermission: any): Agent.Info["permission"] {
if (typeof basePermission.bash === "string") {
basePermission.bash = {
"*": basePermission.bash,
}
}
if (typeof overridePermission.bash === "string") {
overridePermission.bash = {
"*": overridePermission.bash,
}
}
if (typeof basePermission.skill === "string") {
basePermission.skill = {
"*": basePermission.skill,
}
}
if (typeof overridePermission.skill === "string") {
overridePermission.skill = {
"*": overridePermission.skill,
}
}
const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
let mergedBash
if (merged.bash) {
if (typeof merged.bash === "string") {
mergedBash = {
"*": merged.bash,
}
} else if (typeof merged.bash === "object") {
mergedBash = mergeDeep(
{
"*": "allow",
},
merged.bash,
)
}
}
let mergedSkill
if (merged.skill) {
if (typeof merged.skill === "string") {
mergedSkill = {
"*": merged.skill,
}
} else if (typeof merged.skill === "object") {
mergedSkill = mergeDeep(
{
"*": "allow",
},
merged.skill,
)
}
}
const result: Agent.Info["permission"] = {
edit: merged.edit ?? "allow",
webfetch: merged.webfetch ?? "allow",
bash: mergedBash ?? { "*": "allow" },
skill: mergedSkill ?? { "*": "allow" },
doom_loop: merged.doom_loop,
external_directory: merged.external_directory,
}
return result
}
+1 -17
View File
@@ -73,24 +73,8 @@ export namespace BunProc {
}) })
if (parsed.dependencies[pkg] === version) return mod if (parsed.dependencies[pkg] === version) return mod
const proxied = !!(
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.https_proxy
)
// Build command arguments // Build command arguments
const args = [ const args = ["add", "--force", "--exact", "--cwd", Global.Path.cache, pkg + "@" + version]
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution: // Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically // - If .npmrc files exist, Bun will use them automatically
+7
View File
@@ -9,6 +9,13 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network"
const log = Log.create({ service: "acp-command" }) const log = Log.create({ service: "acp-command" })
process.on("unhandledRejection", (reason, promise) => {
log.error("Unhandled rejection", {
promise,
reason,
})
})
export const AcpCommand = cmd({ export const AcpCommand = cmd({
command: "acp", command: "acp",
describe: "start ACP (Agent Client Protocol) server", describe: "start ACP (Agent Client Protocol) server",
+1 -2
View File
@@ -241,8 +241,7 @@ const AgentListCommand = cmd({
}) })
for (const agent of sortedAgents) { for (const agent of sortedAgents) {
process.stdout.write(`${agent.name} (${agent.mode})` + EOL) process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL)
} }
}, },
}) })
+1 -1
View File
@@ -36,7 +36,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):
const method = plugin.auth.methods[index] const method = plugin.auth.methods[index]
// Handle prompts for all auth types // Handle prompts for all auth types
await Bun.sleep(10) await new Promise((resolve) => setTimeout(resolve, 10))
const inputs: Record<string, string> = {} const inputs: Record<string, string> = {}
if (method.prompts) { if (method.prompts) {
for (const prompt of method.prompts) { for (const prompt of method.prompts) {
@@ -1,28 +0,0 @@
import { EOL } from "os"
import { basename } from "path"
import { Agent } from "../../../agent/agent"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const AgentCommand = cmd({
command: "agent <name>",
builder: (yargs) =>
yargs.positional("name", {
type: "string",
demandOption: true,
description: "Agent name",
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const agentName = args.name as string
const agent = await Agent.get(agentName)
if (!agent) {
process.stderr.write(
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
)
process.exit(1)
}
process.stdout.write(JSON.stringify(agent, null, 2) + EOL)
})
},
})
@@ -8,7 +8,6 @@ import { RipgrepCommand } from "./ripgrep"
import { ScrapCommand } from "./scrap" import { ScrapCommand } from "./scrap"
import { SkillCommand } from "./skill" import { SkillCommand } from "./skill"
import { SnapshotCommand } from "./snapshot" import { SnapshotCommand } from "./snapshot"
import { AgentCommand } from "./agent"
export const DebugCommand = cmd({ export const DebugCommand = cmd({
command: "debug", command: "debug",
@@ -21,7 +20,6 @@ export const DebugCommand = cmd({
.command(ScrapCommand) .command(ScrapCommand)
.command(SkillCommand) .command(SkillCommand)
.command(SnapshotCommand) .command(SnapshotCommand)
.command(AgentCommand)
.command(PathsCommand) .command(PathsCommand)
.command({ .command({
command: "wait", command: "wait",
+1 -1
View File
@@ -348,7 +348,7 @@ export const GithubInstallCommand = cmd({
} }
retries++ retries++
await Bun.sleep(1000) await new Promise((resolve) => setTimeout(resolve, 1000))
} while (true) } while (true)
s.stop("Installed GitHub app") s.stop("Installed GitHub app")
+2 -2
View File
@@ -31,9 +31,9 @@ export const ImportCommand = cmd({
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://") const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
if (isUrl) { if (isUrl) {
const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/share\/([a-zA-Z0-9_-]+)/) const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
if (!urlMatch) { if (!urlMatch) {
process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/share/<slug>`) process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/s/<slug>`)
process.stdout.write(EOL) process.stdout.write(EOL)
return return
} }
+3 -3
View File
@@ -202,14 +202,14 @@ export const RunCommand = cmd({
break break
} }
if (event.type === "permission.asked") { if (event.type === "permission.updated") {
const permission = event.properties const permission = event.properties
if (permission.sessionID !== sessionID) continue if (permission.sessionID !== sessionID) continue
const result = await select({ const result = await select({
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`, message: `Permission required to run: ${permission.title}`,
options: [ options: [
{ value: "once", label: "Allow once" }, { value: "once", label: "Allow once" },
{ value: "always", label: "Always allow: " + permission.always.join(", ") }, { value: "always", label: "Always allow" },
{ value: "reject", label: "Reject" }, { value: "reject", label: "Reject" },
], ],
initialValue: "once", initialValue: "once",
+1 -30
View File
@@ -4,36 +4,7 @@ import { Session } from "../../session"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { UI } from "../ui" import { UI } from "../ui"
import { Locale } from "../../util/locale" import { Locale } from "../../util/locale"
import { Flag } from "../../flag/flag"
import { EOL } from "os" import { EOL } from "os"
import path from "path"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
if (process.platform !== "win32") {
return ["less", ...lessOptions]
}
// user could have less installed via other options
const lessOnPath = Bun.which("less")
if (lessOnPath) {
if (Bun.file(lessOnPath).size) return [lessOnPath, ...lessOptions]
}
if (Flag.OPENCODE_GIT_BASH_PATH) {
const less = path.join(Flag.OPENCODE_GIT_BASH_PATH, "..", "..", "usr", "bin", "less.exe")
if (Bun.file(less).size) return [less, ...lessOptions]
}
const git = Bun.which("git")
if (git) {
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
if (Bun.file(less).size) return [less, ...lessOptions]
}
// Fall back to Windows built-in more (via cmd.exe)
return ["cmd", "/c", "more"]
}
export const SessionCommand = cmd({ export const SessionCommand = cmd({
command: "session", command: "session",
@@ -87,7 +58,7 @@ export const SessionListCommand = cmd({
if (shouldPaginate) { if (shouldPaginate) {
const proc = Bun.spawn({ const proc = Bun.spawn({
cmd: pagerCmd(), cmd: ["less", "-R", "-S"],
stdin: "pipe", stdin: "pipe",
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
+4 -12
View File
@@ -118,12 +118,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
return Date.now() - days * MS_IN_DAY return Date.now() - days * MS_IN_DAY
})() })()
const windowDays = (() => {
if (days === undefined) return
if (days === 0) return 1
return days
})()
let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
if (projectFilter !== undefined) { if (projectFilter !== undefined) {
@@ -165,7 +159,6 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
} }
if (filteredSessions.length === 0) { if (filteredSessions.length === 0) {
stats.days = windowDays ?? 0
return stats return stats
} }
@@ -238,7 +231,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning, sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
sessionToolUsage, sessionToolUsage,
sessionModelUsage, sessionModelUsage,
earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, earliestTime: session.time.created,
latestTime: session.time.updated, latestTime: session.time.updated,
} }
}) })
@@ -278,14 +271,13 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
} }
} }
const rangeDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY)) const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY))
const effectiveDays = windowDays ?? rangeDays
stats.dateRange = { stats.dateRange = {
earliest: earliestTime, earliest: earliestTime,
latest: latestTime, latest: latestTime,
} }
stats.days = effectiveDays stats.days = actualDays
stats.costPerDay = stats.totalCost / effectiveDays stats.costPerDay = stats.totalCost / actualDays
const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning const totalTokens = stats.totalTokens.input + stats.totalTokens.output + stats.totalTokens.reasoning
stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0 stats.tokensPerSession = filteredSessions.length > 0 ? totalTokens / filteredSessions.length : 0
sessionTotalTokens.sort((a, b) => a - b) sessionTotalTokens.sort((a, b) => a - b)
+1 -15
View File
@@ -4,6 +4,7 @@ import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route" import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { Global } from "@/global"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
@@ -33,7 +34,6 @@ import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args" import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open" import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt" import { PromptRefProvider, usePromptRef } from "./context/prompt"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
@@ -476,20 +476,6 @@ function App() {
dialog.clear() dialog.clear()
}, },
}, },
{
title: "Write heap snapshot",
category: "System",
value: "app.heap_snapshot",
onSelect: (dialog) => {
const path = writeHeapSnapshot()
toast.show({
variant: "info",
message: `Heap snapshot written to ${path}`,
duration: 5000,
})
dialog.clear()
},
},
{ {
title: "Suspend terminal", title: "Suspend terminal",
value: "terminal.suspend", value: "terminal.suspend",
@@ -259,7 +259,7 @@ export function Autocomplete(props: {
const s = session() const s = session()
for (const command of sync.data.command) { for (const command of sync.data.command) {
results.push({ results.push({
display: "/" + command.name + (command.mcp ? " (MCP)" : ""), display: "/" + command.name,
description: command.description, description: command.description,
onSelect: () => { onSelect: () => {
const newText = "/" + command.name + " " const newText = "/" + command.name + " "
@@ -33,7 +33,6 @@ import { useKV } from "../../context/kv"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
visible?: boolean
disabled?: boolean disabled?: boolean
onSubmit?: () => void onSubmit?: () => void
ref?: (ref: PromptRef) => void ref?: (ref: PromptRef) => void
@@ -203,11 +202,7 @@ export function Prompt(props: PromptProps) {
syncedSessionID = sessionID syncedSessionID = sessionID
// Only set agent if it's a primary agent (not a subagent) if (msg.agent) local.agent.set(msg.agent)
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
if (msg.agent && isPrimaryAgent) {
local.agent.set(msg.agent)
}
if (msg.model) local.model.set(msg.model) if (msg.model) local.model.set(msg.model)
if (msg.variant) local.model.variant.set(msg.variant) if (msg.variant) local.model.variant.set(msg.variant)
} }
@@ -374,8 +369,7 @@ export function Prompt(props: PromptProps) {
}) })
createEffect(() => { createEffect(() => {
if (props.visible !== false) input?.focus() input.focus()
if (props.visible === false) input?.blur()
}) })
onMount(() => { onMount(() => {
@@ -800,7 +794,7 @@ export function Prompt(props: PromptProps) {
agentStyleId={agentStyleId} agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId} promptPartTypeId={() => promptPartTypeId}
/> />
<box ref={(r) => (anchor = r)} visible={props.visible !== false}> <box ref={(r) => (anchor = r)}>
<box <box
border={["left"]} border={["left"]}
borderColor={highlight()} borderColor={highlight()}
@@ -28,7 +28,7 @@ export const TIPS = [
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.", "Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
"Press {highlight}Escape{/highlight} to stop the AI mid-response.", "Press {highlight}Escape{/highlight} to stop the AI mid-response.",
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.", "Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
"Use {highlight}@<agent-name>{/highlight} in prompts to invoke specialized subagents.", "Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.", "Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.", "Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.", "Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
@@ -38,7 +38,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore<{ const [agentStore, setAgentStore] = createStore<{
current: string current: string
}>({ }>({
current: agents()[0].name, current: agents().find((x) => x.default)?.name ?? agents()[0].name,
}) })
const { theme } = useTheme() const { theme } = useTheme()
const colors = createMemo(() => [ const colors = createMemo(() => [
@@ -7,7 +7,7 @@ import type {
Config, Config,
Todo, Todo,
Command, Command,
PermissionRequest, Permission,
LspStatus, LspStatus,
McpStatus, McpStatus,
FormatterStatus, FormatterStatus,
@@ -39,7 +39,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: Agent[] agent: Agent[]
command: Command[] command: Command[]
permission: { permission: {
[sessionID: string]: PermissionRequest[] [sessionID: string]: Permission[]
} }
config: Config config: Config
session: Session[] session: Session[]
@@ -97,13 +97,30 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.event.listen((e) => { sdk.event.listen((e) => {
const event = e.details const event = e.details
switch (event.type) { switch (event.type) {
case "server.instance.disposed": case "permission.updated": {
bootstrap() const permissions = store.permission[event.properties.sessionID]
if (!permissions) {
setStore("permission", event.properties.sessionID, [event.properties])
break
}
const match = Binary.search(permissions, event.properties.id, (p) => p.id)
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
if (match.found) {
draft[match.index] = event.properties
return
}
draft.push(event.properties)
}),
)
break break
}
case "permission.replied": { case "permission.replied": {
const requests = store.permission[event.properties.sessionID] const permissions = store.permission[event.properties.sessionID]
if (!requests) break const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break if (!match.found) break
setStore( setStore(
"permission", "permission",
@@ -115,28 +132,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break break
} }
case "permission.asked": {
const request = event.properties
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
break
}
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("permission", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"permission",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}
case "todo.updated": case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos) setStore("todo", event.properties.sessionID, event.properties.todos)
break break
@@ -263,26 +258,28 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const args = useArgs() const args = useArgs()
async function bootstrap() { async function bootstrap() {
console.log("bootstrapping") const sessionListPromise = sdk.client.session.list().then((x) =>
const sessionListPromise = sdk.client.session setStore(
.list() "session",
.then((x) => setStore("session", reconcile((x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))))) (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
),
)
// blocking - include session.list when continuing a session // blocking - include session.list when continuing a session
const blockingRequests: Promise<unknown>[] = [ const blockingRequests: Promise<unknown>[] = [
sdk.client.config.providers({}, { throwOnError: true }).then((x) => { sdk.client.config.providers({}, { throwOnError: true }).then((x) => {
batch(() => { batch(() => {
setStore("provider", reconcile(x.data!.providers)) setStore("provider", x.data!.providers)
setStore("provider_default", reconcile(x.data!.default)) setStore("provider_default", x.data!.default)
}) })
}), }),
sdk.client.provider.list({}, { throwOnError: true }).then((x) => { sdk.client.provider.list({}, { throwOnError: true }).then((x) => {
batch(() => { batch(() => {
setStore("provider_next", reconcile(x.data!)) setStore("provider_next", x.data!)
}) })
}), }),
sdk.client.app.agents({}, { throwOnError: true }).then((x) => setStore("agent", reconcile(x.data ?? []))), sdk.client.app.agents({}, { throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
sdk.client.config.get({}, { throwOnError: true }).then((x) => setStore("config", reconcile(x.data!))), sdk.client.config.get({}, { throwOnError: true }).then((x) => setStore("config", x.data!)),
...(args.continue ? [sessionListPromise] : []), ...(args.continue ? [sessionListPromise] : []),
] ]
@@ -292,16 +289,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
// non-blocking // non-blocking
Promise.all([ Promise.all([
...(args.continue ? [] : [sessionListPromise]), ...(args.continue ? [] : [sessionListPromise]),
sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
sdk.client.session.status().then((x) => { sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
setStore("session_status", reconcile(x.data!)) sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
}), sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.path.get().then((x) => setStore("path", x.data!)),
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
]).then(() => { ]).then(() => {
setStore("status", "complete") setStore("status", "complete")
}) })
@@ -22,7 +22,6 @@ import mercury from "./theme/mercury.json" with { type: "json" }
import monokai from "./theme/monokai.json" with { type: "json" } import monokai from "./theme/monokai.json" with { type: "json" }
import nightowl from "./theme/nightowl.json" with { type: "json" } import nightowl from "./theme/nightowl.json" with { type: "json" }
import nord from "./theme/nord.json" with { type: "json" } import nord from "./theme/nord.json" with { type: "json" }
import osakaJade from "./theme/osaka-jade.json" with { type: "json" }
import onedark from "./theme/one-dark.json" with { type: "json" } import onedark from "./theme/one-dark.json" with { type: "json" }
import opencode from "./theme/opencode.json" with { type: "json" } import opencode from "./theme/opencode.json" with { type: "json" }
import orng from "./theme/orng.json" with { type: "json" } import orng from "./theme/orng.json" with { type: "json" }
@@ -156,7 +155,6 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
nightowl, nightowl,
nord, nord,
["one-dark"]: onedark, ["one-dark"]: onedark,
["osaka-jade"]: osakaJade,
opencode, opencode,
orng, orng,
["lucent-orng"]: lucentOrng, ["lucent-orng"]: lucentOrng,
@@ -285,12 +283,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
ready: false, ready: false,
}) })
createEffect(() => {
const theme = sync.data.config.theme
console.log("theme", theme)
if (theme) setStore("active", theme)
})
createEffect(() => { createEffect(() => {
getCustomThemes() getCustomThemes()
.then((custom) => { .then((custom) => {
@@ -1,93 +0,0 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkBg0": "#111c18",
"darkBg1": "#1a2520",
"darkBg2": "#23372B",
"darkBg3": "#3d4a44",
"darkFg0": "#C1C497",
"darkFg1": "#9aa88a",
"darkGray": "#53685B",
"darkRed": "#FF5345",
"darkGreen": "#549e6a",
"darkYellow": "#459451",
"darkBlue": "#509475",
"darkMagenta": "#D2689C",
"darkCyan": "#2DD5B7",
"darkWhite": "#F6F5DD",
"darkRedBright": "#db9f9c",
"darkGreenBright": "#63b07a",
"darkYellowBright": "#E5C736",
"darkBlueBright": "#ACD4CF",
"darkMagentaBright": "#75bbb3",
"darkCyanBright": "#8CD3CB",
"lightBg0": "#F6F5DD",
"lightBg1": "#E8E7CC",
"lightBg2": "#D5D4B8",
"lightBg3": "#A8A78C",
"lightFg0": "#111c18",
"lightFg1": "#1a2520",
"lightGray": "#53685B",
"lightRed": "#c7392d",
"lightGreen": "#3d7a52",
"lightYellow": "#b5a020",
"lightBlue": "#3d7560",
"lightMagenta": "#a8527a",
"lightCyan": "#1faa90"
},
"theme": {
"primary": { "dark": "darkCyan", "light": "lightCyan" },
"secondary": { "dark": "darkMagenta", "light": "lightMagenta" },
"accent": { "dark": "darkGreen", "light": "lightGreen" },
"error": { "dark": "darkRed", "light": "lightRed" },
"warning": { "dark": "darkYellowBright", "light": "lightYellow" },
"success": { "dark": "darkGreen", "light": "lightGreen" },
"info": { "dark": "darkCyan", "light": "lightCyan" },
"text": { "dark": "darkFg0", "light": "lightFg0" },
"textMuted": { "dark": "darkGray", "light": "lightGray" },
"background": { "dark": "darkBg0", "light": "lightBg0" },
"backgroundPanel": { "dark": "darkBg1", "light": "lightBg1" },
"backgroundElement": { "dark": "darkBg2", "light": "lightBg2" },
"border": { "dark": "darkBg3", "light": "lightBg3" },
"borderActive": { "dark": "darkCyan", "light": "lightCyan" },
"borderSubtle": { "dark": "darkBg2", "light": "lightBg2" },
"diffAdded": { "dark": "darkGreen", "light": "lightGreen" },
"diffRemoved": { "dark": "darkRed", "light": "lightRed" },
"diffContext": { "dark": "darkGray", "light": "lightGray" },
"diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" },
"diffHighlightAdded": { "dark": "darkGreenBright", "light": "lightGreen" },
"diffHighlightRemoved": { "dark": "darkRedBright", "light": "lightRed" },
"diffAddedBg": { "dark": "#15241c", "light": "#e0eee5" },
"diffRemovedBg": { "dark": "#241515", "light": "#eee0e0" },
"diffContextBg": { "dark": "darkBg1", "light": "lightBg1" },
"diffLineNumber": { "dark": "darkBg3", "light": "lightBg3" },
"diffAddedLineNumberBg": { "dark": "#121f18", "light": "#d5e5da" },
"diffRemovedLineNumberBg": { "dark": "#1f1212", "light": "#e5d5d5" },
"markdownText": { "dark": "darkFg0", "light": "lightFg0" },
"markdownHeading": { "dark": "darkCyan", "light": "lightCyan" },
"markdownLink": { "dark": "darkCyanBright", "light": "lightCyan" },
"markdownLinkText": { "dark": "darkGreen", "light": "lightGreen" },
"markdownCode": { "dark": "darkGreenBright", "light": "lightGreen" },
"markdownBlockQuote": { "dark": "darkGray", "light": "lightGray" },
"markdownEmph": { "dark": "darkMagenta", "light": "lightMagenta" },
"markdownStrong": { "dark": "darkFg0", "light": "lightFg0" },
"markdownHorizontalRule": { "dark": "darkGray", "light": "lightGray" },
"markdownListItem": { "dark": "darkCyan", "light": "lightCyan" },
"markdownListEnumeration": {
"dark": "darkCyanBright",
"light": "lightCyan"
},
"markdownImage": { "dark": "darkCyanBright", "light": "lightCyan" },
"markdownImageText": { "dark": "darkGreen", "light": "lightGreen" },
"markdownCodeBlock": { "dark": "darkFg0", "light": "lightFg0" },
"syntaxComment": { "dark": "darkGray", "light": "lightGray" },
"syntaxKeyword": { "dark": "darkCyan", "light": "lightCyan" },
"syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" },
"syntaxVariable": { "dark": "darkFg0", "light": "lightFg0" },
"syntaxString": { "dark": "darkGreenBright", "light": "lightGreen" },
"syntaxNumber": { "dark": "darkMagenta", "light": "lightMagenta" },
"syntaxType": { "dark": "darkGreen", "light": "lightGreen" },
"syntaxOperator": { "dark": "darkYellow", "light": "lightYellow" },
"syntaxPunctuation": { "dark": "darkFg0", "light": "lightFg0" }
}
}
@@ -59,7 +59,7 @@ export function Footer() {
<Match when={connected()}> <Match when={connected()}>
<Show when={permissions().length > 0}> <Show when={permissions().length > 0}>
<text fg={theme.warning}> <text fg={theme.warning}>
<span style={{ fg: theme.warning }}></span> {permissions().length} Permission <span style={{ fg: theme.warning }}></span> {permissions().length} Permission
{permissions().length > 1 ? "s" : ""} {permissions().length > 1 ? "s" : ""}
</text> </text>
</Show> </Show>
File diff suppressed because it is too large Load Diff
@@ -1,313 +0,0 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useTheme } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useSync } from "../../context/sync"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Locale } from "@/util/locale"
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
return path.relative(process.cwd(), input) || "."
}
return input
}
function filetype(input?: string) {
if (!input) return "none"
const ext = path.extname(input)
const language = LANGUAGE_EXTENSIONS[ext]
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
return language
}
function EditBody(props: { request: PermissionRequest }) {
const { theme, syntax } = useTheme()
const sync = useSync()
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
const view = createMemo(() => {
const diffStyle = sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})
const ft = createMemo(() => filetype(filepath()))
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.textMuted}>{"→"}</text>
<text fg={theme.textMuted}>Edit {normalizePath(filepath())}</text>
</box>
<Show when={diff()}>
<box maxHeight={Math.floor(dimensions().height / 4)} overflow="scroll">
<diff
diff={diff()}
view={view()}
filetype={ft()}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme.text}
addedBg={theme.diffAddedBg}
removedBg={theme.diffRemovedBg}
contextBg={theme.diffContextBg}
addedSignColor={theme.diffHighlightAdded}
removedSignColor={theme.diffHighlightRemoved}
lineNumberFg={theme.diffLineNumber}
lineNumberBg={theme.diffContextBg}
addedLineNumberBg={theme.diffAddedLineNumberBg}
removedLineNumberBg={theme.diffRemovedLineNumberBg}
/>
</box>
</Show>
</box>
)
}
function TextBody(props: { title: string; description?: string; icon?: string }) {
const { theme } = useTheme()
return (
<>
<box flexDirection="row" gap={1} paddingLeft={1}>
<Show when={props.icon}>
<text fg={theme.textMuted} flexShrink={0}>
{props.icon}
</text>
</Show>
<text fg={theme.textMuted}>{props.title}</text>
</box>
<Show when={props.description}>
<box paddingLeft={1}>
<text fg={theme.text}>{props.description}</text>
</box>
</Show>
</>
)
}
export function PermissionPrompt(props: { request: PermissionRequest }) {
const sdk = useSDK()
const sync = useSync()
const [store, setStore] = createStore({
always: false,
})
const input = createMemo(() => {
const tool = props.request.tool
if (!tool) return {}
const parts = sync.data.part[tool.messageID] ?? []
for (const part of parts) {
if (part.type === "tool" && part.callID === tool.callID && part.state.status !== "pending") {
return part.state.input ?? {}
}
}
return {}
})
const { theme } = useTheme()
return (
<Switch>
<Match when={store.always}>
<Prompt
title="Always allow"
body={
<Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
<box>
<For each={props.request.always}>
{(pattern) => (
<text fg={theme.text}>
{"- "}
{pattern}
</text>
)}
</For>
</box>
</box>
</Match>
</Switch>
}
options={{ confirm: "Confirm", cancel: "Cancel" }}
onSelect={(option) => {
setStore("always", false)
if (option === "cancel") return
sdk.client.permission.reply({
reply: "always",
requestID: props.request.id,
})
}}
/>
</Match>
<Match when={!store.always}>
<Prompt
title="Permission required"
body={
<Switch>
<Match when={props.request.permission === "edit"}>
<EditBody request={props.request} />
</Match>
<Match when={props.request.permission === "read"}>
<TextBody icon="→" title={`Read ` + normalizePath(input().filePath as string)} />
</Match>
<Match when={props.request.permission === "glob"}>
<TextBody icon="✱" title={`Glob "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "grep"}>
<TextBody icon="✱" title={`Grep "` + (input().pattern ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "list"}>
<TextBody icon="→" title={`List ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "bash"}>
<TextBody
icon="#"
title={(input().description as string) ?? ""}
description={("$ " + input().command) as string}
/>
</Match>
<Match when={props.request.permission === "task"}>
<TextBody
icon="#"
title={`${Locale.titlecase((input().subagent_type as string) ?? "Unknown")} Task`}
description={"◉ " + input().description}
/>
</Match>
<Match when={props.request.permission === "webfetch"}>
<TextBody icon="%" title={`WebFetch ` + (input().url ?? "")} />
</Match>
<Match when={props.request.permission === "websearch"}>
<TextBody icon="◈" title={`Exa Web Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "codesearch"}>
<TextBody icon="◇" title={`Exa Code Search "` + (input().query ?? "") + `"`} />
</Match>
<Match when={props.request.permission === "external_directory"}>
<TextBody icon="⚠" title={`Access external directory ` + normalizePath(input().path as string)} />
</Match>
<Match when={props.request.permission === "doom_loop"}>
<TextBody icon="⟳" title="Continue after repeated failures" />
</Match>
<Match when={true}>
<TextBody icon="⚙" title={`Call tool ` + props.request.permission} />
</Match>
</Switch>
}
options={{ once: "Allow once", always: "Allow always", reject: "Reject" }}
onSelect={(option) => {
if (option === "always") {
setStore("always", true)
return
}
sdk.client.permission.reply({
reply: option as "once" | "reject",
requestID: props.request.id,
})
}}
/>
</Match>
</Switch>
)
}
function Prompt<const T extends Record<string, string>>(props: {
title: string
body: JSX.Element
options: T
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name == "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
}
if (evt.name === "right" || evt.name == "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
}
if (evt.name === "return") {
evt.preventDefault()
props.onSelect(store.selected)
}
})
return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>{props.title}</text>
</box>
{props.body}
</box>
<box
flexDirection="row"
flexShrink={0}
gap={1}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent="space-between"
>
<box flexDirection="row" gap={1}>
<For each={keys}>
{(option) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
>
<text fg={option === store.selected ? theme.selectedListItemText : theme.textMuted}>
{props.options[option]}
</text>
</box>
)}
</For>
</box>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
</text>
</box>
</box>
</box>
)
}
@@ -77,9 +77,6 @@ export const TuiThreadCommand = cmd({
process.on("unhandledRejection", (e) => { process.on("unhandledRejection", (e) => {
Log.Default.error(e) Log.Default.error(e)
}) })
process.on("SIGUSR2", async () => {
await client.call("reload", undefined)
})
const opts = await resolveNetworkOptions(args) const opts = await resolveNetworkOptions(args)
const server = await client.call("server", opts) const server = await client.call("server", opts)
const prompt = await iife(async () => { const prompt = await iife(async () => {
@@ -9,15 +9,7 @@ export type DialogExportOptionsProps = {
defaultFilename: string defaultFilename: string
defaultThinking: boolean defaultThinking: boolean
defaultToolDetails: boolean defaultToolDetails: boolean
defaultAssistantMetadata: boolean onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void
defaultOpenWithoutSaving: boolean
onConfirm?: (options: {
filename: string
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
openWithoutSaving: boolean
}) => void
onCancel?: () => void onCancel?: () => void
} }
@@ -28,9 +20,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
thinking: props.defaultThinking, thinking: props.defaultThinking,
toolDetails: props.defaultToolDetails, toolDetails: props.defaultToolDetails,
assistantMetadata: props.defaultAssistantMetadata, active: "filename" as "filename" | "thinking" | "toolDetails",
openWithoutSaving: props.defaultOpenWithoutSaving,
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
}) })
useKeyboard((evt) => { useKeyboard((evt) => {
@@ -39,18 +29,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
filename: textarea.plainText, filename: textarea.plainText,
thinking: store.thinking, thinking: store.thinking,
toolDetails: store.toolDetails, toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
openWithoutSaving: store.openWithoutSaving,
}) })
} }
if (evt.name === "tab") { if (evt.name === "tab") {
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"]
"filename",
"thinking",
"toolDetails",
"assistantMetadata",
"openWithoutSaving",
]
const currentIndex = order.indexOf(store.active) const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex]) setStore("active", order[nextIndex])
@@ -59,8 +41,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
if (evt.name === "space") { if (evt.name === "space") {
if (store.active === "thinking") setStore("thinking", !store.thinking) if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
evt.preventDefault() evt.preventDefault()
} }
}) })
@@ -91,8 +71,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
filename: textarea.plainText, filename: textarea.plainText,
thinking: store.thinking, thinking: store.thinking,
toolDetails: store.toolDetails, toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
openWithoutSaving: store.openWithoutSaving,
}) })
}} }}
height={3} height={3}
@@ -130,30 +108,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
</text> </text>
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text> <text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
</box> </box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "assistantMetadata" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "assistantMetadata")}
>
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.textMuted}>
{store.assistantMetadata ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "assistantMetadata" ? theme.primary : theme.text}>Include assistant metadata</text>
</box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "openWithoutSaving" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "openWithoutSaving")}
>
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.textMuted}>
{store.openWithoutSaving ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "openWithoutSaving" ? theme.primary : theme.text}>Open without saving</text>
</box>
</box> </box>
<Show when={store.active !== "filename"}> <Show when={store.active !== "filename"}>
<text fg={theme.textMuted} paddingBottom={1}> <text fg={theme.textMuted} paddingBottom={1}>
@@ -176,24 +130,14 @@ DialogExportOptions.show = (
defaultFilename: string, defaultFilename: string,
defaultThinking: boolean, defaultThinking: boolean,
defaultToolDetails: boolean, defaultToolDetails: boolean,
defaultAssistantMetadata: boolean,
defaultOpenWithoutSaving: boolean,
) => { ) => {
return new Promise<{ return new Promise<{ filename: string; thinking: boolean; toolDetails: boolean } | null>((resolve) => {
filename: string
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
openWithoutSaving: boolean
} | null>((resolve) => {
dialog.replace( dialog.replace(
() => ( () => (
<DialogExportOptions <DialogExportOptions
defaultFilename={defaultFilename} defaultFilename={defaultFilename}
defaultThinking={defaultThinking} defaultThinking={defaultThinking}
defaultToolDetails={defaultToolDetails} defaultToolDetails={defaultToolDetails}
defaultAssistantMetadata={defaultAssistantMetadata}
defaultOpenWithoutSaving={defaultOpenWithoutSaving}
onConfirm={(options) => resolve(options)} onConfirm={(options) => resolve(options)}
onCancel={() => resolve(null)} onCancel={() => resolve(null)}
/> />
@@ -99,7 +99,6 @@ function init() {
replace(input: any, onClose?: () => void) { replace(input: any, onClose?: () => void) {
if (store.stack.length === 0) { if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable focus = renderer.currentFocusedRenderable
focus?.blur()
} }
for (const item of store.stack) { for (const item of store.stack) {
if (item.onClose) item.onClose() if (item.onClose) item.onClose()
@@ -1,98 +0,0 @@
import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale"
export type TranscriptOptions = {
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
}
export type SessionInfo = {
id: string
title: string
time: {
created: number
updated: number
}
}
export type MessageWithParts = {
info: UserMessage | AssistantMessage
parts: Part[]
}
export function formatTranscript(
session: SessionInfo,
messages: MessageWithParts[],
options: TranscriptOptions,
): string {
let transcript = `# ${session.title}\n\n`
transcript += `**Session ID:** ${session.id}\n`
transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n`
transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n`
transcript += `---\n\n`
for (const msg of messages) {
transcript += formatMessage(msg.info, msg.parts, options)
transcript += `---\n\n`
}
return transcript
}
export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[], options: TranscriptOptions): string {
let result = ""
if (msg.role === "user") {
result += `## User\n\n`
} else {
result += formatAssistantHeader(msg, options.assistantMetadata)
}
for (const part of parts) {
result += formatPart(part, options)
}
return result
}
export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: boolean): string {
if (!includeMetadata) {
return `## Assistant\n\n`
}
const duration =
msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : ""
return `## Assistant (${Locale.titlecase(msg.agent)} · ${msg.modelID}${duration ? ` · ${duration}` : ""})\n\n`
}
export function formatPart(part: Part, options: TranscriptOptions): string {
if (part.type === "text" && !part.synthetic) {
return `${part.text}\n\n`
}
if (part.type === "reasoning") {
if (options.thinking) {
return `_Thinking:_\n\n${part.text}\n\n`
}
return ""
}
if (part.type === "tool") {
let result = `\`\`\`\nTool: ${part.tool}\n`
if (options.toolDetails && part.state.input) {
result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
}
if (options.toolDetails && part.state.status === "completed" && part.state.output) {
result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
}
if (options.toolDetails && part.state.status === "error" && part.state.error) {
result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
}
result += `\n\`\`\`\n\n`
return result
}
return ""
}
@@ -6,7 +6,6 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import type { BunWebSocketData } from "hono/bun" import type { BunWebSocketData } from "hono/bun"
import { Config } from "@/config/config"
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),
@@ -52,10 +51,6 @@ export const rpc = {
}, },
}) })
}, },
async reload() {
Config.global.reset()
await Instance.disposeAll()
},
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
await Instance.disposeAll() await Instance.disposeAll()
+1 -11
View File
@@ -17,12 +17,6 @@ const options = {
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
default: false, default: false,
}, },
cors: {
type: "string" as const,
array: true,
describe: "additional domains to allow for CORS",
default: [] as string[],
},
} }
export type NetworkOptions = InferredOptionTypes<typeof options> export type NetworkOptions = InferredOptionTypes<typeof options>
@@ -36,7 +30,6 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const portExplicitlySet = process.argv.includes("--port") const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname") const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns") const mdnsExplicitlySet = process.argv.includes("--mdns")
const corsExplicitlySet = process.argv.includes("--cors")
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
@@ -45,9 +38,6 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
: mdns && !config?.server?.hostname : mdns && !config?.server?.hostname
? "0.0.0.0" ? "0.0.0.0"
: (config?.server?.hostname ?? args.hostname) : (config?.server?.hostname ?? args.hostname)
const configCors = config?.server?.cors ?? []
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
const cors = [...configCors, ...argsCors]
return { hostname, port, mdns, cors } return { hostname, port, mdns }
} }
+1 -2
View File
@@ -1,3 +1,4 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import z from "zod" import z from "zod"
import { Config } from "../config/config" import { Config } from "../config/config"
@@ -26,7 +27,6 @@ export namespace Command {
description: z.string().optional(), description: z.string().optional(),
agent: z.string().optional(), agent: z.string().optional(),
model: z.string().optional(), model: z.string().optional(),
mcp: z.boolean().optional(),
// workaround for zod not supporting async functions natively so we use getters // workaround for zod not supporting async functions natively so we use getters
// https://zod.dev/v4/changelog?id=zfunction // https://zod.dev/v4/changelog?id=zfunction
template: z.promise(z.string()).or(z.string()), template: z.promise(z.string()).or(z.string()),
@@ -94,7 +94,6 @@ export namespace Command {
for (const [name, prompt] of Object.entries(await MCP.prompts())) { for (const [name, prompt] of Object.entries(await MCP.prompts())) {
result[name] = { result[name] = {
name, name,
mcp: true,
description: prompt.description, description: prompt.description,
get template() { get template() {
// since a getter can't be async we need to manually return a promise here // since a getter can't be async we need to manually return a promise here
+33 -107
View File
@@ -90,6 +90,7 @@ export namespace Config {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
} }
const promises: Promise<void>[] = []
for (const dir of unique(directories)) { for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.jsonc", "opencode.json"]) {
@@ -102,12 +103,13 @@ export namespace Config {
} }
} }
installDependencies(dir) promises.push(installDependencies(dir))
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir))
result.agent = mergeDeep(result.agent, await loadMode(dir)) result.agent = mergeDeep(result.agent, await loadMode(dir))
result.plugin.push(...(await loadPlugin(dir))) result.plugin.push(...(await loadPlugin(dir)))
} }
await Promise.allSettled(promises)
// Migrate deprecated mode field to agent field // Migrate deprecated mode field to agent field
for (const [name, mode] of Object.entries(result.mode)) { for (const [name, mode] of Object.entries(result.mode)) {
@@ -123,20 +125,6 @@ export namespace Config {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
} }
// Backwards compatibility: legacy top-level `tools` config
if (result.tools) {
const perms: Record<string, Config.PermissionAction> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: Config.PermissionAction = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username if (!result.username) result.username = os.userInfo().username
// Handle migration from autoshare to share field // Handle migration from autoshare to share field
@@ -144,6 +132,11 @@ export namespace Config {
result.share = "auto" result.share = "auto"
} }
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({}) if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
// Apply flag overrides for compaction settings // Apply flag overrides for compaction settings
@@ -266,7 +259,7 @@ export namespace Config {
return result return result
} }
const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md") const MODE_GLOB = new Bun.Glob("{mode,modes}/**/*.md")
async function loadMode(dir: string) { async function loadMode(dir: string) {
const result: Record<string, Agent> = {} const result: Record<string, Agent> = {}
for await (const item of MODE_GLOB.scan({ for await (const item of MODE_GLOB.scan({
@@ -295,7 +288,7 @@ export namespace Config {
return result return result
} }
const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}") const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/**/*.{ts,js}")
async function loadPlugin(dir: string) { async function loadPlugin(dir: string) {
const plugins: string[] = [] const plugins: string[] = []
@@ -377,45 +370,7 @@ export namespace Config {
export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote]) export const Mcp = z.discriminatedUnion("type", [McpLocal, McpRemote])
export type Mcp = z.infer<typeof Mcp> export type Mcp = z.infer<typeof Mcp>
export const PermissionAction = z.enum(["ask", "allow", "deny"]).meta({ export const Permission = z.enum(["ask", "allow", "deny"])
ref: "PermissionActionConfig",
})
export type PermissionAction = z.infer<typeof PermissionAction>
export const PermissionObject = z.record(z.string(), PermissionAction).meta({
ref: "PermissionObjectConfig",
})
export type PermissionObject = z.infer<typeof PermissionObject>
export const PermissionRule = z.union([PermissionAction, PermissionObject]).meta({
ref: "PermissionRuleConfig",
})
export type PermissionRule = z.infer<typeof PermissionRule>
export const Permission = z
.object({
read: PermissionRule.optional(),
edit: PermissionRule.optional(),
glob: PermissionRule.optional(),
grep: PermissionRule.optional(),
list: PermissionRule.optional(),
bash: PermissionRule.optional(),
task: PermissionRule.optional(),
external_directory: PermissionRule.optional(),
todowrite: PermissionAction.optional(),
todoread: PermissionAction.optional(),
webfetch: PermissionAction.optional(),
websearch: PermissionAction.optional(),
codesearch: PermissionAction.optional(),
lsp: PermissionRule.optional(),
doom_loop: PermissionAction.optional(),
})
.catchall(PermissionRule)
.or(PermissionAction)
.transform((x) => (typeof x === "string" ? { "*": x } : x))
.meta({
ref: "PermissionConfig",
})
export type Permission = z.infer<typeof Permission> export type Permission = z.infer<typeof Permission>
export const Command = z.object({ export const Command = z.object({
@@ -433,70 +388,33 @@ export namespace Config {
temperature: z.number().optional(), temperature: z.number().optional(),
top_p: z.number().optional(), top_p: z.number().optional(),
prompt: z.string().optional(), prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"), tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(), disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"), description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(), mode: z.enum(["subagent", "primary", "all"]).optional(),
options: z.record(z.string(), z.any()).optional(),
color: z color: z
.string() .string()
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format") .regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
.optional() .optional()
.describe("Hex color code for the agent (e.g., #FF5733)"), .describe("Hex color code for the agent (e.g., #FF5733)"),
steps: z maxSteps: z
.number() .number()
.int() .int()
.positive() .positive()
.optional() .optional()
.describe("Maximum number of agentic iterations before forcing text-only response"), .describe("Maximum number of agentic iterations before forcing text-only response"),
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."), permission: z
permission: Permission.optional(), .object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
})
.optional(),
}) })
.catchall(z.any()) .catchall(z.any())
.transform((agent, ctx) => {
const knownKeys = new Set([
"model",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
// Extract unknown properties into options
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!knownKeys.has(key)) options[key] = value
}
// Convert legacy tools config to permissions
const permission: Permission = { ...agent.permission }
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
// write, edit, patch, multiedit all map to edit permission
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
} else {
permission[tool] = action
}
}
// Convert legacy maxSteps to steps
const steps = agent.steps ?? agent.maxSteps
return { ...agent, options, permission, steps } as typeof agent & {
options?: Record<string, unknown>
permission?: Permission
steps?: number
}
})
.meta({ .meta({
ref: "AgentConfig", ref: "AgentConfig",
}) })
@@ -668,7 +586,6 @@ export namespace Config {
port: z.number().int().positive().optional().describe("Port to listen on"), port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"), hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"), mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
}) })
.strict() .strict()
.meta({ .meta({
@@ -869,7 +786,16 @@ export namespace Config {
), ),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."), layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: Permission.optional(), permission: z
.object({
edit: Permission.optional(),
bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
webfetch: Permission.optional(),
doom_loop: Permission.optional(),
external_directory: Permission.optional(),
})
.optional(),
tools: z.record(z.string(), z.boolean()).optional(), tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z enterprise: z
.object({ .object({
@@ -158,7 +158,6 @@ export namespace Installation {
throw new UpgradeFailedError({ throw new UpgradeFailedError({
stderr: result.stderr.toString("utf8"), stderr: result.stderr.toString("utf8"),
}) })
await $`${process.execPath} --version`.nothrow().quiet().text()
} }
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
-23
View File
@@ -98,9 +98,6 @@ export namespace LSPClient {
}, },
workspace: { workspace: {
configuration: true, configuration: true,
didChangeWatchedFiles: {
dynamicRegistration: true,
},
}, },
textDocument: { textDocument: {
synchronization: { synchronization: {
@@ -154,16 +151,6 @@ export namespace LSPClient {
const version = files[input.path] const version = files[input.path]
if (version !== undefined) { if (version !== undefined) {
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
type: 2, // Changed
},
],
})
const next = version + 1 const next = version + 1
files[input.path] = next files[input.path] = next
log.info("textDocument/didChange", { log.info("textDocument/didChange", {
@@ -180,16 +167,6 @@ export namespace LSPClient {
return return
} }
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
type: 1, // Created
},
],
})
log.info("textDocument/didOpen", input) log.info("textDocument/didOpen", input)
diagnostics.delete(input.path) diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didOpen", { await connection.sendNotification("textDocument/didOpen", {
+1 -1
View File
@@ -195,7 +195,7 @@ export namespace MCP {
for (const prompt of prompts.prompts) { for (const prompt of prompts.prompts) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_") const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_") const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
const key = sanitizedClientName + ":" + sanitizedPromptName const key = sanitizedClientName + ":" + sanitizedPromptName + " (MCP)"
commands[key] = { ...prompt, client: clientName } commands[key] = { ...prompt, client: clientName }
} }
-163
View File
@@ -1,163 +0,0 @@
export namespace BashArity {
export function prefix(tokens: string[]) {
for (let len = tokens.length; len > 0; len--) {
const prefix = tokens.slice(0, len).join(" ")
const arity = ARITY[prefix]
if (arity !== undefined) return tokens.slice(0, arity)
}
if (tokens.length === 0) return []
return tokens.slice(0, 1)
}
/* Generated with following prompt:
You are generating a dictionary of command-prefix arities for bash-style commands.
This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string number**, representing how many **tokens** define the command.
2. **Flags NEVER count as tokens**. Only subcommands count.
3. **Longest matching prefix wins**.
4. **Only include a longer prefix if its arity is different from what the shorter prefix already implies**. * Example: If `git` is 2, then do **not** include `git checkout`, `git commit`, etc. unless they require *different* arity.
5. The output must be a **single JSON object**. Each entry should have a comment with an example real world matching command. DO NOT MAKE ANY OTHER COMMENTS. Should be alphabetical
6. Include the **most commonly used commands** across many stacks and languages. More is better.### **Semantics examples*** `touch foo.txt` `touch` (arity 1, explicitly listed)
* `git checkout main` `git checkout` (because `git` has arity 2)
* `npm install` `npm install` (because `npm` has arity 2)
* `npm run dev` `npm run dev` (because `npm run` has arity 3)
* `python script.py` `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
*/
const ARITY: Record<string, number> = {
cat: 1, // cat file.txt
cd: 1, // cd /path/to/dir
chmod: 1, // chmod 755 script.sh
chown: 1, // chown user:group file.txt
cp: 1, // cp source.txt dest.txt
echo: 1, // echo "hello world"
env: 1, // env
export: 1, // export PATH=/usr/bin
grep: 1, // grep pattern file.txt
kill: 1, // kill 1234
killall: 1, // killall process
ln: 1, // ln -s source target
ls: 1, // ls -la
mkdir: 1, // mkdir new-dir
mv: 1, // mv old.txt new.txt
ps: 1, // ps aux
pwd: 1, // pwd
rm: 1, // rm file.txt
rmdir: 1, // rmdir empty-dir
sleep: 1, // sleep 5
source: 1, // source ~/.bashrc
tail: 1, // tail -f log.txt
touch: 1, // touch file.txt
unset: 1, // unset VAR
which: 1, // which node
aws: 3, // aws s3 ls
az: 3, // az storage blob list
bazel: 2, // bazel build
brew: 2, // brew install node
bun: 2, // bun install
"bun run": 3, // bun run dev
"bun x": 3, // bun x vite
cargo: 2, // cargo build
"cargo add": 3, // cargo add tokio
"cargo run": 3, // cargo run main
cdk: 2, // cdk deploy
cf: 2, // cf push app
cmake: 2, // cmake build
composer: 2, // composer require laravel
consul: 2, // consul members
"consul kv": 3, // consul kv get config/app
crictl: 2, // crictl ps
deno: 2, // deno run server.ts
"deno task": 3, // deno task dev
doctl: 3, // doctl kubernetes cluster list
docker: 2, // docker run nginx
"docker builder": 3, // docker builder prune
"docker compose": 3, // docker compose up
"docker container": 3, // docker container ls
"docker image": 3, // docker image prune
"docker network": 3, // docker network inspect
"docker volume": 3, // docker volume ls
eksctl: 2, // eksctl get clusters
"eksctl create": 3, // eksctl create cluster
firebase: 2, // firebase deploy
flyctl: 2, // flyctl deploy
gcloud: 3, // gcloud compute instances list
gh: 3, // gh pr list
git: 2, // git checkout main
"git config": 3, // git config user.name
"git remote": 3, // git remote add origin
"git stash": 3, // git stash pop
go: 2, // go build
gradle: 2, // gradle build
helm: 2, // helm install mychart
heroku: 2, // heroku logs
hugo: 2, // hugo new site blog
ip: 2, // ip link show
"ip addr": 3, // ip addr show
"ip link": 3, // ip link set eth0 up
"ip netns": 3, // ip netns exec foo bash
"ip route": 3, // ip route add default via 1.1.1.1
kind: 2, // kind delete cluster
"kind create": 3, // kind create cluster
kubectl: 2, // kubectl get pods
"kubectl kustomize": 3, // kubectl kustomize overlays/dev
"kubectl rollout": 3, // kubectl rollout restart deploy/api
kustomize: 2, // kustomize build .
make: 2, // make build
mc: 2, // mc ls myminio
"mc admin": 3, // mc admin info myminio
minikube: 2, // minikube start
mongosh: 2, // mongosh test
mysql: 2, // mysql -u root
mvn: 2, // mvn compile
ng: 2, // ng generate component home
npm: 2, // npm install
"npm exec": 3, // npm exec vite
"npm init": 3, // npm init vue
"npm run": 3, // npm run dev
"npm view": 3, // npm view react version
nvm: 2, // nvm use 18
nx: 2, // nx build
openssl: 2, // openssl genrsa 2048
"openssl req": 3, // openssl req -new -key key.pem
"openssl x509": 3, // openssl x509 -in cert.pem
pip: 2, // pip install numpy
pipenv: 2, // pipenv install flask
pnpm: 2, // pnpm install
"pnpm dlx": 3, // pnpm dlx create-next-app
"pnpm exec": 3, // pnpm exec vite
"pnpm run": 3, // pnpm run dev
poetry: 2, // poetry add requests
podman: 2, // podman run alpine
"podman container": 3, // podman container ls
"podman image": 3, // podman image prune
psql: 2, // psql -d mydb
pulumi: 2, // pulumi up
"pulumi stack": 3, // pulumi stack output
pyenv: 2, // pyenv install 3.11
python: 2, // python -m venv env
rake: 2, // rake db:migrate
rbenv: 2, // rbenv install 3.2.0
"redis-cli": 2, // redis-cli ping
rustup: 2, // rustup update
serverless: 2, // serverless invoke
sfdx: 3, // sfdx force:org:list
skaffold: 2, // skaffold dev
sls: 2, // sls deploy
sst: 2, // sst deploy
swift: 2, // swift build
systemctl: 2, // systemctl restart nginx
terraform: 2, // terraform apply
"terraform workspace": 3, // terraform workspace select prod
tmux: 2, // tmux new -s dev
turbo: 2, // turbo run build
ufw: 2, // ufw allow 22
vault: 2, // vault login
"vault auth": 3, // vault auth list
"vault kv": 3, // vault kv get secret/api
vercel: 2, // vercel deploy
volta: 2, // volta install node
wp: 2, // wp plugin install
yarn: 2, // yarn add react
"yarn dlx": 3, // yarn dlx create-react-app
"yarn run": 3, // yarn run dev
}
}
+3 -3
View File
@@ -27,7 +27,7 @@ export namespace Permission {
sessionID: z.string(), sessionID: z.string(),
messageID: z.string(), messageID: z.string(),
callID: z.string().optional(), callID: z.string().optional(),
message: z.string(), title: z.string(),
metadata: z.record(z.string(), z.any()), metadata: z.record(z.string(), z.any()),
time: z.object({ time: z.object({
created: z.number(), created: z.number(),
@@ -99,7 +99,7 @@ export namespace Permission {
export async function ask(input: { export async function ask(input: {
type: Info["type"] type: Info["type"]
message: Info["message"] title: Info["title"]
pattern?: Info["pattern"] pattern?: Info["pattern"]
callID?: Info["callID"] callID?: Info["callID"]
sessionID: Info["sessionID"] sessionID: Info["sessionID"]
@@ -123,7 +123,7 @@ export namespace Permission {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: input.messageID, messageID: input.messageID,
callID: input.callID, callID: input.callID,
message: input.message, title: input.title,
metadata: input.metadata, metadata: input.metadata,
time: { time: {
created: Date.now(), created: Date.now(),
-258
View File
@@ -1,258 +0,0 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
import { Storage } from "@/storage/storage"
import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
import z from "zod"
export namespace PermissionNext {
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({
permission: key,
action: value,
pattern: "*",
})
continue
}
ruleset.push(...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern, action })))
}
return ruleset
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export const Request = z
.object({
id: Identifier.schema("permission"),
sessionID: Identifier.schema("session"),
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: z.string(),
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export const Approval = z.object({
projectID: z.string(),
patterns: z.string().array(),
})
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: z.string(),
requestID: z.string(),
reply: Reply,
}),
),
}
const state = Instance.state(async () => {
const projectID = Instance.project.id
const stored = await Storage.read<Ruleset>(["permission", projectID]).catch(() => [] as Ruleset)
const pending: Record<
string,
{
info: Request
resolve: () => void
reject: (e: any) => void
}
> = {}
return {
pending,
approved: stored,
}
})
export const ask = fn(
Request.partial({ id: true }).extend({
ruleset: Ruleset,
}),
async (input) => {
const s = await state()
const { ruleset, ...request } = input
for (const pattern of request.patterns ?? []) {
const rule = evaluate(request.permission, pattern, ruleset, s.approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny")
throw new AutoRejectedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
if (rule.action === "ask") {
const id = input.id ?? Identifier.ascending("permission")
return new Promise<void>((resolve, reject) => {
const info: Request = {
id,
...request,
}
s.pending[id] = {
info,
resolve,
reject,
}
Bus.publish(Event.Asked, info)
})
}
if (rule.action === "allow") continue
}
},
)
export const reply = fn(
z.object({
requestID: Identifier.schema("permission"),
reply: Reply,
}),
async (input) => {
const s = await state()
const existing = s.pending[input.requestID]
if (!existing) return
delete s.pending[input.requestID]
Bus.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
})
if (input.reply === "reject") {
existing.reject(new RejectedError())
// Reject all other pending permissions for this session
const sessionID = existing.info.sessionID
for (const [id, pending] of Object.entries(s.pending)) {
if (pending.info.sessionID === sessionID) {
delete s.pending[id]
Bus.publish(Event.Replied, {
sessionID: pending.info.sessionID,
requestID: pending.info.id,
reply: "reject",
})
pending.reject(new RejectedError())
}
}
return
}
if (input.reply === "once") {
existing.resolve()
return
}
if (input.reply === "always") {
for (const pattern of existing.info.always) {
s.approved.push({
permission: existing.info.permission,
pattern,
action: "allow",
})
}
existing.resolve()
const sessionID = existing.info.sessionID
for (const [id, pending] of Object.entries(s.pending)) {
if (pending.info.sessionID !== sessionID) continue
const ok = pending.info.patterns.every(
(pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
)
if (!ok) continue
delete s.pending[id]
Bus.publish(Event.Replied, {
sessionID: pending.info.sessionID,
requestID: pending.info.id,
reply: "always",
})
pending.resolve()
}
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
// await Storage.write(["permission", Instance.project.id], s.approved)
return
}
},
)
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const merged = merge(...rulesets)
log.info("evaluate", { permission, pattern, ruleset: merged })
const match = merged.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
if (evaluate(permission, "*", ruleset).action === "deny") {
result.add(tool)
}
}
return result
}
export class RejectedError extends Error {
constructor() {
super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
}
}
export class AutoRejectedError extends Error {
constructor(public readonly ruleset: Ruleset) {
super(
`The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`,
)
}
}
export async function list() {
return state().then((x) => Object.values(x.pending).map((x) => x.info))
}
}
+3 -9
View File
@@ -11,8 +11,6 @@ import { Flag } from "../flag/flag"
export namespace Plugin { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
const BUILTIN = ["opencode-copilot-auth@0.0.9", "opencode-anthropic-auth@0.0.5"]
const state = Instance.state(async () => { const state = Instance.state(async () => {
const client = createOpencodeClient({ const client = createOpencodeClient({
baseUrl: "http://localhost:4096", baseUrl: "http://localhost:4096",
@@ -31,7 +29,8 @@ export namespace Plugin {
} }
const plugins = [...(config.plugin ?? [])] const plugins = [...(config.plugin ?? [])]
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
plugins.push(...BUILTIN) plugins.push("opencode-copilot-auth@0.0.9")
plugins.push("opencode-anthropic-auth@0.0.5")
} }
for (let plugin of plugins) { for (let plugin of plugins) {
log.info("loading plugin", { path: plugin }) log.info("loading plugin", { path: plugin })
@@ -39,11 +38,7 @@ export namespace Plugin {
const lastAtIndex = plugin.lastIndexOf("@") const lastAtIndex = plugin.lastIndexOf("@")
const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
plugin = await BunProc.install(pkg, version).catch((err) => { plugin = await BunProc.install(pkg, version)
if (BUILTIN.includes(pkg)) return ""
throw err
})
if (!plugin) continue
} }
const mod = await import(plugin) const mod = await import(plugin)
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) { for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
@@ -83,7 +78,6 @@ export namespace Plugin {
const hooks = await state().then((x) => x.hooks) const hooks = await state().then((x) => x.hooks)
const config = await Config.get() const config = await Config.get()
for (const hook of hooks) { for (const hook of hooks) {
// @ts-expect-error this is because we haven't moved plugin to sdk v2
await hook.config?.(config) await hook.config?.(config)
} }
Bus.subscribeAll(async (input) => { Bus.subscribeAll(async (input) => {
+6 -6
View File
@@ -501,7 +501,7 @@ export namespace Provider {
api: { api: {
id: model.id, id: model.id,
url: provider.api!, url: provider.api!,
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", npm: model.provider?.npm ?? provider.npm ?? provider.id,
}, },
status: model.status ?? "active", status: model.status ?? "active",
headers: model.headers ?? {}, headers: model.headers ?? {},
@@ -646,11 +646,7 @@ export namespace Provider {
api: { api: {
id: model.id ?? existingModel?.api.id ?? modelID, id: model.id ?? existingModel?.api.id ?? modelID,
npm: npm:
model.provider?.npm ?? model.provider?.npm ?? provider.npm ?? existingModel?.api.npm ?? modelsDev[providerID]?.npm ?? providerID,
provider.npm ??
existingModel?.api.npm ??
modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible",
url: provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, url: provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
}, },
status: model.status ?? existingModel?.status ?? "active", status: model.status ?? existingModel?.status ?? "active",
@@ -1026,6 +1022,10 @@ export namespace Provider {
"gemini-2.5-flash", "gemini-2.5-flash",
"gpt-5-nano", "gpt-5-nano",
] ]
// claude-haiku-4.5 is considered a premium model in github copilot, we shouldn't use premium requests for title gen
if (providerID === "github-copilot") {
priority = priority.filter((m) => m !== "claude-haiku-4.5")
}
if (providerID.startsWith("opencode")) { if (providerID.startsWith("opencode")) {
priority = ["gpt-5-nano"] priority = ["gpt-5-nano"]
} }
+20 -1
View File
@@ -7,12 +7,32 @@ import { Log } from "../util/log"
import type { WSContext } from "hono/ws" import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy" import { lazy } from "@opencode-ai/util/lazy"
import {} from "process"
import { Installation } from "@/installation"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
export namespace Pty { export namespace Pty {
const log = Log.create({ service: "pty" }) const log = Log.create({ service: "pty" })
const pty = lazy(async () => { const pty = lazy(async () => {
if (!Installation.isLocal()) {
const path = require(
`bun-pty/rust-pty/target/release/${
process.platform === "win32"
? "rust_pty.dll"
: process.platform === "linux" && process.arch === "x64"
? "librust_pty.so"
: process.platform === "darwin" && process.arch === "x64"
? "librust_pty.dylib"
: process.platform === "darwin" && process.arch === "arm64"
? "librust_pty_arm64.dylib"
: process.platform === "linux" && process.arch === "arm64"
? "librust_pty_arm64.so"
: ""
}`,
)
process.env.BUN_PTY_LIB = path
}
const { spawn } = await import("bun-pty") const { spawn } = await import("bun-pty")
return spawn return spawn
}) })
@@ -108,7 +128,6 @@ export namespace Pty {
cwd, cwd,
env, env,
}) })
const info = { const info = {
id, id,
title: input.title || `Terminal ${id.slice(-4)}`, title: input.title || `Terminal ${id.slice(-4)}`,
+14 -85
View File
@@ -20,6 +20,7 @@ import { LSP } from "../lsp"
import { Format } from "../format" import { Format } from "../format"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
import { TuiRoute } from "./tui" import { TuiRoute } from "./tui"
import { Permission } from "../permission"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs" import { Vcs } from "../project/vcs"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
@@ -44,9 +45,9 @@ import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary" import { SessionSummary } from "@/session/summary"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun" import { upgradeWebSocket, websocket } from "hono/bun"
import type { BunWebSocketData } from "hono/bun"
import { errors } from "./error" import { errors } from "./error"
import { Pty } from "@/pty" import { Pty } from "@/pty"
import { PermissionNext } from "@/permission/next"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { MDNS } from "./mdns" import { MDNS } from "./mdns"
@@ -57,7 +58,6 @@ export namespace Server {
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
let _url: URL | undefined let _url: URL | undefined
let _corsWhitelist: string[] = []
export function url(): URL { export function url(): URL {
return _url ?? new URL("http://localhost:4096") return _url ?? new URL("http://localhost:4096")
@@ -117,10 +117,6 @@ export namespace Server {
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
return input return input
} }
if (_corsWhitelist.includes(input)) {
return input
}
return return
}, },
}), }),
@@ -1524,7 +1520,6 @@ export namespace Server {
"/session/:sessionID/permissions/:permissionID", "/session/:sessionID/permissions/:permissionID",
describeRoute({ describeRoute({
summary: "Respond to permission", summary: "Respond to permission",
deprecated: true,
description: "Approve or deny a permission request from the AI assistant.", description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.respond", operationId: "permission.respond",
responses: { responses: {
@@ -1546,47 +1541,15 @@ export namespace Server {
permissionID: z.string(), permissionID: z.string(),
}), }),
), ),
validator("json", z.object({ response: PermissionNext.Reply })), validator("json", z.object({ response: Permission.Response })),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
PermissionNext.reply({ const sessionID = params.sessionID
requestID: params.permissionID, const permissionID = params.permissionID
reply: c.req.valid("json").response, Permission.respond({
}) sessionID,
return c.json(true) permissionID,
}, response: c.req.valid("json").response,
)
.post(
"/permission/:requestID/reply",
describeRoute({
summary: "Respond to permission request",
description: "Approve or deny a permission request from the AI assistant.",
operationId: "permission.reply",
responses: {
200: {
description: "Permission processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
...errors(400, 404),
},
}),
validator(
"param",
z.object({
requestID: z.string(),
}),
),
validator("json", z.object({ reply: PermissionNext.Reply })),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await PermissionNext.reply({
requestID: params.requestID,
reply: json.reply,
}) })
return c.json(true) return c.json(true)
}, },
@@ -1602,14 +1565,14 @@ export namespace Server {
description: "List of pending permissions", description: "List of pending permissions",
content: { content: {
"application/json": { "application/json": {
schema: resolver(PermissionNext.Request.array()), schema: resolver(Permission.Info.array()),
}, },
}, },
}, },
}, },
}), }),
async (c) => { async (c) => {
const permissions = await PermissionNext.list() const permissions = Permission.list()
return c.json(permissions) return c.json(permissions)
}, },
) )
@@ -2690,44 +2653,12 @@ export namespace Server {
}, },
) )
.all("/*", async (c) => { .all("/*", async (c) => {
const path = c.req.path return proxy(`https://app.opencode.ai${c.req.path}`, {
const response = await proxy(`https://app.opencode.ai${path}`, {
...c.req, ...c.req,
headers: { headers: {
host: "app.opencode.ai", host: "app.opencode.ai",
}, },
}) })
// Cloudflare doesn't return Content-Type for static assets, so we need to add it
const mimeTypes: Record<string, string> = {
".js": "application/javascript",
".mjs": "application/javascript",
".css": "text/css",
".json": "application/json",
".wasm": "application/wasm",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".eot": "application/vnd.ms-fontobject",
}
for (const [ext, mime] of Object.entries(mimeTypes)) {
if (path.endsWith(ext)) {
const headers = new Headers(response.headers)
headers.set("Content-Type", mime)
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}
}
return response
}), }),
) )
@@ -2745,9 +2676,7 @@ export namespace Server {
return result return result
} }
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { export function listen(opts: { port: number; hostname: string; mdns?: boolean }) {
_corsWhitelist = opts.cors ?? []
const args = { const args = {
hostname: opts.hostname, hostname: opts.hostname,
idleTimeout: 0, idleTimeout: 0,
@@ -2773,7 +2702,7 @@ export namespace Server {
opts.hostname !== "localhost" && opts.hostname !== "localhost" &&
opts.hostname !== "::1" opts.hostname !== "::1"
if (shouldPublishMDNS) { if (shouldPublishMDNS) {
MDNS.publish(server.port!, `opencode-${server.port!}`) MDNS.publish(server.port!)
} else if (opts.mdns) { } else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
} }
+1 -12
View File
@@ -18,7 +18,6 @@ import { Command } from "../command"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
export namespace Session { export namespace Session {
const log = Log.create({ service: "session" }) const log = Log.create({ service: "session" })
@@ -63,7 +62,6 @@ export namespace Session {
compacting: z.number().optional(), compacting: z.number().optional(),
archived: z.number().optional(), archived: z.number().optional(),
}), }),
permission: PermissionNext.Ruleset.optional(),
revert: z revert: z
.object({ .object({
messageID: z.string(), messageID: z.string(),
@@ -128,7 +126,6 @@ export namespace Session {
.object({ .object({
parentID: Identifier.schema("session").optional(), parentID: Identifier.schema("session").optional(),
title: z.string().optional(), title: z.string().optional(),
permission: Info.shape.permission,
}) })
.optional(), .optional(),
async (input) => { async (input) => {
@@ -136,7 +133,6 @@ export namespace Session {
parentID: input?.parentID, parentID: input?.parentID,
directory: Instance.directory, directory: Instance.directory,
title: input?.title, title: input?.title,
permission: input?.permission,
}) })
}, },
) )
@@ -178,13 +174,7 @@ export namespace Session {
}) })
}) })
export async function createNext(input: { export async function createNext(input: { id?: string; title?: string; parentID?: string; directory: string }) {
id?: string
title?: string
parentID?: string
directory: string
permission?: PermissionNext.Ruleset
}) {
const result: Info = { const result: Info = {
id: Identifier.descending("session", input.id), id: Identifier.descending("session", input.id),
version: Installation.VERSION, version: Installation.VERSION,
@@ -192,7 +182,6 @@ export namespace Session {
directory: input.directory, directory: input.directory,
parentID: input.parentID, parentID: input.parentID,
title: input.title ?? createDefaultTitle(!!input.parentID), title: input.title ?? createDefaultTitle(!!input.parentID),
permission: input.permission,
time: { time: {
created: Date.now(), created: Date.now(),
updated: Date.now(), updated: Date.now(),
+8 -6
View File
@@ -17,8 +17,8 @@ import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system" import { SystemPrompt } from "./system"
import { ToolRegistry } from "@/tool/registry"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { PermissionNext } from "@/permission/next"
export namespace LLM { export namespace LLM {
const log = Log.create({ service: "llm" }) const log = Log.create({ service: "llm" })
@@ -200,11 +200,13 @@ export namespace LLM {
} }
async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) { async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission) const enabled = pipe(
for (const tool of Object.keys(input.tools)) { input.agent.tools,
if (input.user.tools?.[tool] === false || disabled.has(tool)) { mergeDeep(await ToolRegistry.enabled(input.agent)),
delete input.tools[tool] mergeDeep(input.user.tools ?? {}),
} )
for (const [key, value] of Object.entries(enabled)) {
if (value === false) delete input.tools[key]
} }
return input.tools return input.tools
} }
+29 -22
View File
@@ -3,6 +3,7 @@ import { Log } from "@/util/log"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { Session } from "." import { Session } from "."
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Permission } from "@/permission"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { SessionSummary } from "./summary" import { SessionSummary } from "./summary"
import { Bus } from "@/bus" import { Bus } from "@/bus"
@@ -12,8 +13,6 @@ import { Plugin } from "@/plugin"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { LLM } from "./llm" import { LLM } from "./llm"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
export namespace SessionProcessor { export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3 const DOOM_LOOP_THRESHOLD = 3
@@ -32,7 +31,6 @@ export namespace SessionProcessor {
let snapshot: string | undefined let snapshot: string | undefined
let blocked = false let blocked = false
let attempt = 0 let attempt = 0
let needsCompaction = false
const result = { const result = {
get message() { get message() {
@@ -43,7 +41,6 @@ export namespace SessionProcessor {
}, },
async process(streamInput: LLM.StreamInput) { async process(streamInput: LLM.StreamInput) {
log.info("process") log.info("process")
needsCompaction = false
const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true const shouldBreak = (await Config.get()).experimental?.continue_loop_on_deny !== true
while (true) { while (true) {
try { try {
@@ -152,18 +149,32 @@ export namespace SessionProcessor {
JSON.stringify(p.state.input) === JSON.stringify(value.input), JSON.stringify(p.state.input) === JSON.stringify(value.input),
) )
) { ) {
const agent = await Agent.get(input.assistantMessage.agent) const permission = await Agent.get(input.assistantMessage.mode).then((x) => x.permission)
await PermissionNext.ask({ if (permission.doom_loop === "ask") {
permission: "doom_loop", await Permission.ask({
patterns: [value.toolName], type: "doom_loop",
sessionID: input.assistantMessage.sessionID, pattern: value.toolName,
metadata: { sessionID: input.assistantMessage.sessionID,
tool: value.toolName, messageID: input.assistantMessage.id,
input: value.input, callID: value.toolCallId,
}, title: `Possible doom loop: "${value.toolName}" called ${DOOM_LOOP_THRESHOLD} times with identical arguments`,
always: [value.toolName], metadata: {
ruleset: agent.permission, tool: value.toolName,
}) input: value.input,
},
})
} else if (permission.doom_loop === "deny") {
throw new Permission.RejectedError(
input.assistantMessage.sessionID,
"doom_loop",
value.toolCallId,
{
tool: value.toolName,
input: value.input,
},
`You seem to be stuck in a doom loop, please stop repeating the same action`,
)
}
} }
} }
break break
@@ -201,6 +212,7 @@ export namespace SessionProcessor {
status: "error", status: "error",
input: value.input, input: value.input,
error: (value.error as any).toString(), error: (value.error as any).toString(),
metadata: value.error instanceof Permission.RejectedError ? value.error.metadata : undefined,
time: { time: {
start: match.state.time.start, start: match.state.time.start,
end: Date.now(), end: Date.now(),
@@ -208,7 +220,7 @@ export namespace SessionProcessor {
}, },
}) })
if (value.error instanceof PermissionNext.RejectedError) { if (value.error instanceof Permission.RejectedError) {
blocked = shouldBreak blocked = shouldBreak
} }
delete toolcalls[value.toolCallId] delete toolcalls[value.toolCallId]
@@ -267,9 +279,6 @@ export namespace SessionProcessor {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: input.assistantMessage.parentID, messageID: input.assistantMessage.parentID,
}) })
if (await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model })) {
needsCompaction = true
}
break break
case "text-start": case "text-start":
@@ -330,7 +339,6 @@ export namespace SessionProcessor {
}) })
continue continue
} }
if (needsCompaction) break
} }
} catch (e: any) { } catch (e: any) {
log.error("process", { log.error("process", {
@@ -390,7 +398,6 @@ export namespace SessionProcessor {
} }
input.assistantMessage.time.completed = Date.now() input.assistantMessage.time.completed = Date.now()
await Session.updateMessage(input.assistantMessage) await Session.updateMessage(input.assistantMessage)
if (needsCompaction) return "compact"
if (blocked) return "stop" if (blocked) return "stop"
if (input.assistantMessage.error) return "stop" if (input.assistantMessage.error) return "stop"
return "continue" return "continue"
+83 -153
View File
@@ -9,7 +9,7 @@ import { SessionRevert } from "./revert"
import { Session } from "." import { Session } from "."
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider" import { Provider } from "../provider/provider"
import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai" import { type Tool as AITool, tool, jsonSchema } from "ai"
import { SessionCompaction } from "./compaction" import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Bus } from "../bus" import { Bus } from "../bus"
@@ -20,8 +20,9 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt"
import MAX_STEPS from "../session/prompt/max-steps.txt" import MAX_STEPS from "../session/prompt/max-steps.txt"
import { defer } from "../util/defer" import { defer } from "../util/defer"
import { clone } from "remeda" import { clone, mergeDeep, pipe } from "remeda"
import { ToolRegistry } from "../tool/registry" import { ToolRegistry } from "../tool/registry"
import { Wildcard } from "../util/wildcard"
import { MCP } from "../mcp" import { MCP } from "../mcp"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { ReadTool } from "../tool/read" import { ReadTool } from "../tool/read"
@@ -38,8 +39,6 @@ import { NamedError } from "@opencode-ai/util/error"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task" import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { PermissionNext } from "@/permission/next"
import { SessionStatus } from "./status" import { SessionStatus } from "./status"
import { LLM } from "./llm" import { LLM } from "./llm"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
@@ -69,9 +68,6 @@ export namespace SessionPrompt {
async (current) => { async (current) => {
for (const item of Object.values(current)) { for (const item of Object.values(current)) {
item.abort.abort() item.abort.abort()
for (const callback of item.callbacks) {
callback.reject()
}
} }
}, },
) )
@@ -92,12 +88,7 @@ export namespace SessionPrompt {
.optional(), .optional(),
agent: z.string().optional(), agent: z.string().optional(),
noReply: z.boolean().optional(), noReply: z.boolean().optional(),
tools: z tools: z.record(z.string(), z.boolean()).optional(),
.record(z.string(), z.boolean())
.optional()
.describe(
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
),
system: z.string().optional(), system: z.string().optional(),
variant: z.string().optional(), variant: z.string().optional(),
parts: z.array( parts: z.array(
@@ -154,23 +145,6 @@ export namespace SessionPrompt {
const message = await createUserMessage(input) const message = await createUserMessage(input)
await Session.touch(input.sessionID) await Session.touch(input.sessionID)
// this is backwards compatibility for allowing `tools` to be specified when
// prompting
const permissions: PermissionNext.Ruleset = []
for (const [tool, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({
permission: tool,
action: enabled ? "allow" : "deny",
pattern: "*",
})
}
if (permissions.length > 0) {
session.permission = permissions
await Session.update(session.id, (draft) => {
draft.permission = permissions
})
}
if (input.noReply === true) { if (input.noReply === true) {
return message return message
} }
@@ -266,7 +240,6 @@ export namespace SessionPrompt {
using _ = defer(() => cancel(sessionID)) using _ = defer(() => cancel(sessionID))
let step = 0 let step = 0
const session = await Session.get(sessionID)
while (true) { while (true) {
SessionStatus.set(sessionID, { type: "busy" }) SessionStatus.set(sessionID, { type: "busy" })
log.info("loop", { step, sessionID }) log.info("loop", { step, sessionID })
@@ -303,7 +276,7 @@ export namespace SessionPrompt {
step++ step++
if (step === 1) if (step === 1)
ensureTitle({ ensureTitle({
session, session: await Session.get(sessionID),
modelID: lastUser.model.modelID, modelID: lastUser.model.modelID,
providerID: lastUser.model.providerID, providerID: lastUser.model.providerID,
message: msgs.find((m) => m.info.role === "user")!, message: msgs.find((m) => m.info.role === "user")!,
@@ -377,35 +350,28 @@ export namespace SessionPrompt {
{ args: taskArgs }, { args: taskArgs },
) )
let executionError: Error | undefined let executionError: Error | undefined
const taskAgent = await Agent.get(task.agent) const result = await taskTool
const taskCtx: Tool.Context = { .execute(taskArgs, {
agent: task.agent, agent: task.agent,
messageID: assistantMessage.id, messageID: assistantMessage.id,
sessionID: sessionID, sessionID: sessionID,
abort, abort,
async metadata(input) { async metadata(input) {
await Session.updatePart({ await Session.updatePart({
...part, ...part,
type: "tool", type: "tool",
state: { state: {
...part.state, ...part.state,
...input, ...input,
}, },
} satisfies MessageV2.ToolPart) } satisfies MessageV2.ToolPart)
}, },
async ask(req) { })
await PermissionNext.ask({ .catch((error) => {
...req, executionError = error
sessionID: sessionID, log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []), return undefined
}) })
},
}
const result = await taskTool.execute(taskArgs, taskCtx).catch((error) => {
executionError = error
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
return undefined
})
await Plugin.trigger( await Plugin.trigger(
"tool.execute.after", "tool.execute.after",
{ {
@@ -507,7 +473,7 @@ export namespace SessionPrompt {
// normal processing // normal processing
const agent = await Agent.get(lastUser.agent) const agent = await Agent.get(lastUser.agent)
const maxSteps = agent.steps ?? Infinity const maxSteps = agent.maxSteps ?? Infinity
const isLastStep = step >= maxSteps const isLastStep = step >= maxSteps
msgs = insertReminders({ msgs = insertReminders({
messages: msgs, messages: msgs,
@@ -545,7 +511,7 @@ export namespace SessionPrompt {
}) })
const tools = await resolveTools({ const tools = await resolveTools({
agent, agent,
session, sessionID,
model, model,
tools: lastUser.tools, tools: lastUser.tools,
processor, processor,
@@ -583,14 +549,6 @@ export namespace SessionPrompt {
model, model,
}) })
if (result === "stop") break if (result === "stop") break
if (result === "compact") {
await SessionCompaction.create({
sessionID,
agent: lastUser.agent,
model: lastUser.model,
auto: true,
})
}
continue continue
} }
SessionCompaction.prune({ sessionID }) SessionCompaction.prune({ sessionID })
@@ -615,73 +573,67 @@ export namespace SessionPrompt {
async function resolveTools(input: { async function resolveTools(input: {
agent: Agent.Info agent: Agent.Info
model: Provider.Model model: Provider.Model
session: Session.Info sessionID: string
tools?: Record<string, boolean> tools?: Record<string, boolean>
processor: SessionProcessor.Info processor: SessionProcessor.Info
}) { }) {
using _ = log.time("resolveTools") using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {} const tools: Record<string, AITool> = {}
const enabledTools = pipe(
const context = (args: any, options: ToolCallOptions): Tool.Context => ({ input.agent.tools,
sessionID: input.session.id, mergeDeep(await ToolRegistry.enabled(input.agent)),
abort: options.abortSignal!, mergeDeep(input.tools ?? {}),
messageID: input.processor.message.id, )
callID: options.toolCallId, for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
extra: { model: input.model }, if (Wildcard.all(item.id, enabledTools) === false) continue
agent: input.agent.name,
metadata: async (val: { title?: string; metadata?: any }) => {
const match = input.processor.partFromToolCall(options.toolCallId)
if (match && match.state.status === "running") {
await Session.updatePart({
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: {
start: Date.now(),
},
},
})
}
},
async ask(req) {
await PermissionNext.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
ruleset: PermissionNext.merge(input.agent.permission, input.session.permission ?? []),
})
},
})
for (const item of await ToolRegistry.tools(input.model.providerID)) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({ tools[item.id] = tool({
id: item.id as any, id: item.id as any,
description: item.description, description: item.description,
inputSchema: jsonSchema(schema as any), inputSchema: jsonSchema(schema as any),
async execute(args, options) { async execute(args, options) {
const ctx = context(args, options)
await Plugin.trigger( await Plugin.trigger(
"tool.execute.before", "tool.execute.before",
{ {
tool: item.id, tool: item.id,
sessionID: ctx.sessionID, sessionID: input.sessionID,
callID: ctx.callID, callID: options.toolCallId,
}, },
{ {
args, args,
}, },
) )
const result = await item.execute(args, ctx) const result = await item.execute(args, {
sessionID: input.sessionID,
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
extra: { model: input.model },
agent: input.agent.name,
metadata: async (val) => {
const match = input.processor.partFromToolCall(options.toolCallId)
if (match && match.state.status === "running") {
await Session.updatePart({
...match,
state: {
title: val.title,
metadata: val.metadata,
status: "running",
input: args,
time: {
start: Date.now(),
},
},
})
}
},
})
await Plugin.trigger( await Plugin.trigger(
"tool.execute.after", "tool.execute.after",
{ {
tool: item.id, tool: item.id,
sessionID: ctx.sessionID, sessionID: input.sessionID,
callID: ctx.callID, callID: options.toolCallId,
}, },
result, result,
) )
@@ -695,41 +647,31 @@ export namespace SessionPrompt {
}, },
}) })
} }
for (const [key, item] of Object.entries(await MCP.tools())) { for (const [key, item] of Object.entries(await MCP.tools())) {
if (Wildcard.all(key, enabledTools) === false) continue
const execute = item.execute const execute = item.execute
if (!execute) continue if (!execute) continue
// Wrap execute to add plugin hooks and format output // Wrap execute to add plugin hooks and format output
item.execute = async (args, opts) => { item.execute = async (args, opts) => {
const ctx = context(args, opts)
await Plugin.trigger( await Plugin.trigger(
"tool.execute.before", "tool.execute.before",
{ {
tool: key, tool: key,
sessionID: ctx.sessionID, sessionID: input.sessionID,
callID: opts.toolCallId, callID: opts.toolCallId,
}, },
{ {
args, args,
}, },
) )
await ctx.ask({
permission: key,
metadata: {},
patterns: ["*"],
always: ["*"],
})
const result = await execute(args, opts) const result = await execute(args, opts)
await Plugin.trigger( await Plugin.trigger(
"tool.execute.after", "tool.execute.after",
{ {
tool: key, tool: key,
sessionID: ctx.sessionID, sessionID: input.sessionID,
callID: opts.toolCallId, callID: opts.toolCallId,
}, },
result, result,
@@ -744,7 +686,7 @@ export namespace SessionPrompt {
} else if (contentItem.type === "image") { } else if (contentItem.type === "image") {
attachments.push({ attachments.push({
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
sessionID: input.session.id, sessionID: input.sessionID,
messageID: input.processor.message.id, messageID: input.processor.message.id,
type: "file", type: "file",
mime: contentItem.mimeType, mime: contentItem.mimeType,
@@ -884,16 +826,14 @@ export namespace SessionPrompt {
await ReadTool.init() await ReadTool.init()
.then(async (t) => { .then(async (t) => {
const model = await Provider.getModel(info.model.providerID, info.model.modelID) const model = await Provider.getModel(info.model.providerID, info.model.modelID)
const readCtx: Tool.Context = { const result = await t.execute(args, {
sessionID: input.sessionID, sessionID: input.sessionID,
abort: new AbortController().signal, abort: new AbortController().signal,
agent: input.agent!, agent: input.agent!,
messageID: info.id, messageID: info.id,
extra: { bypassCwdCheck: true, model }, extra: { bypassCwdCheck: true, model },
metadata: async () => {}, metadata: async () => {},
ask: async () => {}, })
}
const result = await t.execute(args, readCtx)
pieces.push({ pieces.push({
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
messageID: info.id, messageID: info.id,
@@ -945,16 +885,16 @@ export namespace SessionPrompt {
if (part.mime === "application/x-directory") { if (part.mime === "application/x-directory") {
const args = { path: filepath } const args = { path: filepath }
const listCtx: Tool.Context = { const result = await ListTool.init().then((t) =>
sessionID: input.sessionID, t.execute(args, {
abort: new AbortController().signal, sessionID: input.sessionID,
agent: input.agent!, abort: new AbortController().signal,
messageID: info.id, agent: input.agent!,
extra: { bypassCwdCheck: true }, messageID: info.id,
metadata: async () => {}, extra: { bypassCwdCheck: true },
ask: async () => {}, metadata: async () => {},
} }),
const result = await ListTool.init().then((t) => t.execute(args, listCtx)) )
return [ return [
{ {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
@@ -1391,7 +1331,7 @@ export namespace SessionPrompt {
} }
if (command.agent) { if (command.agent) {
const cmdAgent = await Agent.get(command.agent) const cmdAgent = await Agent.get(command.agent)
if (cmdAgent?.model) { if (cmdAgent.model) {
return cmdAgent.model return cmdAgent.model
} }
} }
@@ -1413,16 +1353,6 @@ export namespace SessionPrompt {
throw e throw e
} }
const agent = await Agent.get(agentName) const agent = await Agent.get(agentName)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: error.toObject(),
})
throw error
}
const parts = const parts =
(agent.mode === "subagent" && command.subtask !== false) || command.subtask === true (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
+6 -10
View File
@@ -5,22 +5,18 @@ export namespace SessionRetry {
export const RETRY_INITIAL_DELAY = 2000 export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
export async function sleep(ms: number, signal: AbortSignal): Promise<void> { export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const abortHandler = () => { const timeout = setTimeout(resolve, ms)
clearTimeout(timeout) signal.addEventListener(
reject(new DOMException("Aborted", "AbortError")) "abort",
}
const timeout = setTimeout(
() => { () => {
signal.removeEventListener("abort", abortHandler) clearTimeout(timeout)
resolve() reject(new DOMException("Aborted", "AbortError"))
}, },
Math.min(ms, RETRY_MAX_DELAY), { once: true },
) )
signal.addEventListener("abort", abortHandler, { once: true })
}) })
} }
+1 -1
View File
@@ -44,7 +44,7 @@ export namespace SystemPrompt {
`</env>`, `</env>`,
`<files>`, `<files>`,
` ${ ` ${
project.vcs === "git" && false project.vcs === "git"
? await Ripgrep.tree({ ? await Ripgrep.tree({
cwd: Instance.directory, cwd: Instance.directory,
limit: 200, limit: 200,
+1 -1
View File
@@ -127,7 +127,7 @@ export namespace ShareNext {
const queued = queue.get(sessionID) const queued = queue.get(sessionID)
if (!queued) return if (!queued) return
queue.delete(sessionID) queue.delete(sessionID)
const share = await get(sessionID).catch(() => undefined) const share = await get(sessionID)
if (!share) return if (!share) return
await fetch(`${await url()}/api/share/${share.id}/sync`, { await fetch(`${await url()}/api/share/${share.id}/sync`, {

Some files were not shown because too many files have changed in this diff Show More