Compare commits

...

16 Commits

Author SHA1 Message Date
opencode-agent[bot] 9d61eef141 fix: keep read path validation model-visible 2026-06-07 13:09:27 +00:00
Luke Parker e82542b802 fix(desktop): disable hidden agent cycling (#31207) 2026-06-07 07:54:13 +00:00
Luke Parker 2006259a02 fix(desktop): style home session scrollbar (#31202) 2026-06-07 06:19:49 +00:00
opencode-agent[bot] 472b33113e chore: generate 2026-06-07 05:58:27 +00:00
Luke Parker 218147271c feat(desktop): open attachments in active project (#31192) 2026-06-07 05:57:07 +00:00
Luke Parker a29deb1ee9 fix(app): refresh directory MCP status (#31194) 2026-06-07 05:53:23 +00:00
opencode-agent[bot] f24049736e chore: update nix node_modules hashes 2026-06-07 05:41:48 +00:00
opencode-agent[bot] 21a644fef5 chore: generate 2026-06-07 05:25:52 +00:00
Dax 106f8e94d6 refactor(tui): extract standalone package (#31193) 2026-06-07 05:24:27 +00:00
opencode-agent[bot] 7a2c49e762 chore: generate 2026-06-07 05:22:22 +00:00
Luke Parker 9b4d5b0395 feat(desktop): make updates persistent and responsive (#31191) 2026-06-07 05:20:56 +00:00
Robert Douglass f20655bef4 fix(tui): sort connect providers alphabetically (#30891)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-07 00:17:38 -05:00
opencode-agent[bot] 31c099be43 chore: update nix node_modules hashes 2026-06-07 03:41:56 +00:00
opencode-agent[bot] 155e1f20d6 chore: generate 2026-06-07 03:28:52 +00:00
Dax fe0c4f8c74 refactor(server): canonicalize service API (#31049) 2026-06-07 03:27:28 +00:00
Kit Langton 53ff1b57c9 fix(core): preserve session failure causes 2026-06-06 23:26:21 -04:00
447 changed files with 8645 additions and 5691 deletions
+46 -7
View File
@@ -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
View File
@@ -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="
}
}
+1
View File
@@ -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"
-1
View File
@@ -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),
)
}
}
+22 -13
View File
@@ -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()
}
+19
View File
@@ -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),
}),
}))
}
+27 -20
View File
@@ -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({
+23
View File
@@ -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))
},
})
},
},
}
}
-12
View File
@@ -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) {
+2
View File
@@ -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",
+1
View File
@@ -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,
+24 -26
View File
@@ -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>
+20 -30
View File
@@ -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 -37
View File
@@ -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])
})
})
-10
View File
@@ -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),
}),
]
+17
View File
@@ -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>
}
+1
View File
@@ -0,0 +1 @@
preload = ["@opentui/solid/preload"]
+5 -1
View File
@@ -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:*",
+19 -1
View File
@@ -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))
}
+10 -10
View File
@@ -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
}
+1
View File
@@ -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"),
},
+22 -10
View File
@@ -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 })
}),
)
+36
View File
@@ -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
View File
@@ -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
}
+2 -5
View File
@@ -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)
}),
+1 -1
View File
@@ -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 }),
)
+2
View File
@@ -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),
)
+14 -5
View File
@@ -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 }) {
+1 -1
View File
@@ -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) =>
+8 -18
View File
@@ -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,
)
+4 -5
View File
@@ -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))
+8
View File
@@ -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 -5
View File
@@ -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),
+2
View File
@@ -58,3 +58,5 @@ export const layer = Layer.effect(
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
+13 -6
View File
@@ -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
}
+1 -1
View File
@@ -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) })
}),
)
+12 -12
View File
@@ -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" }),
)
}),
})
+2 -2
View File
@@ -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,
+12 -5
View File
@@ -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 })
}),
)
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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")
})
}
})
+1 -1
View File
@@ -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")
+17
View File
@@ -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()
}
}
+16 -15
View File
@@ -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()
+52 -14
View File
@@ -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()
},
}
}
+31 -95
View File
@@ -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()
}
+31 -4
View File
@@ -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),
+10 -10
View File
@@ -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>
+20 -15
View File
@@ -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(),
-12
View File
@@ -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"))
}
}
}
+1 -2
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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 -2
View File
@@ -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))
}
+1 -1
View File
@@ -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>()
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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"
@@ -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