mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 01:06:16 -04:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d61eef141 | |||
| e82542b802 | |||
| 2006259a02 | |||
| 472b33113e | |||
| 218147271c | |||
| a29deb1ee9 | |||
| f24049736e | |||
| 21a644fef5 | |||
| 106f8e94d6 | |||
| 7a2c49e762 | |||
| 9b4d5b0395 | |||
| f20655bef4 | |||
| 31c099be43 | |||
| 155e1f20d6 | |||
| fe0c4f8c74 | |||
| 53ff1b57c9 |
@@ -94,8 +94,12 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
@@ -531,7 +535,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
@@ -553,7 +557,6 @@
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bonjour-service": "1.3.0",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "catalog:",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
@@ -787,6 +790,34 @@
|
||||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.16.2",
|
||||
@@ -1776,6 +1807,8 @@
|
||||
|
||||
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
|
||||
|
||||
"@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
|
||||
|
||||
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
@@ -3336,7 +3369,7 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
@@ -5722,6 +5755,8 @@
|
||||
|
||||
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@opencode-ai/tui/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
|
||||
|
||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
@@ -5730,8 +5765,6 @@
|
||||
|
||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"@opentui/solid/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
|
||||
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
@@ -5920,6 +5953,8 @@
|
||||
|
||||
"dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
|
||||
|
||||
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
@@ -5978,10 +6013,12 @@
|
||||
|
||||
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
|
||||
|
||||
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||
@@ -6388,6 +6425,8 @@
|
||||
|
||||
"@jsx-email/cli/vite/rollup": ["rollup@3.30.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA=="],
|
||||
|
||||
"@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-5DhbOm/gs2mfjmNYdZHkr0ZopgSC2HcGN9/r1noGqhc=",
|
||||
"aarch64-linux": "sha256-0dIKcqKmhrPhRpabnfM20wnqt/AkoCWDMgG9cQZ8P3o=",
|
||||
"aarch64-darwin": "sha256-Sx3G63vORj69u2AOMDfpk6NU7fMgxsbYBh3jnGohNXI=",
|
||||
"x86_64-darwin": "sha256-4g2ydNayqNqWlBeQt90rUI6bY5dMvXP4OmQ6QWaJbuI="
|
||||
"x86_64-linux": "sha256-yZeq16sWAtsAHZO3pbsr90t3+8PlOueRym2J/Kgpj1U=",
|
||||
"aarch64-linux": "sha256-m54/gm6R8MyQXBh68K8McAfZ9nLLK08nYQwMvAsTs8o=",
|
||||
"aarch64-darwin": "sha256-pbtcF4ZCUqKO/SVLxv4wskl+O1LlT2RNsUV5d4s9+WY=",
|
||||
"x86_64-darwin": "sha256-o3ucdbFNy2y9Hrb594Y9AtpRBUPglhqZLtTyiqMECR8="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./desktop-menu": "./src/desktop-menu.ts",
|
||||
"./updater": "./src/updater.ts",
|
||||
"./wsl/types": "./src/wsl/types.ts",
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
|
||||
@@ -70,7 +70,6 @@ function UiI18nBridge(props: ParentProps) {
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
updaterEnabled?: boolean
|
||||
deepLinks?: string[]
|
||||
}
|
||||
api?: {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
|
||||
const statusLabels = {
|
||||
connected: "mcp.status.connected",
|
||||
@@ -19,10 +16,7 @@ const statusLabels = {
|
||||
|
||||
export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const queryOptions = useQueryOptions()
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
@@ -30,21 +24,7 @@ export const DialogSelectMcp: Component = () => {
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const toggle = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
if (status?.status === "needs_auth") {
|
||||
await sdk.client.mcp.auth.authenticate({ name })
|
||||
return
|
||||
}
|
||||
await sdk.client.mcp.connect({ name })
|
||||
},
|
||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||
}))
|
||||
const toggle = useMcpToggle()
|
||||
|
||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||
const totalCount = createMemo(() => items().length)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import type { Platform } from "@/context/platform"
|
||||
|
||||
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
|
||||
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
|
||||
return "server" as const
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { directoryPickerKind } from "./directory-picker-policy"
|
||||
|
||||
const local = {
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: { url: "http://localhost:4096" },
|
||||
} as const
|
||||
const remote = {
|
||||
type: "ssh",
|
||||
host: "example.test",
|
||||
http: { url: "http://localhost:4096" },
|
||||
} as const
|
||||
|
||||
describe("directoryPickerKind", () => {
|
||||
test("uses the native picker only for local desktop projects", () => {
|
||||
expect(directoryPickerKind("desktop", local)).toBe("native")
|
||||
expect(directoryPickerKind("desktop", remote)).toBe("server")
|
||||
expect(directoryPickerKind("web", local)).toBe("server")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DialogSelectDirectory } from "./dialog-select-directory"
|
||||
import { directoryPickerKind } from "./directory-picker-policy"
|
||||
|
||||
type DirectoryPickerInput = {
|
||||
server: ServerConnection.Any
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
}
|
||||
|
||||
export function useDirectoryPicker() {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
|
||||
return (input: DirectoryPickerInput) => {
|
||||
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
|
||||
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory {...input} />,
|
||||
() => input.onSelect(null),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
navigatePromptHistory,
|
||||
@@ -73,6 +73,8 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { useDirectoryPicker } from "./directory-picker"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { useQueries } from "@tanstack/solid-query"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
@@ -140,6 +142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const settings = useSettings()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
let editorRef!: HTMLDivElement
|
||||
@@ -468,7 +471,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const pick = () => {
|
||||
if (server.isLocal()) {
|
||||
fileInputRef?.click()
|
||||
pickAttachmentFiles({
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk.directory,
|
||||
fallback: () => fileInputRef?.click(),
|
||||
onFile: addAttachment,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
void import("@/components/dialog-select-file").then((module) =>
|
||||
@@ -1094,7 +1108,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
@@ -1385,7 +1399,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
const addProject = async () => {
|
||||
const addProject = () => {
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
const select = (result: string | string[] | null) => {
|
||||
@@ -1393,15 +1407,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!directory) return
|
||||
selectProject(directory)
|
||||
}
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
|
||||
return
|
||||
}
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
|
||||
() => select(null),
|
||||
)
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
onSelect: select,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { attachmentMime } from "./files"
|
||||
import { attachmentMime, pickAttachmentFiles } from "./files"
|
||||
import { pasteMode } from "./paste"
|
||||
|
||||
describe("attachmentMime", () => {
|
||||
@@ -24,6 +24,70 @@ describe("attachmentMime", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickAttachmentFiles", () => {
|
||||
test("reads the current project directory for every native picker invocation", async () => {
|
||||
const paths: string[] = []
|
||||
const files: File[] = []
|
||||
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
|
||||
let directory = "C:\\Projects\\LoremIpsum"
|
||||
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
|
||||
paths.push(options?.defaultPath ?? "")
|
||||
await onFile?.(file)
|
||||
}
|
||||
|
||||
pickAttachmentFiles({
|
||||
picker,
|
||||
directory: () => directory,
|
||||
fallback: () => undefined,
|
||||
onFile: async (selected) => files.push(selected),
|
||||
onError: () => undefined,
|
||||
})
|
||||
await Promise.resolve()
|
||||
directory = "C:\\Projects\\DolorSit"
|
||||
pickAttachmentFiles({
|
||||
picker,
|
||||
directory: () => directory,
|
||||
fallback: () => undefined,
|
||||
onFile: async (selected) => files.push(selected),
|
||||
onError: () => undefined,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(files).toEqual([file, file])
|
||||
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
|
||||
})
|
||||
|
||||
test("uses the browser file input when no native picker exists", async () => {
|
||||
let fallback = 0
|
||||
pickAttachmentFiles({
|
||||
directory: () => "/projects/consectetur-adipiscing",
|
||||
fallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
onFile: async () => undefined,
|
||||
onError: () => undefined,
|
||||
})
|
||||
expect(fallback).toBe(1)
|
||||
})
|
||||
|
||||
test("reports native picker failures without rejecting", async () => {
|
||||
const error = new Error("picker unavailable")
|
||||
const errors: unknown[] = []
|
||||
const handled = Promise.withResolvers<void>()
|
||||
pickAttachmentFiles({
|
||||
picker: async () => Promise.reject(error),
|
||||
directory: () => "C:\\Projects\\LoremIpsum",
|
||||
fallback: () => undefined,
|
||||
onFile: async () => undefined,
|
||||
onError: (cause) => {
|
||||
errors.push(cause)
|
||||
handled.resolve()
|
||||
},
|
||||
})
|
||||
await handled.promise
|
||||
expect(errors).toEqual([error])
|
||||
})
|
||||
})
|
||||
|
||||
describe("pasteMode", () => {
|
||||
test("uses native paste for short single-line text", () => {
|
||||
expect(pasteMode("hello world")).toBe("native")
|
||||
|
||||
@@ -2,6 +2,38 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
|
||||
|
||||
export { ACCEPTED_FILE_TYPES }
|
||||
|
||||
type AttachmentPicker = (
|
||||
options: {
|
||||
defaultPath?: string
|
||||
multiple?: boolean
|
||||
accept?: string[]
|
||||
},
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
) => Promise<void>
|
||||
|
||||
export function pickAttachmentFiles(input: {
|
||||
picker?: AttachmentPicker
|
||||
directory: () => string
|
||||
fallback: () => void
|
||||
onFile: (file: File) => Promise<unknown>
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
if (!input.picker) {
|
||||
input.fallback()
|
||||
return
|
||||
}
|
||||
void input
|
||||
.picker(
|
||||
{
|
||||
defaultPath: input.directory(),
|
||||
multiple: true,
|
||||
accept: ACCEPTED_FILE_TYPES,
|
||||
},
|
||||
input.onFile,
|
||||
)
|
||||
.catch(input.onError)
|
||||
}
|
||||
|
||||
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||
const IMAGE_EXTS = new Map([
|
||||
["gif", "image/gif"],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
@@ -8,13 +7,13 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "./updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -91,9 +90,7 @@ export const SettingsGeneral: Component = () => {
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
@@ -123,58 +120,6 @@ export const SettingsGeneral: Component = () => {
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
|
||||
void platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
if (!result.updateAvailable) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const actions = platform.updateAndRestart
|
||||
? [
|
||||
{
|
||||
label: language.t("toast.update.action.installRestart"),
|
||||
onClick: async () => {
|
||||
await platform.updateAndRestart!()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
|
||||
showToast({
|
||||
persistent: true,
|
||||
icon: "download",
|
||||
title: language.t("toast.update.title"),
|
||||
description: language.t("toast.update.description", { version: result.version ?? "" }),
|
||||
actions,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const serverSync = useServerSync()
|
||||
@@ -723,19 +668,6 @@ export const SettingsGeneral: Component = () => {
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
@@ -752,10 +684,8 @@ export const SettingsGeneral: Component = () => {
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
||||
{store.checking
|
||||
? language.t("settings.updates.action.checking")
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
|
||||
{language.t(updater.action().label)}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
@@ -8,13 +7,13 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -93,9 +92,7 @@ export const SettingsGeneralV2: Component = () => {
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
const updater = useUpdaterAction()
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
@@ -125,58 +122,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
|
||||
void platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
if (!result.updateAvailable) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const actions = platform.updateAndRestart
|
||||
? [
|
||||
{
|
||||
label: language.t("toast.update.action.installRestart"),
|
||||
onClick: async () => {
|
||||
await platform.updateAndRestart!()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
|
||||
showToast({
|
||||
persistent: true,
|
||||
icon: "download",
|
||||
title: language.t("toast.update.title"),
|
||||
description: language.t("toast.update.description", { version: result.version ?? "" }),
|
||||
actions,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const serverSync = useServerSync()
|
||||
@@ -740,19 +685,6 @@ export const SettingsGeneralV2: Component = () => {
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
@@ -769,10 +701,8 @@ export const SettingsGeneralV2: Component = () => {
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
||||
{store.checking
|
||||
? language.t("settings.updates.action.checking")
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
|
||||
{language.t(updater.action().label)}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
@@ -11,14 +10,12 @@ import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -99,37 +96,6 @@ const useDefaultServerKey = (
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const queryOptions = useQueryOptions()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
if (status?.status === "needs_auth") {
|
||||
await sdk.client.mcp.auth.authenticate({ name })
|
||||
return
|
||||
}
|
||||
await sdk.client.mcp.connect({ name })
|
||||
},
|
||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
type ServerStatusState = {
|
||||
servers: () => ServerStatusItem[]
|
||||
defaultKey: () => ServerConnection.Key | undefined
|
||||
@@ -311,7 +277,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
dialogRun += 1
|
||||
})
|
||||
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const toggleMcp = useMcpToggle()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { updaterAction } from "./updater-action"
|
||||
|
||||
describe("updaterAction", () => {
|
||||
test("disables update actions when the platform has no updater", () => {
|
||||
expect(updaterAction(undefined)).toEqual({ label: "settings.updates.action.checkNow" })
|
||||
})
|
||||
|
||||
test("projects updater transitions into one settings action", () => {
|
||||
expect(updaterAction({ status: "idle" })).toEqual({
|
||||
label: "settings.updates.action.checkNow",
|
||||
run: "check",
|
||||
})
|
||||
expect(updaterAction({ status: "checking" })).toEqual({ label: "settings.updates.action.checking" })
|
||||
expect(updaterAction({ status: "downloading", version: "2.0.0" })).toEqual({
|
||||
label: "settings.updates.action.downloading",
|
||||
})
|
||||
expect(updaterAction({ status: "ready", version: "2.0.0" })).toEqual({
|
||||
label: "toast.update.action.installRestart",
|
||||
run: "install",
|
||||
})
|
||||
expect(updaterAction({ status: "installing", version: "2.0.0" })).toEqual({
|
||||
label: "settings.updates.action.installing",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import type { UpdaterState } from "@/updater"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
export function updaterAction(state: UpdaterState | undefined) {
|
||||
if (!state) return { label: "settings.updates.action.checkNow" as const }
|
||||
switch (state.status) {
|
||||
case "checking":
|
||||
return { label: "settings.updates.action.checking" as const }
|
||||
case "downloading":
|
||||
return { label: "settings.updates.action.downloading" as const }
|
||||
case "ready":
|
||||
return { label: "toast.update.action.installRestart" as const, run: "install" as const }
|
||||
case "installing":
|
||||
return { label: "settings.updates.action.installing" as const }
|
||||
case "disabled":
|
||||
return { label: "settings.updates.action.checkNow" as const }
|
||||
default:
|
||||
return { label: "settings.updates.action.checkNow" as const, run: "check" as const }
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdaterAction() {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const action = createMemo(() => updaterAction(platform.updater?.state()))
|
||||
|
||||
return {
|
||||
action,
|
||||
async run() {
|
||||
const run = action().run
|
||||
if (run === "install") return platform.updater?.install()
|
||||
if (run !== "check") return
|
||||
|
||||
const state = await platform.updater?.check()
|
||||
if (state?.status === "up-to-date") {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
}
|
||||
if (state?.status === "error") {
|
||||
showToast({ title: language.t("common.requestFailed"), description: state.message })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -609,6 +609,9 @@ export const createDirSyncContext = (
|
||||
)
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
|
||||
},
|
||||
absolute,
|
||||
get directory() {
|
||||
return current()[0].path.directory
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { toggleMcp } from "./mcp"
|
||||
|
||||
describe("toggleMcp", () => {
|
||||
test("runs the status action before refreshing the owning query", async () => {
|
||||
const calls: string[] = []
|
||||
const input = (status: "connected" | "needs_auth" | "disabled") => ({
|
||||
status,
|
||||
connect: async () => {
|
||||
calls.push("connect")
|
||||
},
|
||||
disconnect: async () => {
|
||||
calls.push("disconnect")
|
||||
},
|
||||
authenticate: async () => {
|
||||
calls.push("authenticate")
|
||||
},
|
||||
refresh: async () => {
|
||||
calls.push("refresh")
|
||||
},
|
||||
})
|
||||
|
||||
await toggleMcp(input("connected"))
|
||||
expect(calls).toEqual(["disconnect", "refresh"])
|
||||
|
||||
calls.length = 0
|
||||
await toggleMcp(input("needs_auth"))
|
||||
expect(calls).toEqual(["authenticate", "refresh"])
|
||||
|
||||
calls.length = 0
|
||||
await toggleMcp(input("disabled"))
|
||||
expect(calls).toEqual(["connect", "refresh"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export async function toggleMcp(input: {
|
||||
status: McpStatus["status"]
|
||||
connect: () => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
authenticate: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}) {
|
||||
await {
|
||||
connected: input.disconnect,
|
||||
needs_auth: input.authenticate,
|
||||
disabled: input.connect,
|
||||
failed: input.connect,
|
||||
needs_client_registration: input.connect,
|
||||
}[input.status]()
|
||||
await input.refresh()
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
export function useMcpToggle() {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: sync.mcp.toggle,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -4,12 +4,18 @@ import type { Accessor } from "solid-js"
|
||||
import type { DesktopMenuAction } from "../desktop-menu"
|
||||
import { ServerConnection } from "./server"
|
||||
import type { WslServersPlatform } from "../wsl/types"
|
||||
import type { UpdaterPlatform } from "../updater"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
||||
type OpenAttachmentPickerOptions = {
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
accept?: string[]
|
||||
extensions?: string[]
|
||||
defaultPath?: string
|
||||
}
|
||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||
type PlatformName = "web" | "desktop"
|
||||
type DesktopOS = "macos" | "windows" | "linux"
|
||||
|
||||
@@ -21,13 +27,7 @@ export type FatalRendererErrorLog = {
|
||||
os?: DesktopOS
|
||||
}
|
||||
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: PlatformName
|
||||
|
||||
/** Desktop OS (Tauri only) */
|
||||
os?: DesktopOS
|
||||
|
||||
type PlatformBase = {
|
||||
/** App version */
|
||||
version?: string
|
||||
|
||||
@@ -49,23 +49,20 @@ export type Platform = {
|
||||
/** Send a system notification (optional deep link) */
|
||||
notify(title: string, description?: string, href?: string): Promise<void>
|
||||
|
||||
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
||||
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
||||
openAttachmentPickerDialog?(
|
||||
opts: OpenAttachmentPickerOptions,
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
): Promise<void>
|
||||
|
||||
/** Open native file picker dialog (Tauri only) */
|
||||
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
|
||||
|
||||
/** Save file picker dialog (Tauri only) */
|
||||
/** Open a native save file picker dialog (desktop only) */
|
||||
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||
|
||||
/** Check for a downloadable desktop update */
|
||||
checkUpdate?(): Promise<UpdateInfo>
|
||||
|
||||
/** Install the downloaded update using the platform restart flow */
|
||||
updateAndRestart?(): Promise<void>
|
||||
/** Application-global desktop updater */
|
||||
updater?: UpdaterPlatform
|
||||
|
||||
/** Fetch override */
|
||||
fetch?: typeof fetch
|
||||
@@ -113,6 +110,16 @@ export type Platform = {
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
(
|
||||
| { platform: "web"; os?: never }
|
||||
| {
|
||||
platform: "desktop"
|
||||
os?: DesktopOS
|
||||
openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||
}
|
||||
)
|
||||
|
||||
export type DisplayBackend = "auto" | "wayland"
|
||||
|
||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ServerConnection, useServer } from "./server"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -481,6 +482,28 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||
todo: {
|
||||
set: setSessionTodo,
|
||||
},
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
const sdk = sdkFor(key)
|
||||
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
|
||||
await toggleMcp({
|
||||
status,
|
||||
connect: async () => {
|
||||
await sdk.mcp.connect({ name })
|
||||
},
|
||||
disconnect: async () => {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
},
|
||||
authenticate: async () => {
|
||||
await sdk.mcp.auth.authenticate({ name })
|
||||
},
|
||||
refresh: async () => {
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,9 +35,6 @@ export interface Settings {
|
||||
showCustomAgents: boolean
|
||||
newLayoutDesigns?: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
mono: string
|
||||
@@ -122,9 +119,6 @@ const defaultSettings: Settings = {
|
||||
showSessionProgressBar: true,
|
||||
showCustomAgents: false,
|
||||
},
|
||||
updates: {
|
||||
startup: true,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
mono: "",
|
||||
@@ -250,12 +244,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
setStartup(value: boolean) {
|
||||
setStore("updates", "startup", value)
|
||||
},
|
||||
},
|
||||
appearance: {
|
||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||
setFontSize(value: number) {
|
||||
|
||||
@@ -866,6 +866,8 @@ export const dict = {
|
||||
"settings.updates.row.check.description": "Manually check for updates and install if available",
|
||||
"settings.updates.action.checkNow": "Check now",
|
||||
"settings.updates.action.checking": "Checking...",
|
||||
"settings.updates.action.downloading": "Downloading...",
|
||||
"settings.updates.action.installing": "Installing...",
|
||||
"settings.updates.toast.latest.title": "You're up to date",
|
||||
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
|
||||
"sound.option.none": "None",
|
||||
|
||||
@@ -4,6 +4,7 @@ export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||
export { useWslServers } from "./wsl/context"
|
||||
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./updater"
|
||||
export {
|
||||
type WslDistroProbe,
|
||||
type WslInstalledDistro,
|
||||
|
||||
@@ -225,8 +225,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
const formattedError = () => formatError(props.error, language.t)
|
||||
let recordedFatalError: Promise<void> | undefined
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
version: undefined as string | undefined,
|
||||
actionError: undefined as string | undefined,
|
||||
})
|
||||
|
||||
@@ -247,32 +245,25 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
await platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
setStore("actionError", undefined)
|
||||
if (result.updateAvailable && result.version) setStore("version", result.version)
|
||||
})
|
||||
.catch((err) => {
|
||||
setStore("actionError", formatError(err, language.t))
|
||||
})
|
||||
.finally(() => {
|
||||
setStore("checking", false)
|
||||
})
|
||||
const state = await platform.updater?.check()
|
||||
setStore("actionError", state?.status === "error" ? state.message : undefined)
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
if (!platform.updateAndRestart) return
|
||||
await platform
|
||||
.updateAndRestart()
|
||||
await platform.updater
|
||||
?.install()
|
||||
.then(() => setStore("actionError", undefined))
|
||||
.catch((err) => {
|
||||
setStore("actionError", formatError(err, language.t))
|
||||
})
|
||||
}
|
||||
|
||||
const updateVersion = () => {
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return
|
||||
return state.version
|
||||
}
|
||||
|
||||
async function exportDebugLogs() {
|
||||
const exportLogs = platform.exportDebugLogs
|
||||
if (!exportLogs) return
|
||||
@@ -327,20 +318,27 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<Show when={platform.checkUpdate}>
|
||||
<Show when={platform.updater}>
|
||||
<Show
|
||||
when={store.version}
|
||||
when={updateVersion()}
|
||||
fallback={
|
||||
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
|
||||
{store.checking
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
onClick={checkForUpdates}
|
||||
disabled={["checking", "downloading", "installing"].includes(platform.updater?.state().status ?? "")}
|
||||
>
|
||||
{platform.updater?.state().status === "checking"
|
||||
? language.t("error.page.action.checking")
|
||||
: language.t("error.page.action.checkUpdates")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button size="large" onClick={installUpdate}>
|
||||
{language.t("error.page.action.updateTo", { version: store.version ?? "" })}
|
||||
</Button>
|
||||
{(version) => (
|
||||
<Button size="large" onClick={installUpdate}>
|
||||
{language.t("error.page.action.updateTo", { version: version() })}
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
@@ -18,7 +19,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
@@ -125,6 +126,7 @@ function HomeDesign() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
@@ -314,26 +316,19 @@ function HomeDesign() {
|
||||
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
|
||||
}
|
||||
|
||||
async function chooseProject(conn: ServerConnection.Any) {
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
function resolve(result: string | string[] | null) {
|
||||
addProjects(conn, homeProjectDirectories(result))
|
||||
}
|
||||
|
||||
const server = global.createServerCtx(conn)
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
@@ -390,7 +385,7 @@ function HomeDesign() {
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
@@ -432,7 +427,7 @@ function HomeDesign() {
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1101,6 +1096,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
||||
function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const global = useGlobal()
|
||||
@@ -1128,7 +1124,7 @@ function LegacyHome() {
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
function chooseProject() {
|
||||
const s = server.current
|
||||
if (!s) return
|
||||
|
||||
@@ -1142,18 +1138,12 @@ function LegacyHome() {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={s} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
}
|
||||
pickDirectory({
|
||||
server: s,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { useLayout, LocalProject } from "@/context/layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -65,6 +64,7 @@ import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@@ -90,7 +90,6 @@ import {
|
||||
} from "./layout/sidebar-workspace"
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
import { runUpdateAndRestart } from "./layout/update"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -119,6 +118,7 @@ export default function Layout(props: ParentProps) {
|
||||
const layout = useLayout()
|
||||
const layoutReady = createMemo(() => layout.ready())
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const notification = useNotification()
|
||||
@@ -168,28 +168,15 @@ export default function Layout(props: ParentProps) {
|
||||
peeked: false,
|
||||
})
|
||||
|
||||
const [update, setUpdate] = createStore({
|
||||
installing: false,
|
||||
})
|
||||
const updateQuery = useQuery(() => ({
|
||||
queryKey: ["desktop", "update"] as const,
|
||||
enabled: () =>
|
||||
!!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
|
||||
queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
|
||||
refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
|
||||
}))
|
||||
const updateVersion = () => {
|
||||
if (!settings.ready()) return
|
||||
if (!settings.updates.startup()) return
|
||||
if (!updateQuery.data?.updateAvailable) return
|
||||
return updateQuery.data.version ?? ""
|
||||
}
|
||||
const installUpdate = () => {
|
||||
runUpdateAndRestart(platform.updateAndRestart, (installing) => setUpdate("installing", installing))
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return
|
||||
return state.version
|
||||
}
|
||||
const installUpdate = () => void platform.updater?.install()
|
||||
const titlebarUpdate: TitlebarUpdate = {
|
||||
version: updateVersion,
|
||||
installing: () => update.installing,
|
||||
installing: () => platform.updater?.state().status === "installing",
|
||||
install: installUpdate,
|
||||
}
|
||||
|
||||
@@ -1472,7 +1459,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
function chooseProject() {
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
function resolve(result: string | string[] | null) {
|
||||
@@ -1486,22 +1473,12 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { runUpdateAndRestart } from "./update"
|
||||
|
||||
describe("runUpdateAndRestart", () => {
|
||||
test("clears the installing state when restart resolves without exiting", async () => {
|
||||
const states: boolean[] = []
|
||||
await new Promise<void>((resolve) => {
|
||||
runUpdateAndRestart(
|
||||
async () => {},
|
||||
(installing) => {
|
||||
states.push(installing)
|
||||
if (states.length === 2) resolve()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
expect(states).toEqual([true, false])
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
export function runUpdateAndRestart(
|
||||
updateAndRestart: (() => Promise<void>) | undefined,
|
||||
setInstalling: (installing: boolean) => void,
|
||||
) {
|
||||
if (!updateAndRestart) return
|
||||
setInstalling(true)
|
||||
void updateAndRestart()
|
||||
.catch(() => undefined)
|
||||
.finally(() => setInstalling(false))
|
||||
}
|
||||
@@ -547,6 +547,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
disabled: desktopV2() && !settings.general.showCustomAgents(),
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
@@ -554,6 +555,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
disabled: desktopV2() && !settings.general.showCustomAgents(),
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
|
||||
export type UpdaterState =
|
||||
| { status: "disabled" }
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "downloading"; version: string; percent?: number }
|
||||
| { status: "ready"; version: string }
|
||||
| { status: "up-to-date" }
|
||||
| { status: "installing"; version: string }
|
||||
| { status: "error"; message: string }
|
||||
|
||||
export type UpdaterPlatform = {
|
||||
state: Accessor<UpdaterState>
|
||||
check(): Promise<UpdaterState>
|
||||
install(): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
@@ -20,8 +20,12 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
@@ -13,7 +17,9 @@ await rm("dist", { recursive: true, force: true })
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const sourcemapsFlag = process.argv.includes("--sourcemaps")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -43,6 +49,12 @@ const targets = singleFlag
|
||||
})
|
||||
: allTargets
|
||||
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
|
||||
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
||||
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
|
||||
|
||||
for (const item of targets) {
|
||||
const target = [
|
||||
binary,
|
||||
@@ -56,8 +68,9 @@ for (const item of targets) {
|
||||
const name = target.replace(binary, "cli")
|
||||
console.log(`building ${name}`)
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
entrypoints: ["./src/index.ts", parserWorker],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
@@ -79,6 +92,11 @@ for (const item of targets) {
|
||||
OPENCODE_MODELS_DEV: modelsData,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
||||
OTUI_TREE_SITTER_WORKER_PATH:
|
||||
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
|
||||
path.relative(dir, parserWorker).replaceAll("\\", "/") +
|
||||
'"',
|
||||
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Effect } from "effect"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
|
||||
export default Runtime.handler(Commands, () =>
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* daemon.transport()
|
||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||
yield* runTui(transport)
|
||||
}),
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { Context, Layer, Option } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
@@ -34,6 +35,7 @@ function bind(hostname: string, port: number, password: string) {
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
||||
Layer.provide(PermissionSaved.defaultLayer),
|
||||
),
|
||||
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
|
||||
}
|
||||
|
||||
@@ -60,19 +60,19 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, op
|
||||
}
|
||||
|
||||
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
|
||||
const spec: Command.Command.Any = Object.keys(node.commands).length
|
||||
? (node.spec as Command.Command<string, unknown>).pipe(
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
const handler = handlers.find((handler) => handler.spec === node.spec)
|
||||
const spec = handler
|
||||
? node.spec.pipe(
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
|
||||
}),
|
||||
),
|
||||
)
|
||||
: node.spec
|
||||
const handler = handlers.find((handler) => handler.spec === node.spec)
|
||||
if (!handler) return spec as ProvidedCommand
|
||||
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
|
||||
return spec.pipe(
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
|
||||
}),
|
||||
),
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
) as ProvidedCommand
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Runtime } from "./framework/runtime"
|
||||
import { Daemon } from "./services/daemon"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
},
|
||||
|
||||
@@ -5,10 +5,12 @@ import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { randomBytes, randomUUID } from "crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
import path from "path"
|
||||
|
||||
export interface Interface {
|
||||
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
|
||||
readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
|
||||
readonly start: () => Effect.Effect<string, Error>
|
||||
readonly status: () => Effect.Effect<string | undefined>
|
||||
readonly stop: () => Effect.Effect<void, unknown>
|
||||
@@ -108,16 +110,21 @@ export const layer = Layer.effect(
|
||||
const start = Effect.fn("cli.daemon.start")(function* () {
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
const found = Option.getOrUndefined(existing)
|
||||
if (found?.version === InstallationVersion) return found.url
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
if (found?.version === InstallationVersion && compiled) return found.url
|
||||
if (found) yield* stopProcess(found).pipe(Effect.ignore)
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}).unref()
|
||||
const entrypoint = compiled ? undefined : process.argv[1]
|
||||
if (!compiled && entrypoint === undefined)
|
||||
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref()
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
|
||||
return yield* compatible().pipe(
|
||||
@@ -127,8 +134,13 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const transport = Effect.fn("cli.daemon.transport")(function* () {
|
||||
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
|
||||
})
|
||||
|
||||
const client = Effect.fn("cli.daemon.client")(function* () {
|
||||
return yield* createClient(yield* start())
|
||||
const connection = yield* transport()
|
||||
return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
|
||||
})
|
||||
|
||||
const status = Effect.fn("cli.daemon.status")(function* () {
|
||||
@@ -173,7 +185,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ client, start, status, stop, password, register })
|
||||
return Service.of({ client, transport, start, status, stop, password, register })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { run } from "@opencode-ai/tui"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
return run({
|
||||
...transport,
|
||||
args: {},
|
||||
config,
|
||||
fetch: gracefulFetch,
|
||||
pluginHost: {
|
||||
async start() {},
|
||||
async dispose() {},
|
||||
},
|
||||
}).pipe(Effect.provide(Global.defaultLayer))
|
||||
}
|
||||
|
||||
const legacyDefaults: Record<string, unknown> = {
|
||||
"/config/providers": { providers: [], default: {} },
|
||||
"/provider": { all: [], default: {}, connected: [] },
|
||||
"/agent": [],
|
||||
"/config": {},
|
||||
}
|
||||
|
||||
const gracefulFetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await fetch(input, init)
|
||||
if (response.status !== 404) return response
|
||||
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
|
||||
if (fallback === undefined) return response
|
||||
return Response.json(fallback)
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
@@ -2,6 +2,8 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export type Editor = {
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly default: () => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
@@ -113,10 +113,7 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
update: Effect.fn("AgentV2.update")(function* (update) {
|
||||
const transform = yield* state.transform()
|
||||
yield* transform(update)
|
||||
}),
|
||||
update: state.update,
|
||||
get: Effect.fn("AgentV2.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
|
||||
@@ -203,7 +203,7 @@ export const layer = Layer.effect(
|
||||
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
|
||||
),
|
||||
Stream.runForEach((event) =>
|
||||
state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
|
||||
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -27,6 +27,7 @@ export type Editor = {
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
@@ -54,6 +55,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
update: state.update,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||
return state.get().commands.get(name)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionExecution } from "../session/execution"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
@@ -124,5 +125,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -35,6 +35,13 @@ export class BinaryFileError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class PathValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "PathValidationError"
|
||||
}
|
||||
}
|
||||
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
".zip",
|
||||
".tar",
|
||||
@@ -265,21 +272,23 @@ export const layer = Layer.effect(
|
||||
ToolOutputStore.MANAGED_DIRECTORY,
|
||||
)
|
||||
if (input && path.isAbsolute(input)) {
|
||||
if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference"))
|
||||
if (reference)
|
||||
return yield* Effect.die(new PathValidationError("Absolute paths cannot use a project reference"))
|
||||
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
|
||||
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
|
||||
return yield* Effect.die(new PathValidationError("Absolute path is not managed tool output"))
|
||||
const real = yield* fs.realPath(input).pipe(Effect.orDie)
|
||||
const managedRoot = yield* fs.realPath(managed).pipe(Effect.orDie)
|
||||
if (path.dirname(real) !== managedRoot || !path.basename(real).startsWith("tool_"))
|
||||
return yield* Effect.die(new Error("Path escapes managed tool output"))
|
||||
return yield* Effect.die(new PathValidationError("Path escapes managed tool output"))
|
||||
return { absolute: input, real, directory: managed, root: managedRoot }
|
||||
}
|
||||
const selected = yield* select(reference)
|
||||
const absolute = path.resolve(selected.directory, input ?? ".")
|
||||
if (!FSUtil.contains(selected.directory, absolute))
|
||||
return yield* Effect.die(new Error("Path escapes the location"))
|
||||
return yield* Effect.die(new PathValidationError("Path escapes the location"))
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
if (!FSUtil.contains(selected.root, real))
|
||||
return yield* Effect.die(new PathValidationError("Path escapes the location"))
|
||||
return { absolute, real, ...selected }
|
||||
})
|
||||
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
|
||||
|
||||
@@ -70,7 +70,7 @@ export const layer = Layer.effectDiscard(
|
||||
return files.filter((file): file is File => file !== undefined)
|
||||
})
|
||||
|
||||
yield* registry.contribute({
|
||||
yield* registry.register({
|
||||
key,
|
||||
load: observe().pipe(
|
||||
Effect.map((files) =>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { logFailure } from "./session/logging"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
@@ -177,10 +178,7 @@ export const layer = Layer.effect(
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logError("Failed to wake Session").pipe(
|
||||
Effect.annotateLogs("sessionID", admitted.sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
: logFailure("Failed to wake Session", admitted.sessionID, cause),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
@@ -427,20 +425,12 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const DefaultDatabase = Database.defaultLayer
|
||||
const DefaultEvents = EventV2.layer.pipe(Layer.provide(DefaultDatabase))
|
||||
const DefaultProjector = SessionProjector.layer.pipe(Layer.provide(DefaultEvents), Layer.provide(DefaultDatabase))
|
||||
const DefaultStore = SessionStore.layer.pipe(Layer.provide(DefaultDatabase))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
DefaultDatabase,
|
||||
DefaultEvents,
|
||||
DefaultProjector,
|
||||
DefaultStore,
|
||||
SessionExecution.noopLayer,
|
||||
ProjectV2.defaultLayer,
|
||||
),
|
||||
),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SessionRunner } from "../runner"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { logFailure } from "../logging"
|
||||
|
||||
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
||||
export const layer = Layer.effect(
|
||||
@@ -20,11 +21,7 @@ export const layer = Layer.effect(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
onFailure: (sessionID, cause) =>
|
||||
Effect.logError("Failed to drain Session").pipe(
|
||||
Effect.annotateLogs("sessionID", sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
|
||||
})
|
||||
|
||||
return SessionExecution.Service.of({
|
||||
@@ -34,3 +31,5 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Cause, Effect } from "effect"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export const logFailure = (
|
||||
message: "Failed to drain Session" | "Failed to wake Session",
|
||||
sessionID: SessionSchema.ID,
|
||||
cause: Cause.Cause<unknown>,
|
||||
) => Effect.logError(message, cause).pipe(Effect.annotateLogs("sessionID", sessionID))
|
||||
@@ -2,6 +2,7 @@ export * as SessionRunCoordinator from "./run-coordinator"
|
||||
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
|
||||
import { SessionRunner } from "./runner"
|
||||
import { logFailure } from "./logging"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export type Mode = "run" | "wake"
|
||||
@@ -275,11 +276,7 @@ export const layer = Layer.effect(
|
||||
Effect.flatMap((runner) =>
|
||||
make<SessionSchema.ID, void, SessionRunner.RunError>({
|
||||
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
|
||||
onFailure: (sessionID, cause) =>
|
||||
Effect.logError("Failed to drain Session").pipe(
|
||||
Effect.annotateLogs("sessionID", sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
|
||||
}),
|
||||
),
|
||||
Effect.map(Service.of),
|
||||
|
||||
@@ -58,3 +58,5 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Effect, Scope, Semaphore } from "effect"
|
||||
import type { Draft, Objectish } from "immer"
|
||||
|
||||
/**
|
||||
* A replayable contribution applied to an editor during rebuild.
|
||||
* A replayable transform applied to an editor during rebuild.
|
||||
*
|
||||
* Transforms are intentionally synchronous and mutation-shaped: domain editors
|
||||
* hide the draft representation while preserving concise plugin/config code.
|
||||
@@ -39,15 +39,17 @@ export interface Interface<State extends Objectish, Editor> {
|
||||
* registration order. Closing the owning Scope removes the slot and rebuilds.
|
||||
*/
|
||||
readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope>
|
||||
/** Registers and applies a replayable transform in the current Scope. */
|
||||
readonly update: (update: Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
|
||||
/**
|
||||
* Mutates the current materialized state directly.
|
||||
* Mutates the current materialized state directly, once.
|
||||
*
|
||||
* This is not replayable contribution state: a later rebuild starts again
|
||||
* This is not replayable transform state: a later rebuild starts again
|
||||
* from `initial()` plus active transforms, so direct edits must be reserved
|
||||
* for current-state adjustments that are intentionally outside the transform
|
||||
* fold.
|
||||
*/
|
||||
readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
|
||||
readonly mutate: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
|
||||
@@ -69,7 +71,7 @@ export function create<State extends Objectish, Editor>(options: Options<State,
|
||||
yield* commit(next)
|
||||
})
|
||||
|
||||
return {
|
||||
const result: Interface<State, Editor> = {
|
||||
get: () => state,
|
||||
transform: Effect.fn("State.transform")(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
@@ -96,10 +98,15 @@ export function create<State extends Objectish, Editor>(options: Options<State,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
update: Effect.fn("State.update")(function* (update, reason) {
|
||||
update: Effect.fn("State.update")(function* (update) {
|
||||
const transform = yield* result.transform()
|
||||
yield* transform(update)
|
||||
}),
|
||||
mutate: Effect.fn("State.mutate")(function* (update, reason) {
|
||||
const api = options.editor(state as Draft<State>)
|
||||
yield* update(api)
|
||||
if (options.finalize) yield* options.finalize(api, reason)
|
||||
}, semaphore.withPermit),
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ const builtIns = Layer.effectDiscard(
|
||||
}),
|
||||
])
|
||||
|
||||
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||
yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ export * as SystemContextRegistry from "./registry"
|
||||
import { Context, Effect, Layer, Ref, Scope } from "effect"
|
||||
import { SystemContext } from "./index"
|
||||
|
||||
export interface Contribution {
|
||||
export interface Entry {
|
||||
readonly key: SystemContext.Key
|
||||
readonly load: Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly contribute: (contribution: Contribution) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
@@ -18,27 +18,27 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([])
|
||||
const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
|
||||
|
||||
return Service.of({
|
||||
contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) {
|
||||
register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
|
||||
yield* Effect.acquireRelease(
|
||||
Ref.modify(contributions, (current) => {
|
||||
if (current.some((item) => item.key === contribution.key)) return [false, current]
|
||||
return [true, [...current, contribution]]
|
||||
Ref.modify(entries, (current) => {
|
||||
if (current.some((item) => item.key === entry.key)) return [false, current]
|
||||
return [true, [...current, entry]]
|
||||
}).pipe(
|
||||
Effect.flatMap((added) =>
|
||||
added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`),
|
||||
added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
|
||||
),
|
||||
Effect.as(contribution),
|
||||
Effect.as(entry),
|
||||
),
|
||||
(entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)),
|
||||
(entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),
|
||||
)
|
||||
}),
|
||||
load: Effect.fn("SystemContextRegistry.load")(function* () {
|
||||
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||
const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||
return SystemContext.combine(
|
||||
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
|
||||
yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { WebSearchTool } from "./websearch"
|
||||
import { WriteTool } from "./write"
|
||||
|
||||
/**
|
||||
* Composes only the shipped Location-scoped built-in tool contributions.
|
||||
* Composes only the shipped Location-scoped built-in tool transforms.
|
||||
* Each tool retains its implementation and focused tests independently. Dynamic
|
||||
* MCP and plugin tools later use separate scoped canonical registrations, while
|
||||
* provider/model filtering belongs to a future materialization phase rather
|
||||
@@ -25,7 +25,7 @@ import { WriteTool } from "./write"
|
||||
* TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
|
||||
* parity, task, LSP,
|
||||
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
|
||||
* contributions separate from this static built-in list.
|
||||
* transforms separate from this static built-in list.
|
||||
*/
|
||||
export const locationLayer = Layer.mergeAll(
|
||||
ApplyPatchTool.layer,
|
||||
|
||||
@@ -68,14 +68,21 @@ export const layer = Layer.effectDiscard(
|
||||
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
|
||||
return content
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof FileSystem.PathValidationError
|
||||
? Effect.fail(new ToolFailure({ message: defect.message }))
|
||||
: Effect.die(defect),
|
||||
),
|
||||
Effect.mapError((error) => {
|
||||
const message =
|
||||
error instanceof FileSystem.BinaryFileError ||
|
||||
error instanceof FileSystem.MediaIngestLimitError ||
|
||||
error instanceof Image.DecodeError ||
|
||||
error instanceof Image.SizeError
|
||||
error instanceof ToolFailure
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
: error instanceof FileSystem.BinaryFileError ||
|
||||
error instanceof FileSystem.MediaIngestLimitError ||
|
||||
error instanceof Image.DecodeError ||
|
||||
error instanceof Image.SizeError
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("AgentV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes a transform contribution when its scope closes", () =>
|
||||
it.effect("removes a transform when its scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("scoped")
|
||||
|
||||
@@ -67,6 +67,11 @@ describe("FileSystem", () => {
|
||||
|
||||
expect(yield* service.read({ path: output })).toMatchObject({ type: "text", content: "failure here" })
|
||||
expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
|
||||
expect(
|
||||
yield* service
|
||||
.resolveReadPath({ path: unrelated })
|
||||
.pipe(Effect.catchDefect((defect) => Effect.succeed(defect))),
|
||||
).toBeInstanceOf(FileSystem.PathValidationError)
|
||||
expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
|
||||
expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
|
||||
}).pipe(provide(worktree, inertReferences, FSUtil.defaultLayer, data))
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serializes same-ID additions and leaves one removable contribution", () =>
|
||||
it.effect("serializes same-ID additions and leaves one removable attachment", () =>
|
||||
Effect.gen(function* () {
|
||||
const values = state()
|
||||
const layerScope = yield* Scope.fork(yield* Scope.Scope)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Logger } from "effect"
|
||||
import { logFailure } from "@opencode-ai/core/session/logging"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
|
||||
describe("Session logging", () => {
|
||||
for (const message of ["Failed to drain Session", "Failed to wake Session"] as const) {
|
||||
test(`renders the cause for ${message}`, async () => {
|
||||
const entries: Array<ReturnType<typeof Logger.formatStructured.log>> = []
|
||||
const logger = Logger.formatStructured.pipe(
|
||||
Logger.map((entry): void => {
|
||||
entries.push(entry)
|
||||
}),
|
||||
)
|
||||
|
||||
await logFailure(
|
||||
message,
|
||||
SessionSchema.ID.make("session-123"),
|
||||
Cause.fail({ _tag: "SessionFailure", detail: { code: "nested-code" } }),
|
||||
).pipe(Effect.provide(Logger.layer([logger])), Effect.runPromise)
|
||||
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]?.message).toBe(message)
|
||||
expect(entries[0]?.annotations).toEqual({ sessionID: "session-123" })
|
||||
expect(entries[0]?.cause).toContain("SessionFailure")
|
||||
expect(entries[0]?.cause).toContain("nested-code")
|
||||
expect(entries[0]?.cause).not.toContain("[Object")
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -173,7 +173,7 @@ const skillBaselines = new Map<AgentV2.ID, string>()
|
||||
const systemContext = Layer.effectDiscard(
|
||||
SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((registry) =>
|
||||
registry.contribute({
|
||||
registry.register({
|
||||
key: systemContextKey,
|
||||
load: Effect.sync(() =>
|
||||
SystemContext.combine(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const contribution = (key: string, text: string, sourceKey = key) => ({
|
||||
const entry = (key: string, text: string, sourceKey = key) => ({
|
||||
key: SystemContext.Key.make(key),
|
||||
load: Effect.succeed(
|
||||
SystemContext.make({
|
||||
@@ -20,7 +20,7 @@ const contribution = (key: string, text: string, sourceKey = key) => ({
|
||||
const it = testEffect(SystemContextRegistry.layer)
|
||||
|
||||
describe("SystemContextRegistry", () => {
|
||||
it.effect("loads empty system context when there are no contributions", () =>
|
||||
it.effect("loads empty system context when there are no entries", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
|
||||
@@ -28,21 +28,21 @@ describe("SystemContextRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads scoped contributions in stable key order", () =>
|
||||
it.effect("loads scoped entries in stable key order", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.contribute(contribution("test/second", "second"))
|
||||
yield* registry.contribute(contribution("test/first", "first"))
|
||||
yield* registry.register(entry("test/second", "second"))
|
||||
yield* registry.register(entry("test/first", "first"))
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("re-evaluates contribution producers on each load", () =>
|
||||
it.effect("re-evaluates entry producers on each load", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
let loads = 0
|
||||
yield* registry.contribute({
|
||||
yield* registry.register({
|
||||
key: SystemContext.Key.make("test/dynamic"),
|
||||
load: Effect.sync(() => {
|
||||
loads++
|
||||
@@ -57,11 +57,11 @@ describe("SystemContextRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates contribution producer failures", () =>
|
||||
it.effect("propagates entry producer failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const failure = new Error("contribution failed")
|
||||
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
|
||||
const failure = new Error("entry failed")
|
||||
yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
|
||||
|
||||
const exit = yield* registry.load().pipe(Effect.exit)
|
||||
|
||||
@@ -70,11 +70,11 @@ describe("SystemContextRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate source keys from separate contributions", () =>
|
||||
it.effect("rejects duplicate source keys from separate entries", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.contribute(contribution("test/first", "first", "test/duplicate"))
|
||||
yield* registry.contribute(contribution("test/second", "second", "test/duplicate"))
|
||||
yield* registry.register(entry("test/first", "first", "test/duplicate"))
|
||||
yield* registry.register(entry("test/second", "second", "test/duplicate"))
|
||||
|
||||
const exit = yield* registry.load().pipe(Effect.exit)
|
||||
|
||||
@@ -86,23 +86,23 @@ describe("SystemContextRegistry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate contribution keys", () =>
|
||||
it.effect("rejects duplicate entry keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.contribute(contribution("test/duplicate", "first"))
|
||||
yield* registry.register(entry("test/duplicate", "first"))
|
||||
|
||||
const exit = yield* registry.contribute(contribution("test/duplicate", "second", "test/other")).pipe(Effect.exit)
|
||||
const exit = yield* registry.register(entry("test/duplicate", "second", "test/other")).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context contribution key")
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context entry key")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes a contribution when its owning scope closes", () =>
|
||||
it.effect("removes an entry when its owning scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* registry.contribute(contribution("test/scoped", "scoped")).pipe(Scope.provide(scope))
|
||||
yield* registry.register(entry("test/scoped", "scoped")).pipe(Scope.provide(scope))
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")
|
||||
|
||||
|
||||
@@ -490,6 +490,23 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns path validation failures as model-visible tool output", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
||||
resolveFailure = new FileSystem.PathValidationError("Absolute path is not managed tool output")
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-unmanaged", name: "read", input: { path: "/tmp/private.txt" } },
|
||||
}),
|
||||
).toEqual({ type: "error", value: "Absolute path is not managed tool output" })
|
||||
|
||||
expect(readCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards pagination and returns bounded text pages with continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
readResult = new FileSystem.TextPage({
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
assertAttachmentBudget,
|
||||
createPickedFileAuthorizations,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
readAttachment,
|
||||
} from "./attachment-picker"
|
||||
|
||||
describe("assertAttachmentBudget", () => {
|
||||
test("accepts selections within the media ingest limit", () => {
|
||||
expect(() =>
|
||||
assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES / 2 }, { size: MAX_ATTACHMENT_BYTES / 2 }]),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test("rejects the selection before files are read when its total exceeds the limit", () => {
|
||||
expect(() => assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES }, { size: 1 }])).toThrow("20 MB limit")
|
||||
})
|
||||
|
||||
test("reads an approved file through a bounded buffer", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
|
||||
const file = join(directory, "example.txt")
|
||||
try {
|
||||
await writeFile(file, "lorem ipsum")
|
||||
expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects an oversized file before allocating its contents", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
|
||||
const file = join(directory, "oversized.txt")
|
||||
try {
|
||||
await writeFile(file, "")
|
||||
await truncate(file, MAX_ATTACHMENT_BYTES + 1)
|
||||
await expect(readAttachment(file)).rejects.toThrow("20 MB limit")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("picked file authorizations", () => {
|
||||
const read = async (path: string) => new TextEncoder().encode(path).buffer
|
||||
|
||||
test("keeps concurrent picker selections isolated", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(read)
|
||||
const first = authorizations.add(1, ["a.txt", "b.txt"])
|
||||
const second = authorizations.add(1, ["c.txt"])
|
||||
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt")
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt")
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt")
|
||||
})
|
||||
|
||||
test("releases unread files for one picker without affecting another", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(read)
|
||||
const first = authorizations.add(1, ["a.txt"])
|
||||
const second = authorizations.add(1, ["b.txt"])
|
||||
authorizations.release(1, first)
|
||||
|
||||
await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected")
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt")
|
||||
})
|
||||
|
||||
test("keeps picker tokens scoped to their renderer", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(read)
|
||||
const token = authorizations.add(1, ["a.txt"])
|
||||
|
||||
await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected")
|
||||
})
|
||||
|
||||
test("charges actual reads against the selection budget", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => {
|
||||
if (6 > maxBytes) throw new Error("budget exceeded")
|
||||
return new ArrayBuffer(6)
|
||||
}, 10)
|
||||
const token = authorizations.add(1, ["a.txt", "b.txt"])
|
||||
|
||||
await authorizations.read(1, token, "a.txt")
|
||||
await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { open } from "node:fs/promises"
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
export function createPickedFileAuthorizations(
|
||||
read: (path: string, maxBytes: number) => Promise<ArrayBuffer> = readAttachment,
|
||||
budget = MAX_ATTACHMENT_BYTES,
|
||||
) {
|
||||
const selections = new Map<string, { sender: number; paths: Set<string>; remaining: number }>()
|
||||
|
||||
return {
|
||||
add(sender: number, paths: string[]) {
|
||||
const token = randomUUID()
|
||||
selections.set(token, { sender, paths: new Set(paths), remaining: budget })
|
||||
return token
|
||||
},
|
||||
async read(sender: number, token: string, path: string) {
|
||||
const selection = selections.get(token)
|
||||
if (selection?.sender !== sender || !selection.paths.delete(path))
|
||||
throw new Error("File was not selected by the picker")
|
||||
const bytes = await read(path, selection.remaining)
|
||||
selection.remaining -= bytes.byteLength
|
||||
if (selection.paths.size === 0) selections.delete(token)
|
||||
return bytes
|
||||
},
|
||||
release(sender: number, token: string) {
|
||||
if (selections.get(token)?.sender === sender) selections.delete(token)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAttachmentBudget(files: { size: number }[]) {
|
||||
const total = files.reduce((sum, file) => sum + file.size, 0)
|
||||
if (total <= MAX_ATTACHMENT_BYTES) return
|
||||
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
|
||||
}
|
||||
|
||||
export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
|
||||
const file = await open(filePath, "r")
|
||||
try {
|
||||
const info = await file.stat()
|
||||
if (info.size > maxBytes)
|
||||
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
|
||||
const bytes = Buffer.allocUnsafe(info.size)
|
||||
let offset = 0
|
||||
while (offset < info.size) {
|
||||
const result = await file.read(bytes, offset, info.size - offset, offset)
|
||||
if (result.bytesRead === 0) break
|
||||
offset += result.bytesRead
|
||||
}
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import contextMenu from "electron-context-menu"
|
||||
|
||||
import type { ServerReadyData } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath } from "./apps"
|
||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||
import { CHANNEL } from "./constants"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
|
||||
import { forwardInitializationFailure } from "./initialization"
|
||||
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
spawnLocalServer,
|
||||
type SidecarListener,
|
||||
} from "./server"
|
||||
import { checkUpdate, checkForUpdates, installUpdate, setupAutoUpdater } from "./updater"
|
||||
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||
import {
|
||||
createMainWindow,
|
||||
registerRendererProtocol,
|
||||
@@ -232,6 +232,13 @@ const main = Effect.gen(function* () {
|
||||
|
||||
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
|
||||
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
|
||||
if (!TEST_ONBOARDING) migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
const updater = setupAutoUpdater(stopSidecars)
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
relaunch,
|
||||
@@ -244,7 +251,6 @@ const main = Effect.gen(function* () {
|
||||
},
|
||||
(e) => Effect.runPromise(e),
|
||||
),
|
||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
@@ -253,22 +259,17 @@ const main = Effect.gen(function* () {
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, stopSidecars),
|
||||
checkUpdate: async () => checkUpdate(),
|
||||
installUpdate: async () => installUpdate(stopSidecars),
|
||||
updater,
|
||||
showUpdater: () => showUpdaterDialog(updater, true),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
exportDebugLogs: () => exportDebugLogs(),
|
||||
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
||||
})
|
||||
registerWslIpcHandlers(wslServers)
|
||||
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
|
||||
if (!TEST_ONBOARDING) migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
setupAutoUpdater()
|
||||
void updater.start()
|
||||
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
||||
updateTimer.unref()
|
||||
app.once("will-quit", () => clearInterval(updateTimer))
|
||||
yield* Effect.promise(() => startNetLog()).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
@@ -350,7 +351,7 @@ const main = Effect.gen(function* () {
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true, stopSidecars)
|
||||
void showUpdaterDialog(updater, true)
|
||||
},
|
||||
relaunch: () => {
|
||||
relaunch()
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { basename } from "node:path"
|
||||
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
|
||||
import type { FatalRendererError, ServerReadyData, TitlebarTheme, WindowConfig } from "../preload/types"
|
||||
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||
import { getStore } from "./store"
|
||||
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||
import type { UpdaterController } from "./updater-controller"
|
||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||
|
||||
const pickerFilters = (ext?: string[]) => {
|
||||
if (!ext || ext.length === 0) return undefined
|
||||
return [{ name: "Files", extensions: ext }]
|
||||
}
|
||||
|
||||
const pickedFiles = createPickedFileAuthorizations()
|
||||
|
||||
type Deps = {
|
||||
killSidecar: () => Promise<void> | void
|
||||
relaunch: () => void
|
||||
awaitInitialization: () => Promise<ServerReadyData>
|
||||
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||
@@ -26,18 +32,19 @@ type Deps = {
|
||||
parseMarkdown: (markdown: string) => Promise<string> | string
|
||||
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
||||
resolveAppPath: (appName: string) => Promise<string | null>
|
||||
runUpdater: (alertOnFail: boolean) => Promise<void> | void
|
||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||
installUpdate: () => Promise<void> | void
|
||||
updater: UpdaterController
|
||||
showUpdater: () => Promise<void> | void
|
||||
setBackgroundColor: (color: string) => void
|
||||
exportDebugLogs: () => Promise<string>
|
||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
const updaterSubscriptions = createUpdaterSubscriptions()
|
||||
app.once("will-quit", updaterSubscriptions.clear)
|
||||
|
||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
|
||||
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
|
||||
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
||||
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
||||
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
|
||||
@@ -50,9 +57,20 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
|
||||
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
|
||||
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
||||
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
|
||||
ipcMain.handle("check-update", () => deps.checkUpdate())
|
||||
ipcMain.handle("install-update", () => deps.installUpdate())
|
||||
ipcMain.handle("updater-subscribe", (event) => {
|
||||
const id = event.sender.id
|
||||
updaterSubscriptions.set(
|
||||
id,
|
||||
deps.updater.subscribe((state) => {
|
||||
if (event.sender.isDestroyed()) return updaterSubscriptions.delete(id)
|
||||
event.sender.send("updater-state", state)
|
||||
}),
|
||||
)
|
||||
event.sender.once("destroyed", () => updaterSubscriptions.delete(id))
|
||||
})
|
||||
ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id))
|
||||
ipcMain.handle("updater-check", () => deps.updater.check())
|
||||
ipcMain.handle("updater-install", () => deps.updater.install())
|
||||
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
||||
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
|
||||
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
|
||||
@@ -102,8 +120,8 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
ipcMain.handle(
|
||||
"open-file-picker",
|
||||
async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
|
||||
event: IpcMainInvokeEvent,
|
||||
opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
|
||||
) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||
@@ -112,10 +130,27 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
filters: pickerFilters(opts?.extensions),
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
||||
const files = await Promise.all(
|
||||
result.filePaths.map(async (filePath) => ({
|
||||
path: filePath,
|
||||
name: basename(filePath),
|
||||
size: (await stat(filePath)).size,
|
||||
})),
|
||||
)
|
||||
assertAttachmentBudget(files)
|
||||
const token = pickedFiles.add(event.sender.id, result.filePaths)
|
||||
return { token, files }
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => {
|
||||
return pickedFiles.read(event.sender.id, token, filePath)
|
||||
})
|
||||
|
||||
ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => {
|
||||
pickedFiles.release(event.sender.id, token)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"save-file-picker",
|
||||
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
|
||||
@@ -191,7 +226,10 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
setTitlebar(win, theme)
|
||||
})
|
||||
ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
|
||||
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action)
|
||||
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
|
||||
checkForUpdates: () => void deps.showUpdater(),
|
||||
relaunch: deps.relaunch,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller"
|
||||
|
||||
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
|
||||
const calls: string[] = []
|
||||
const backend: UpdaterBackend = {
|
||||
async checkForUpdates() {
|
||||
calls.push("check")
|
||||
return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }
|
||||
},
|
||||
async downloadUpdate() {
|
||||
calls.push("download")
|
||||
},
|
||||
quitAndInstall() {
|
||||
calls.push("install")
|
||||
},
|
||||
}
|
||||
let ready = input?.ready
|
||||
const controller = createUpdaterController({
|
||||
enabled: true,
|
||||
currentVersion: input?.currentVersion ?? "1.0.0",
|
||||
backend,
|
||||
persistence: {
|
||||
get: () => ready,
|
||||
set: (value) => {
|
||||
ready = value
|
||||
},
|
||||
clear: () => {
|
||||
ready = undefined
|
||||
},
|
||||
},
|
||||
stop: async () => {
|
||||
calls.push("stop")
|
||||
},
|
||||
})
|
||||
return { controller, calls, getReady: () => ready }
|
||||
}
|
||||
|
||||
describe("updater controller", () => {
|
||||
test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
|
||||
const app = setup()
|
||||
const states: ReturnType<typeof app.controller.getState>[] = []
|
||||
app.controller.subscribe((state) => states.push(state))
|
||||
|
||||
await app.controller.start()
|
||||
|
||||
expect(app.calls).toEqual(["check", "download"])
|
||||
expect(app.getReady()).toEqual({ version: "2.0.0" })
|
||||
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("revalidates a persisted target through the updater cache on launch", async () => {
|
||||
const app = setup({ ready: { version: "2.0.0" } })
|
||||
|
||||
await app.controller.start()
|
||||
|
||||
expect(app.calls).toEqual(["check", "download"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("clears a target already installed before checking", async () => {
|
||||
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
|
||||
|
||||
await app.controller.start()
|
||||
|
||||
expect(app.getReady()).toBeUndefined()
|
||||
expect(app.calls).toEqual(["check"])
|
||||
})
|
||||
|
||||
test("coalesces concurrent checks", async () => {
|
||||
const app = setup()
|
||||
|
||||
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
|
||||
|
||||
expect(app.calls).toEqual(["check", "download"])
|
||||
})
|
||||
|
||||
test("returns to ready when quitAndInstall returns without exiting", async () => {
|
||||
const app = setup()
|
||||
await app.controller.start()
|
||||
|
||||
await app.controller.install()
|
||||
|
||||
expect(app.calls).toEqual(["check", "download", "stop", "install"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("returns to ready when installation cannot start", async () => {
|
||||
const app = setup()
|
||||
await app.controller.start()
|
||||
|
||||
const failed = createUpdaterController({
|
||||
enabled: true,
|
||||
currentVersion: "1.0.0",
|
||||
backend: {
|
||||
checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }),
|
||||
downloadUpdate: async () => {},
|
||||
quitAndInstall() {},
|
||||
},
|
||||
persistence: { get: () => undefined, set() {}, clear() {} },
|
||||
stop: async () => {
|
||||
throw new Error("stop failed")
|
||||
},
|
||||
})
|
||||
await failed.start()
|
||||
|
||||
await expect(failed.install()).rejects.toThrow("stop failed")
|
||||
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
|
||||
export type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
|
||||
export type UpdaterReadyRecord = { version: string }
|
||||
|
||||
export type UpdaterBackend = {
|
||||
checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined>
|
||||
downloadUpdate(): Promise<unknown>
|
||||
quitAndInstall(): void
|
||||
}
|
||||
|
||||
type UpdaterPersistence = {
|
||||
get(): UpdaterReadyRecord | undefined | Promise<UpdaterReadyRecord | undefined>
|
||||
set(value: UpdaterReadyRecord): void | Promise<void>
|
||||
clear(): void | Promise<void>
|
||||
}
|
||||
|
||||
export function createUpdaterController(input: {
|
||||
enabled: boolean
|
||||
currentVersion: string
|
||||
backend: UpdaterBackend
|
||||
persistence: UpdaterPersistence
|
||||
stop: () => Promise<void>
|
||||
log?: (message: string, data?: object) => void
|
||||
}) {
|
||||
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
|
||||
let pending: Promise<UpdaterState> | undefined
|
||||
const listeners = new Set<(state: UpdaterState) => void>()
|
||||
|
||||
const transition = (next: UpdaterState) => {
|
||||
input.log?.("updater state changed", { from: state.status, to: next.status })
|
||||
state = next
|
||||
listeners.forEach((listener) => listener(state))
|
||||
return state
|
||||
}
|
||||
|
||||
const check = () => {
|
||||
if (!input.enabled) return Promise.resolve(state)
|
||||
if (state.status === "ready") return Promise.resolve(state)
|
||||
if (pending) return pending
|
||||
|
||||
pending = (async () => {
|
||||
transition({ status: "checking" })
|
||||
const result = await input.backend.checkForUpdates()
|
||||
const version = result?.updateInfo?.version
|
||||
if (!result?.isUpdateAvailable || !version || version === input.currentVersion) {
|
||||
await input.persistence.clear()
|
||||
return transition({ status: "up-to-date" })
|
||||
}
|
||||
|
||||
transition({ status: "downloading", version })
|
||||
await input.backend.downloadUpdate()
|
||||
await input.persistence.set({ version })
|
||||
return transition({ status: "ready", version })
|
||||
})()
|
||||
.catch((error) =>
|
||||
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
|
||||
)
|
||||
.finally(() => {
|
||||
pending = undefined
|
||||
})
|
||||
return pending
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe(listener: (state: UpdaterState) => void) {
|
||||
listeners.add(listener)
|
||||
listener(state)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
async start() {
|
||||
const ready = await input.persistence.get()
|
||||
if (ready?.version === input.currentVersion) await input.persistence.clear()
|
||||
return check()
|
||||
},
|
||||
check,
|
||||
async install() {
|
||||
if (state.status !== "ready") throw new Error("Update is not ready to install")
|
||||
const version = state.version
|
||||
transition({ status: "installing", version })
|
||||
await input
|
||||
.stop()
|
||||
.then(() => {
|
||||
input.backend.quitAndInstall()
|
||||
transition({ status: "ready", version })
|
||||
})
|
||||
.catch((error) => {
|
||||
transition({ status: "ready", version })
|
||||
throw error
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type UpdaterController = ReturnType<typeof createUpdaterController>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||
|
||||
describe("updater subscriptions", () => {
|
||||
test("replaces the previous renderer subscription on reload", () => {
|
||||
const subscriptions = createUpdaterSubscriptions()
|
||||
const disposed: string[] = []
|
||||
|
||||
subscriptions.set(1, () => disposed.push("first"))
|
||||
subscriptions.set(1, () => disposed.push("second"))
|
||||
|
||||
expect(disposed).toEqual(["first"])
|
||||
subscriptions.delete(1)
|
||||
expect(disposed).toEqual(["first", "second"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
export function createUpdaterSubscriptions() {
|
||||
const subscriptions = new Map<number, () => void>()
|
||||
|
||||
const remove = (id: number) => {
|
||||
subscriptions.get(id)?.()
|
||||
subscriptions.delete(id)
|
||||
}
|
||||
|
||||
return {
|
||||
set(id: number, unsubscribe: () => void) {
|
||||
remove(id)
|
||||
subscriptions.set(id, unsubscribe)
|
||||
},
|
||||
delete: remove,
|
||||
clear() {
|
||||
subscriptions.forEach((unsubscribe) => unsubscribe())
|
||||
subscriptions.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import { app, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||
import { getLogger } from "./logging"
|
||||
import { getStore } from "./store"
|
||||
|
||||
const { autoUpdater } = pkg
|
||||
type UpdateCheckResult = { updateAvailable: boolean; version?: string; failed?: boolean }
|
||||
let downloadedVersion: string | undefined
|
||||
let pendingCheck: Promise<UpdateCheckResult> | undefined
|
||||
const key = "ready"
|
||||
|
||||
export function setupAutoUpdater() {
|
||||
if (!UPDATER_ENABLED) return
|
||||
export function setupAutoUpdater(stop: () => Promise<void>) {
|
||||
const logger = getLogger()
|
||||
autoUpdater.logger = logger
|
||||
autoUpdater.channel = "latest"
|
||||
@@ -23,110 +22,47 @@ export function setupAutoUpdater() {
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkUpdate(): Promise<UpdateCheckResult> {
|
||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||
if (downloadedVersion) return { updateAvailable: true, version: downloadedVersion }
|
||||
if (pendingCheck) return pendingCheck
|
||||
|
||||
pendingCheck = checkAndDownloadUpdate().finally(() => {
|
||||
pendingCheck = undefined
|
||||
})
|
||||
return pendingCheck
|
||||
}
|
||||
|
||||
async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
|
||||
const logger = getLogger()
|
||||
logger.log("checking for updates", {
|
||||
const store = getStore("opencode.updater")
|
||||
return createUpdaterController({
|
||||
enabled: UPDATER_ENABLED,
|
||||
currentVersion: app.getVersion(),
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
backend: autoUpdater,
|
||||
persistence: {
|
||||
get() {
|
||||
const value = store.get(key)
|
||||
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return
|
||||
return { version: value.version } satisfies UpdaterReadyRecord
|
||||
},
|
||||
set: (value) => store.set(key, value),
|
||||
clear: () => store.delete(key),
|
||||
},
|
||||
stop,
|
||||
log: (message, data) => logger.log(message, data),
|
||||
})
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
const updateInfo = result?.updateInfo
|
||||
logger.log("update metadata fetched", {
|
||||
releaseVersion: updateInfo?.version ?? null,
|
||||
releaseDate: updateInfo?.releaseDate ?? null,
|
||||
releaseName: updateInfo?.releaseName ?? null,
|
||||
files: updateInfo?.files?.map((file) => file.url) ?? [],
|
||||
})
|
||||
const version = result?.updateInfo?.version
|
||||
if (result?.isUpdateAvailable === false || !version) {
|
||||
logger.log("no update available", {
|
||||
reason: "provider returned no newer version",
|
||||
})
|
||||
return { updateAvailable: false }
|
||||
}
|
||||
logger.log("update available", { version })
|
||||
await autoUpdater.downloadUpdate()
|
||||
downloadedVersion = version
|
||||
logger.log("update download completed", { version })
|
||||
return { updateAvailable: true, version }
|
||||
} catch (error) {
|
||||
logger.error("update check failed", error)
|
||||
return { updateAvailable: false, failed: true }
|
||||
}
|
||||
}
|
||||
|
||||
export async function installUpdate(killSidecar: () => Promise<void>) {
|
||||
const result = downloadedVersion ? { updateAvailable: true, version: downloadedVersion } : await checkUpdate()
|
||||
const logger = getLogger()
|
||||
if (!result.updateAvailable || !downloadedVersion) {
|
||||
logger.log("install update skipped", {
|
||||
reason: result.failed ? "update check failed" : "no update available",
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.log("installing downloaded update", {
|
||||
version: result.version ?? null,
|
||||
})
|
||||
await killSidecar()
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
|
||||
export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise<void>) {
|
||||
if (!UPDATER_ENABLED) return
|
||||
const logger = getLogger()
|
||||
logger.log("checkForUpdates invoked", { alertOnFail })
|
||||
const result = await checkUpdate()
|
||||
if (!result.updateAvailable) {
|
||||
if (result.failed) {
|
||||
logger.log("no update decision", { reason: "update check failed" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
message: "Update check failed.",
|
||||
title: "Update Error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("no update decision", { reason: "already up to date" })
|
||||
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
|
||||
const state = await controller.check()
|
||||
if (state.status === "error") {
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: "You're up to date.",
|
||||
title: "No Updates",
|
||||
})
|
||||
await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
|
||||
return
|
||||
}
|
||||
if (state.status === "up-to-date") {
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({ type: "info", message: "You're up to date.", title: "No Updates" })
|
||||
return
|
||||
}
|
||||
if (state.status !== "ready") return
|
||||
|
||||
const response = await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: `Update ${result.version ?? ""} downloaded. Restart now?`,
|
||||
message: `Update ${state.version} downloaded. Restart now?`,
|
||||
title: "Update Ready",
|
||||
buttons: ["Restart", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
logger.log("update prompt response", {
|
||||
version: result.version ?? null,
|
||||
restartNow: response.response === 0,
|
||||
})
|
||||
if (response.response === 0) {
|
||||
await installUpdate(killSidecar)
|
||||
}
|
||||
if (response.response === 0) await controller.install()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
import type { ElectronAPI, WslServersEvent } from "./types"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
|
||||
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
|
||||
let updaterState: UpdaterState | undefined
|
||||
let updaterSubscription: Promise<void> | undefined
|
||||
const updaterHandler = (_: unknown, state: UpdaterState) => {
|
||||
updaterState = state
|
||||
updaterCallbacks.forEach((callback) => callback(state))
|
||||
}
|
||||
|
||||
const api: ElectronAPI = {
|
||||
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
|
||||
@@ -28,7 +37,26 @@ const api: ElectronAPI = {
|
||||
removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
|
||||
startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
|
||||
},
|
||||
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
|
||||
updater: {
|
||||
subscribe: async (cb) => {
|
||||
updaterCallbacks.add(cb)
|
||||
if (updaterState) cb(updaterState)
|
||||
if (!updaterSubscription) {
|
||||
ipcRenderer.on("updater-state", updaterHandler)
|
||||
updaterSubscription = ipcRenderer.invoke("updater-subscribe")
|
||||
}
|
||||
await updaterSubscription
|
||||
return () => {
|
||||
updaterCallbacks.delete(cb)
|
||||
if (updaterCallbacks.size > 0) return
|
||||
ipcRenderer.removeListener("updater-state", updaterHandler)
|
||||
updaterSubscription = undefined
|
||||
void ipcRenderer.invoke("updater-unsubscribe")
|
||||
}
|
||||
},
|
||||
check: () => ipcRenderer.invoke("updater-check"),
|
||||
install: () => ipcRenderer.invoke("updater-install"),
|
||||
},
|
||||
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
||||
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
||||
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
|
||||
@@ -58,6 +86,8 @@ const api: ElectronAPI = {
|
||||
|
||||
openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
|
||||
openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
|
||||
readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path),
|
||||
releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token),
|
||||
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
|
||||
openLink: (url) => ipcRenderer.send("open-link", url),
|
||||
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
|
||||
@@ -83,9 +113,6 @@ const api: ElectronAPI = {
|
||||
},
|
||||
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
||||
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
|
||||
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
|
||||
checkUpdate: () => ipcRenderer.invoke("check-update"),
|
||||
installUpdate: () => ipcRenderer.invoke("install-update"),
|
||||
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
|
||||
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
|
||||
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
export type {
|
||||
WslDistroProbe,
|
||||
WslInstalledDistro,
|
||||
@@ -21,15 +22,16 @@ export type ServerReadyData = {
|
||||
}
|
||||
|
||||
export type WslServersAPI = WslServersPlatform
|
||||
export type UpdaterAPI = {
|
||||
subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void>
|
||||
check: () => Promise<UpdaterState>
|
||||
install: () => Promise<void>
|
||||
}
|
||||
|
||||
export type LinuxDisplayBackend = "wayland" | "auto"
|
||||
export type TitlebarTheme = {
|
||||
mode: "light" | "dark"
|
||||
}
|
||||
export type WindowConfig = {
|
||||
updaterEnabled: boolean
|
||||
}
|
||||
|
||||
export type FatalRendererError = {
|
||||
error: string
|
||||
url: string
|
||||
@@ -43,7 +45,7 @@ export type ElectronAPI = {
|
||||
installCli: () => Promise<string>
|
||||
awaitInitialization: () => Promise<ServerReadyData>
|
||||
wslServers: WslServersAPI
|
||||
getWindowConfig: () => Promise<WindowConfig>
|
||||
updater: UpdaterAPI
|
||||
consumeInitialDeepLinks: () => Promise<string[]>
|
||||
getDefaultServerUrl: () => Promise<string | null>
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void>
|
||||
@@ -72,9 +74,10 @@ export type ElectronAPI = {
|
||||
multiple?: boolean
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
accept?: string[]
|
||||
extensions?: string[]
|
||||
}) => Promise<string | string[] | null>
|
||||
}) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null>
|
||||
readPickedFile: (token: string, path: string) => Promise<ArrayBuffer>
|
||||
releasePickedFiles: (token: string) => Promise<void>
|
||||
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
|
||||
openLink: (url: string) => void
|
||||
openPath: (path: string, app?: string) => Promise<void>
|
||||
@@ -92,9 +95,6 @@ export type ElectronAPI = {
|
||||
onZoomFactorChanged: (cb: (factor: number) => void) => () => void
|
||||
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
||||
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
|
||||
runUpdater: (alertOnFail: boolean) => Promise<void>
|
||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||
installUpdate: () => Promise<void>
|
||||
setBackgroundColor: (color: string) => Promise<void>
|
||||
exportDebugLogs: () => Promise<string>
|
||||
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
ACCEPTED_FILE_EXTENSIONS,
|
||||
ACCEPTED_FILE_TYPES,
|
||||
AppBaseProviders,
|
||||
AppInterface,
|
||||
handleNotificationClick,
|
||||
@@ -15,10 +14,11 @@ import {
|
||||
useCommand,
|
||||
useWslServers,
|
||||
} from "@opencode-ai/app"
|
||||
import type { UpdaterState } from "@opencode-ai/app/updater"
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { MemoryRouter } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import pkg from "../../package.json"
|
||||
import { initI18n, t } from "./i18n"
|
||||
@@ -59,6 +59,9 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
|
||||
void initI18n()
|
||||
|
||||
const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "disabled" })
|
||||
void window.api.updater.subscribe(setUpdaterState)
|
||||
|
||||
const deepLinkEvent = "opencode:deep-link"
|
||||
|
||||
const emitDeepLinks = (urls: string[]) => {
|
||||
@@ -140,13 +143,21 @@ const createPlatform = (): Platform => {
|
||||
})
|
||||
},
|
||||
|
||||
async openFilePickerDialog(opts) {
|
||||
return window.api.openFilePicker({
|
||||
async openAttachmentPickerDialog(opts, onFile) {
|
||||
const result = await window.api.openFilePicker({
|
||||
multiple: opts?.multiple ?? false,
|
||||
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
||||
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
|
||||
defaultPath: opts?.defaultPath,
|
||||
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
|
||||
})
|
||||
if (!result) return
|
||||
try {
|
||||
for (const file of result.files) {
|
||||
await onFile(new File([await window.api.readPickedFile(result.token, file.path)], file.name))
|
||||
}
|
||||
} finally {
|
||||
await window.api.releasePickedFiles(result.token)
|
||||
}
|
||||
},
|
||||
|
||||
async saveFilePickerDialog(opts) {
|
||||
@@ -177,16 +188,10 @@ const createPlatform = (): Platform => {
|
||||
|
||||
storage,
|
||||
|
||||
checkUpdate: async () => {
|
||||
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
|
||||
if (!config.updaterEnabled) return { updateAvailable: false }
|
||||
return window.api.checkUpdate()
|
||||
},
|
||||
|
||||
updateAndRestart: async () => {
|
||||
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
|
||||
if (!config.updaterEnabled) return
|
||||
await window.api.installUpdate()
|
||||
updater: {
|
||||
state: updaterState,
|
||||
check: () => window.api.updater.check(),
|
||||
install: () => window.api.updater.install(),
|
||||
},
|
||||
|
||||
exportDebugLogs: () => window.api.exportDebugLogs(),
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { initI18n, t } from "./i18n"
|
||||
|
||||
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
|
||||
await initI18n()
|
||||
try {
|
||||
await window.api.runUpdater(alertOnFail)
|
||||
} catch {
|
||||
if (alertOnFail) {
|
||||
window.alert(t("desktop.updater.checkFailed.message"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
@@ -111,7 +111,6 @@
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bonjour-service": "1.3.0",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "catalog:",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
|
||||
@@ -1,386 +1 @@
|
||||
export default {
|
||||
// NOTE: FOR markdown, javascript and typescript, we use the opentui built-in parsers
|
||||
// Warn: when taking queries from the nvim-treesitter repo, make sure to include the query dependencies as well
|
||||
// marked with for example `; inherits: ecma` at the top of the file. Just put the dependencies before the actual query.
|
||||
// ALSO: Some queries use breaking changes in the nvim-treesitter repo, that are not compatible with the (web-)tree-sitter parser.
|
||||
parsers: [
|
||||
{
|
||||
filetype: "python",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
|
||||
// it is using "except" nodes that the parser is complaining about, but it has been in the query for 3+ years.
|
||||
// Unclear.
|
||||
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/highlights.scm",
|
||||
"https://github.com/tree-sitter/tree-sitter-python/raw/refs/heads/master/queries/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "rust",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.24.0/tree-sitter-rust.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "go",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.25.0/tree-sitter-go.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "cpp",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-cpp/releases/download/v0.23.4/tree-sitter-cpp.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "csharp",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-c-sharp/releases/download/v0.23.1/tree-sitter-c_sharp.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "bash",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.25.0/tree-sitter-bash.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/bash/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "c",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-c/releases/download/v0.24.1/tree-sitter-c.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "java",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-java/releases/download/v0.23.5/tree-sitter-java.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "kotlin",
|
||||
wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
|
||||
queries: {
|
||||
highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
|
||||
locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/locals.scm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ruby",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "php",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-php/releases/download/v0.24.2/tree-sitter-php.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
|
||||
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/php/highlights.scm",
|
||||
"https://github.com/tree-sitter/tree-sitter-php/raw/refs/heads/master/queries/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "scala",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-scala/releases/download/v0.24.0/tree-sitter-scala.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/scala/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "html",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
|
||||
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/html/highlights.scm",
|
||||
"https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/highlights.scm",
|
||||
],
|
||||
// TODO: Injections not working for some reason
|
||||
// injections: [
|
||||
// "https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/injections.scm",
|
||||
// ],
|
||||
},
|
||||
// injectionMapping: {
|
||||
// nodeTypes: {
|
||||
// script_element: "javascript",
|
||||
// style_element: "css",
|
||||
// },
|
||||
// infoStringMap: {
|
||||
// javascript: "javascript",
|
||||
// css: "css",
|
||||
// },
|
||||
// },
|
||||
},
|
||||
{
|
||||
filetype: "vue",
|
||||
wasm: "https://github.com/anomalyco/tree-sitter-vue/releases/download/v0.1.2/tree-sitter-vue.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/anomalyco/tree-sitter-vue/v0.1.2/queries/html_tags/highlights.scm",
|
||||
"https://raw.githubusercontent.com/anomalyco/tree-sitter-vue/v0.1.2/queries/vue/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "hcl",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "json",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/json/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "yaml",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-yaml/releases/download/v0.7.2/tree-sitter-yaml.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/yaml/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "haskell",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/haskell/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "css",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.25.0/tree-sitter-css.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/css/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "julia",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-julia/releases/download/v0.23.1/tree-sitter-julia.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/julia/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "lua",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
|
||||
],
|
||||
locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.scm"],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ocaml",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ocaml/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "clojure",
|
||||
// temporarily using fork to fix issues
|
||||
wasm: "https://github.com/anomalyco/tree-sitter-clojure/releases/download/v0.0.1/tree-sitter-clojure.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/clojure/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "swift",
|
||||
wasm: "https://github.com/alex-pinkus/tree-sitter-swift/releases/download/0.7.1/tree-sitter-swift.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
// NOTE: Using parser repo queries instead of nvim-treesitter due to incompatible #lua-match? predicates
|
||||
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/highlights.scm
|
||||
"https://raw.githubusercontent.com/alex-pinkus/tree-sitter-swift/main/queries/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/swift/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "toml",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "nix",
|
||||
// TODO: Replace with official tree-sitter-nix WASM when published
|
||||
// See: https://github.com/nix-community/tree-sitter-nix/issues/66
|
||||
wasm: "https://github.com/ast-grep/ast-grep.github.io/raw/40b84530640aa83a0d34a20a2b0623d7b8e5ea97/website/public/parsers/tree-sitter-nix.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "diff",
|
||||
aliases: ["udiff", "patch"],
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-diff/releases/download/v0.1.0/tree-sitter-diff.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-diff/master/queries/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "elixir",
|
||||
wasm: "https://github.com/elixir-lang/tree-sitter-elixir/releases/download/v0.3.5/tree-sitter-elixir.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/elixir/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "fsharp",
|
||||
wasm: "https://github.com/ionide/tree-sitter-fsharp/releases/download/0.3.0/tree-sitter-fsharp.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/fsharp/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "r",
|
||||
wasm: "https://github.com/r-lib/tree-sitter-r/releases/download/v1.2.0/tree-sitter-r.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/r/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "make",
|
||||
aliases: ["makefile"],
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-make/releases/download/v1.1.1/tree-sitter-make.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/make/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "vim",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-vim/releases/download/v0.8.1/tree-sitter-vim.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/vim/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "xml",
|
||||
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-xml/releases/download/v0.7.0/tree-sitter-xml.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/xml/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "agda",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-agda/releases/download/v1.3.3/tree-sitter-agda.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/agda/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
export { default } from "@opencode-ai/tui/parsers-config"
|
||||
|
||||
@@ -158,7 +158,7 @@ for (const item of targets) {
|
||||
const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
||||
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
|
||||
const workerPath = "./src/cli/cmd/tui/worker.ts"
|
||||
const workerPath = "./src/cli/tui/worker.ts"
|
||||
|
||||
// Use platform-specific bunfs root path based on target OS
|
||||
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Schema } from "effect"
|
||||
import { TuiInfo } from "../src/cli/cmd/tui/config/tui-schema"
|
||||
|
||||
type JsonSchema = Record<string, unknown>
|
||||
const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model"
|
||||
@@ -73,5 +73,5 @@ await Bun.write(configFile, JSON.stringify(generateEffect(ConfigV1.Info), null,
|
||||
|
||||
if (tuiFile) {
|
||||
console.log(tuiFile)
|
||||
await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiInfo), null, 2))
|
||||
await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiConfig.Info), null, 2))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as ts from "typescript"
|
||||
const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
|
||||
|
||||
// Get entry file from command line arg or use default
|
||||
const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts"
|
||||
const ENTRY_FILE = process.argv[2] || "src/plugin/tui/runtime.ts"
|
||||
|
||||
const visited = new Set<string>()
|
||||
|
||||
|
||||
+40
-46
@@ -1,8 +1,7 @@
|
||||
import { cmd } from "../cmd"
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { validateSession } from "./validate-session"
|
||||
import { errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
@@ -44,49 +43,47 @@ export const AttachCommand = cmd({
|
||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { TuiConfig } = await import("@/cli/cmd/tui/config/tui")
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
try {
|
||||
win32DisableProcessedInput()
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
try {
|
||||
process.chdir(args.dir)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
// If the directory doesn't exist locally (remote attach), pass it through.
|
||||
return args.dir
|
||||
}
|
||||
})()
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const config = await TuiConfig.get()
|
||||
const { TuiConfig } = await import("@/config/tui")
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
try {
|
||||
await validateSession({
|
||||
url: args.url,
|
||||
sessionID: args.session,
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
process.exitCode = 1
|
||||
return
|
||||
process.chdir(args.dir)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
// If the directory doesn't exist locally (remote attach), pass it through.
|
||||
return args.dir
|
||||
}
|
||||
})()
|
||||
const headers = ServerAuth.headers({ password: args.password, username: args.username })
|
||||
const config = await TuiConfig.get()
|
||||
|
||||
const { createTuiRenderer, tui } = await import("./app")
|
||||
const renderer = await createTuiRenderer(config)
|
||||
const handle = tui({
|
||||
try {
|
||||
await validateSession({
|
||||
url: args.url,
|
||||
sessionID: args.session,
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
} catch (error) {
|
||||
UI.error(errorMessage(error))
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const { Effect } = await import("effect")
|
||||
const { run } = await import("../tui/layer")
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
await Effect.runPromise(
|
||||
run({
|
||||
url: args.url,
|
||||
config,
|
||||
renderer,
|
||||
pluginHost: createLegacyTuiPluginHost(),
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
@@ -94,10 +91,7 @@ export const AttachCommand = cmd({
|
||||
},
|
||||
directory,
|
||||
headers,
|
||||
})
|
||||
await handle.done
|
||||
} finally {
|
||||
unguard?.()
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -1,48 +1 @@
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
|
||||
export function promptOffsetWidth(value: string) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
// Textarea offsets count newlines as one position; Bun.stringWidth counts them as zero.
|
||||
width += part.segment === "\n" ? 1 : Bun.stringWidth(part.segment)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
function displayOffsetIndex(value: string, offset: number) {
|
||||
if (offset <= 0) return 0
|
||||
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
const next = width + promptOffsetWidth(part.segment)
|
||||
if (next > offset) return part.index
|
||||
width = next
|
||||
}
|
||||
|
||||
return value.length
|
||||
}
|
||||
|
||||
export function displaySlice(value: string, start = 0, end = promptOffsetWidth(value)) {
|
||||
return value.slice(displayOffsetIndex(value, start), displayOffsetIndex(value, end))
|
||||
}
|
||||
|
||||
export function displayCharAt(value: string, offset: number) {
|
||||
let width = 0
|
||||
for (const part of graphemes.segment(value)) {
|
||||
const next = width + promptOffsetWidth(part.segment)
|
||||
if (offset === width || offset < next) return part.segment
|
||||
width = next
|
||||
}
|
||||
}
|
||||
|
||||
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
|
||||
const text = displaySlice(value, 0, offset)
|
||||
const index = text.lastIndexOf("@")
|
||||
if (index === -1) return
|
||||
|
||||
const before = index === 0 ? undefined : text[index - 1]
|
||||
const query = text.slice(index)
|
||||
if ((before === undefined || /\s/.test(before)) && !/\s/.test(query)) {
|
||||
return promptOffsetWidth(text.slice(0, index))
|
||||
}
|
||||
}
|
||||
export * from "@opencode-ai/tui/prompt/display"
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
movePromptHistory,
|
||||
pushPromptHistory,
|
||||
} from "./prompt.shared"
|
||||
import { OPENCODE_BASE_MODE, useBindings } from "@/cli/cmd/tui/keymap"
|
||||
import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import "opentui-spinner/solid"
|
||||
import { Show, createMemo, indexArray } from "solid-js"
|
||||
import { SPINNER_FRAMES } from "../tui/component/spinner"
|
||||
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
||||
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
||||
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { Keymap } from "@opentui/keymap"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { OpencodeKeymapProvider, formatKeyBindings } from "@/cli/cmd/tui/keymap"
|
||||
import { OpencodeKeymapProvider, formatKeyBindings } from "@opencode-ai/tui/keymap"
|
||||
import { withRunSpan } from "./otel"
|
||||
import { RUN_COMMAND_PANEL_ROWS, RUN_SUBAGENT_PANEL_ROWS } from "./footer.command"
|
||||
import { SUBAGENT_INSPECTOR_ROWS } from "./footer.subagent"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||
import { createColors, createFrames } from "@opencode-ai/tui/ui/spinner"
|
||||
import {
|
||||
RUN_SUBAGENT_PANEL_ROWS,
|
||||
RunCommandMenuBody,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
useBindings,
|
||||
useKeymapSelector,
|
||||
type OpenTuiKeymap,
|
||||
} from "@/cli/cmd/tui/keymap"
|
||||
} from "@opencode-ai/tui/keymap"
|
||||
import type {
|
||||
FooterPromptRoute,
|
||||
FooterQueuedPrompt,
|
||||
|
||||
@@ -6,17 +6,14 @@
|
||||
// history ring. All are async because they read config or hit the SDK, but
|
||||
// none block each other.
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { reusePendingTask } from "./runtime.shared"
|
||||
import { resolveSession, sessionHistory } from "./session.shared"
|
||||
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
||||
import { pickVariant } from "./variant.shared"
|
||||
|
||||
const DEFAULT_LEADER_TIMEOUT = 2000
|
||||
|
||||
export type ModelInfo = {
|
||||
providers: RunProvider[]
|
||||
variants: string[]
|
||||
@@ -70,13 +67,8 @@ function emptySessionInfo(): SessionInfo {
|
||||
}
|
||||
|
||||
function defaultRunTuiConfig(): RunTuiConfig {
|
||||
const keybinds = TuiKeybind.parse({})
|
||||
return {
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
}),
|
||||
leader_timeout: DEFAULT_LEADER_TIMEOUT,
|
||||
...resolve({}, { terminalSuspend: process.platform !== "win32" }),
|
||||
diff_style: "auto",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { Session as SessionApi } from "@/session/session"
|
||||
import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
|
||||
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||
import * as Locale from "@/util/locale"
|
||||
import { withRunSpan } from "./otel"
|
||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
||||
|
||||
@@ -590,7 +590,7 @@ export async function resolveRunTheme(renderer: CliRenderer): Promise<RunTheme>
|
||||
: (renderer.themeMode ?? mode(RGBA.fromHex(bg)))
|
||||
const theme = resolveTheme(generateSystem(colors, pick), pick)
|
||||
const indexed = indexedPalette(colors, 256)
|
||||
const shared = await import("../tui/context/theme")
|
||||
const shared = await import("@opencode-ai/tui/context/theme")
|
||||
const syntaxTheme: SharedSyntaxTheme = {
|
||||
...theme,
|
||||
_hasSelectedListItemText: true,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// → footer.ts queues commits and patches the footer view
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
import type { OpencodeClient, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import type { TuiConfig } from "@opencode-ai/tui/config"
|
||||
|
||||
export type RunFilePart = {
|
||||
type: "file"
|
||||
|
||||
+40
-40
@@ -1,17 +1,16 @@
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { type rpc } from "./worker"
|
||||
import { type rpc } from "../tui/worker"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { UI } from "@/cli/ui"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { errorMessage } from "@opencode-ai/tui/util/error"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import type { EventSource } from "@opencode-ai/tui/context/sdk"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
import {
|
||||
OPENCODE_PROCESS_ROLE,
|
||||
@@ -19,7 +18,8 @@ import {
|
||||
ensureRunID,
|
||||
sanitizedProcessEnv,
|
||||
} from "@opencode-ai/core/util/opencode-process"
|
||||
import { validateSession } from "./validate-session"
|
||||
import { validateSession } from "../tui/validate-session"
|
||||
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
@@ -57,9 +57,9 @@ function createEventSource(client: RpcClient): EventSource {
|
||||
|
||||
async function target() {
|
||||
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
|
||||
const dist = new URL("./cli/cmd/tui/worker.js", import.meta.url)
|
||||
const dist = new URL("./cli/tui/worker.js", import.meta.url)
|
||||
if (await Filesystem.exists(fileURLToPath(dist))) return dist
|
||||
return new URL("./worker.ts", import.meta.url)
|
||||
return new URL("../tui/worker.ts", import.meta.url)
|
||||
}
|
||||
|
||||
async function input(value?: string) {
|
||||
@@ -112,15 +112,9 @@ export const TuiThreadCommand = cmd({
|
||||
describe: "agent to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const { TuiConfig } = await import("./config/tui")
|
||||
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
||||
// (Important when running under `bun run` wrappers on Windows.)
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
try {
|
||||
// Must be the very first thing — disables CTRL_C_EVENT before any Worker
|
||||
// spawn or async work so the OS cannot kill the process group.
|
||||
win32DisableProcessedInput()
|
||||
|
||||
const { TuiConfig } = await import("@/config/tui")
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exitCode = 1
|
||||
@@ -228,37 +222,43 @@ export const TuiThreadCommand = cmd({
|
||||
}, 1000).unref?.()
|
||||
|
||||
try {
|
||||
const { createTuiRenderer, tui } = await import("./app")
|
||||
const renderer = await createTuiRenderer(config)
|
||||
const handle = tui({
|
||||
url: transport.url,
|
||||
renderer,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
return [tui, server]
|
||||
},
|
||||
config,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
events: transport.events,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
agent: args.agent,
|
||||
model: args.model,
|
||||
prompt,
|
||||
fork: args.fork,
|
||||
},
|
||||
})
|
||||
await handle.done
|
||||
const { Effect } = await import("effect")
|
||||
const { run } = await import("../tui/layer")
|
||||
const { createLegacyTuiPluginHost } = await import("@/plugin/tui/runtime")
|
||||
await Effect.runPromise(
|
||||
run({
|
||||
url: transport.url,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
return [tui, server]
|
||||
},
|
||||
config,
|
||||
pluginHost: createLegacyTuiPluginHost(),
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
events: transport.events,
|
||||
args: {
|
||||
continue: args.continue,
|
||||
sessionID: args.session,
|
||||
agent: args.agent,
|
||||
model: args.model,
|
||||
prompt,
|
||||
fork: args.fork,
|
||||
},
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
await stop()
|
||||
}
|
||||
process.exit(0)
|
||||
} finally {
|
||||
unguard?.()
|
||||
try {
|
||||
unguard?.()
|
||||
} catch (error) {
|
||||
Log.Default.warn("failed to restore terminal guard", { error: errorMessage(error) })
|
||||
}
|
||||
}
|
||||
process.exit(0)
|
||||
},
|
||||
})
|
||||
// scratch
|
||||
@@ -1,90 +0,0 @@
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "../../context/helper"
|
||||
import { appendFile, writeFile } from "fs/promises"
|
||||
|
||||
function calculateFrecency(entry?: { frequency: number; lastOpen: number }): number {
|
||||
if (!entry) return 0
|
||||
const daysSince = (Date.now() - entry.lastOpen) / 86400000 // ms per day
|
||||
const weight = 1 / (1 + daysSince)
|
||||
return entry.frequency * weight
|
||||
}
|
||||
|
||||
const MAX_FRECENCY_ENTRIES = 1000
|
||||
|
||||
export const { use: useFrecency, provider: FrecencyProvider } = createSimpleContext({
|
||||
name: "Frecency",
|
||||
init: () => {
|
||||
const frecencyPath = path.join(Global.Path.state, "frecency.jsonl")
|
||||
onMount(async () => {
|
||||
const text = await Filesystem.readText(frecencyPath).catch(() => "")
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as { path: string; frequency: number; lastOpen: number }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((line): line is { path: string; frequency: number; lastOpen: number } => line !== null)
|
||||
|
||||
const latest = lines.reduce(
|
||||
(acc, entry) => {
|
||||
acc[entry.path] = entry
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, { path: string; frequency: number; lastOpen: number }>,
|
||||
)
|
||||
|
||||
const sorted = Object.values(latest)
|
||||
.sort((a, b) => b.lastOpen - a.lastOpen)
|
||||
.slice(0, MAX_FRECENCY_ENTRIES)
|
||||
|
||||
setStore(
|
||||
"data",
|
||||
Object.fromEntries(
|
||||
sorted.map((entry) => [entry.path, { frequency: entry.frequency, lastOpen: entry.lastOpen }]),
|
||||
),
|
||||
)
|
||||
|
||||
if (sorted.length > 0) {
|
||||
const content = sorted.map((entry) => JSON.stringify(entry)).join("\n") + "\n"
|
||||
writeFile(frecencyPath, content).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
data: {} as Record<string, { frequency: number; lastOpen: number }>,
|
||||
})
|
||||
|
||||
function updateFrecency(filePath: string) {
|
||||
const absolutePath = path.resolve(process.cwd(), filePath)
|
||||
const newEntry = {
|
||||
frequency: (store.data[absolutePath]?.frequency || 0) + 1,
|
||||
lastOpen: Date.now(),
|
||||
}
|
||||
setStore("data", absolutePath, newEntry)
|
||||
appendFile(frecencyPath, JSON.stringify({ path: absolutePath, ...newEntry }) + "\n").catch(() => {})
|
||||
|
||||
if (Object.keys(store.data).length > MAX_FRECENCY_ENTRIES) {
|
||||
const sorted = Object.entries(store.data)
|
||||
.sort(([, a], [, b]) => b.lastOpen - a.lastOpen)
|
||||
.slice(0, MAX_FRECENCY_ENTRIES)
|
||||
setStore("data", Object.fromEntries(sorted))
|
||||
const content = sorted.map(([path, entry]) => JSON.stringify({ path, ...entry })).join("\n") + "\n"
|
||||
writeFile(frecencyPath, content).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getFrecency: (filePath: string) => calculateFrecency(store.data[path.resolve(process.cwd(), filePath)]),
|
||||
updateFrecency,
|
||||
data: () => store.data,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import { PartID } from "@/session/schema"
|
||||
import { displaySlice } from "@/cli/cmd/prompt-display"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
||||
type Item = PromptInfo["parts"][number]
|
||||
|
||||
export function strip(part: Item & { id: string; messageID: string; sessionID: string }): Item {
|
||||
const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
|
||||
return rest
|
||||
}
|
||||
|
||||
export function assign(part: Item): Item & { id: PartID } {
|
||||
return {
|
||||
...part,
|
||||
id: PartID.ascending(),
|
||||
}
|
||||
}
|
||||
|
||||
export function expandPastedTextPlaceholders(text: string, parts: PromptInfo["parts"]) {
|
||||
return parts.reduce((result, part) => {
|
||||
if (part.type !== "text" || !part.source?.text) return result
|
||||
return result.replace(part.source.text.value, part.text)
|
||||
}, text)
|
||||
}
|
||||
|
||||
export function expandTrackedPastedText(text: string, ranges: { start: number; end: number; text: string }[]) {
|
||||
return ranges
|
||||
.slice()
|
||||
.sort((a, b) => b.start - a.start)
|
||||
.reduce((result, part) => displaySlice(result, 0, part.start) + part.text + displaySlice(result, part.end), text)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { createSimpleContext } from "../../context/helper"
|
||||
import { appendFile, writeFile } from "fs/promises"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
||||
export type StashEntry = {
|
||||
input: string
|
||||
parts: PromptInfo["parts"]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const MAX_STASH_ENTRIES = 50
|
||||
|
||||
export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
|
||||
name: "PromptStash",
|
||||
init: () => {
|
||||
const stashPath = path.join(Global.Path.state, "prompt-stash.jsonl")
|
||||
onMount(async () => {
|
||||
const text = await Filesystem.readText(stashPath).catch(() => "")
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((line): line is StashEntry => line !== null)
|
||||
.slice(-MAX_STASH_ENTRIES)
|
||||
|
||||
setStore("entries", lines)
|
||||
|
||||
// Rewrite file with only valid entries to self-heal corruption
|
||||
if (lines.length > 0) {
|
||||
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
|
||||
writeFile(stashPath, content).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
entries: [] as StashEntry[],
|
||||
})
|
||||
|
||||
return {
|
||||
list() {
|
||||
return store.entries
|
||||
},
|
||||
push(entry: Omit<StashEntry, "timestamp">) {
|
||||
const stash = structuredClone(unwrap({ ...entry, timestamp: Date.now() }))
|
||||
let trimmed = false
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.entries.push(stash)
|
||||
if (draft.entries.length > MAX_STASH_ENTRIES) {
|
||||
draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
|
||||
trimmed = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (trimmed) {
|
||||
const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n"
|
||||
writeFile(stashPath, content).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
appendFile(stashPath, JSON.stringify(stash) + "\n").catch(() => {})
|
||||
},
|
||||
pop() {
|
||||
if (store.entries.length === 0) return undefined
|
||||
const entry = store.entries[store.entries.length - 1]
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.entries.pop()
|
||||
}),
|
||||
)
|
||||
const content =
|
||||
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
|
||||
writeFile(stashPath, content).catch(() => {})
|
||||
return entry
|
||||
},
|
||||
remove(index: number) {
|
||||
if (index < 0 || index >= store.entries.length) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.entries.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
const content =
|
||||
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
|
||||
writeFile(stashPath, content).catch(() => {})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
import { Schema } from "effect"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { TuiAttentionSoundNames, type TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
|
||||
|
||||
export type TuiAttentionSoundPaths = Partial<Record<TuiAttentionSoundName, string>>
|
||||
|
||||
export function isAttentionSoundName(value: string): value is TuiAttentionSoundName {
|
||||
return TuiAttentionSoundNames.includes(value as TuiAttentionSoundName)
|
||||
}
|
||||
|
||||
export function resolveAttentionSoundPaths(
|
||||
root: string,
|
||||
sounds: unknown,
|
||||
options?: { trim?: boolean },
|
||||
): TuiAttentionSoundPaths {
|
||||
if (!isRecord(sounds)) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(sounds).flatMap(([name, file]) => {
|
||||
if (!isAttentionSoundName(name)) return []
|
||||
if (typeof file !== "string") return []
|
||||
const value = options?.trim ? file.trim() : file
|
||||
if (!value) return []
|
||||
return [[name, Filesystem.resolveFilePath(root, value)]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const KeymapLeaderTimeoutDefault = 2000
|
||||
const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
|
||||
description: "Leader key timeout in milliseconds",
|
||||
})
|
||||
|
||||
const TuiAttentionSounds = Schema.Struct({
|
||||
default: Schema.optional(Schema.String),
|
||||
question: Schema.optional(Schema.String),
|
||||
permission: Schema.optional(Schema.String),
|
||||
error: Schema.optional(Schema.String),
|
||||
done: Schema.optional(Schema.String),
|
||||
subagent_done: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
|
||||
|
||||
export const ScrollAcceleration = Schema.Struct({
|
||||
enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }),
|
||||
}).annotate({ description: "Scroll acceleration settings" })
|
||||
|
||||
export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
|
||||
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
|
||||
})
|
||||
|
||||
export const Attention = Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean),
|
||||
notifications: Schema.optional(Schema.Boolean),
|
||||
sound: Schema.optional(Schema.Boolean),
|
||||
volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
|
||||
sound_pack: Schema.optional(Schema.String),
|
||||
sounds: Schema.optional(TuiAttentionSounds),
|
||||
}).annotate({ description: "Attention notification and sound settings" })
|
||||
|
||||
const PromptSize = Schema.Int.check(Schema.isGreaterThan(0))
|
||||
|
||||
export const Prompt = Schema.Struct({
|
||||
max_height: Schema.optional(PromptSize).annotate({ description: "Prompt textarea max height" }),
|
||||
max_width: Schema.optional(Schema.Union([PromptSize, Schema.Literal("auto")])).annotate({
|
||||
description: "Home prompt max width: a positive integer for a fixed cap, or 'auto' to scale with terminal width",
|
||||
}),
|
||||
}).annotate({ description: "Prompt size settings" })
|
||||
|
||||
export const TuiInfo = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String),
|
||||
theme: Schema.optional(Schema.String),
|
||||
keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
|
||||
plugin: Schema.optional(Schema.Array(ConfigPluginV1.Spec)),
|
||||
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
leader_timeout: Schema.optional(KeymapLeaderTimeout),
|
||||
attention: Schema.optional(Attention),
|
||||
prompt: Schema.optional(Prompt),
|
||||
scroll_speed: Schema.optional(ScrollSpeed).annotate({
|
||||
description: "TUI scroll speed",
|
||||
}),
|
||||
scroll_acceleration: Schema.optional(ScrollAcceleration),
|
||||
diff_style: Schema.optional(DiffStyle),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export type Exit = ((reason?: unknown) => Promise<void>) & {
|
||||
message: {
|
||||
set: (value?: string) => () => void
|
||||
clear: () => void
|
||||
get: () => string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function createExit(run: (reason: unknown | undefined, message: () => string | undefined) => Promise<void>) {
|
||||
let message: string | undefined
|
||||
let task: Promise<void> | undefined
|
||||
const store = {
|
||||
set: (value?: string) => {
|
||||
const prev = message
|
||||
message = value
|
||||
return () => {
|
||||
message = prev
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
message = undefined
|
||||
},
|
||||
get: () => message,
|
||||
}
|
||||
|
||||
return Object.assign(
|
||||
(reason?: unknown) => {
|
||||
task ??= run(reason, store.get)
|
||||
return task
|
||||
},
|
||||
{
|
||||
message: store,
|
||||
},
|
||||
) satisfies Exit
|
||||
}
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { exit: Exit }) => input.exit,
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
||||
name: "TuiConfig",
|
||||
init: (props: { config: TuiConfig.Resolved }) => {
|
||||
return props.config
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user