Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b85247c75 | ||
|
|
812b1ccb3b | ||
|
|
636fa3527f | ||
|
|
00e2ed04e7 |
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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`
|
|
||||||
|
|||||||
@@ -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) |
|
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
[install]
|
[install]
|
||||||
exact = true
|
exact = true
|
||||||
|
|
||||||
[test]
|
|
||||||
root = "./do-not-run-tests-from-root"
|
|
||||||
|
|
||||||
|
|||||||
Generated
+3
-3
@@ -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
@@ -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
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw="
|
"nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -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
@@ -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,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
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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(() =>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()}
|
||||||
>
|
>
|
||||||
|
|||||||
+410
-749
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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,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,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,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,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",
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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,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>
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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')`,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user