Compare commits

...

38 Commits

Author SHA1 Message Date
Ryan Vogel
c380d389aa feat(release): add highlights template to draft releases 2026-01-26 17:42:28 -05:00
zerone0x
ec2ab639bb fix(enterprise): add message navigation to share page desktop view (#10071)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-26 12:32:16 -05:00
adamelmore
d05ed5ca83 chore(app): createStore over signals 2026-01-26 11:26:17 -06:00
adamelmore
37f1a1a4ef chore: cleanup 2026-01-26 11:07:52 -06:00
adamelmore
b8e8d82323 chore: cleanup 2026-01-26 11:07:52 -06:00
adamelmore
801eb5d2cb wip(app): file tree mode 2026-01-26 11:07:52 -06:00
adamelmore
ebeed03115 wip(app): file tree mode 2026-01-26 11:07:51 -06:00
Adam
d9eed4c6ca feat(app): file tree 2026-01-26 11:07:51 -06:00
Ryan Vogel
7e34d27b77 fix: add 44px top padding to sticky version header on changelog (#10715) 2026-01-26 12:02:20 -05:00
Rahul A Mistry
783121c06e fix(ui): use focus-visible instead of focus to prevent sticky hover effect on click (#10651) 2026-01-26 10:58:29 -06:00
Alex Yaroshuk
984518b1c0 fix(app): restore external link opening in system browser (#10697) 2026-01-26 10:57:49 -06:00
Sam Huckaby
7fcdbd155b fix(app): Order themes alphabetically (#10698) 2026-01-26 10:55:43 -06:00
Ryan Vogel
5856ea4e75 fix: move changelog footer outside content div to fix padding (#10712) 2026-01-26 11:46:56 -05:00
Github Action
32a0dcedcb chore: update nix node_modules hashes 2026-01-26 16:21:24 +00:00
Sebastian Herrlinger
f48784d152 upgrade opentui to v0.1.75 2026-01-26 11:17:26 -05:00
adamelmore
3dce6a6608 chore: gen changelog page off changelog json 2026-01-26 09:53:10 -06:00
Aiden Cline
39a73d4894 feat: dynamically resolve AGENTS.md files from subdirectories as agent explores them (#10678) 2026-01-26 10:49:41 -05:00
Steffen Deusch
b1fbfa7e94 feat(opencode): add agent description (#10680) 2026-01-26 10:43:10 -05:00
GitHub Action
805ae19c9a chore: generate 2026-01-26 15:34:31 +00:00
adamelmore
fcea7e18a5 chore: stale-while-revalidate headers for changelog 2026-01-26 09:33:43 -06:00
adamelmore
7c34319b19 fix(app): query selector with non-latin chars 2026-01-26 09:33:42 -06:00
adamelmore
cd4676171b feat(app): better sidebar hover when collapsed 2026-01-26 09:33:42 -06:00
adamelmore
7016be0739 wip(app): full-height sidebar 2026-01-26 09:33:42 -06:00
adamelmore
ff35db0360 wip(app): full-height sidebar 2026-01-26 09:33:42 -06:00
adamelmore
af3d8c383e wip(app): sidebar hover full 2026-01-26 09:33:42 -06:00
GitHub Action
7f75f71f6b chore: generate 2026-01-26 14:15:46 +00:00
adamelmore
84b12a8fb7 feat(app): model settings 2026-01-26 08:15:01 -06:00
adamelmore
1934ee13d8 wip(app): model settings 2026-01-26 08:15:01 -06:00
adamelmore
6c1e18f111 fix(app): line selection waits on ready 2026-01-26 08:15:00 -06:00
adamelmore
3296b90372 fix(app): handle non-tool call permissions 2026-01-26 08:15:00 -06:00
adamelmore
0d651eab3b feat(app): default servers on web 2026-01-26 08:15:00 -06:00
Ariane Emory
0edd304f42 fix: Make diff wrapping toggle always available in command_list and correct a type error (resolves #10682) (#10683) 2026-01-26 09:01:23 -05:00
Noam Bressler
6b83b172ae fix: await SessionRevert.cleanup for shell (#10669) 2026-01-26 07:36:17 -05:00
Idris Gadi
444934a4c1 fix(tui): add visual feedback for diff wrap and conceal toggles (#10655) 2026-01-26 07:34:09 -05:00
adamelmore
c4f1087e58 chore: details 2026-01-26 06:24:47 -06:00
adamelmore
c87232d5df perf(app): performance improvements 2026-01-26 06:08:09 -06:00
adamelmore
d03c5f6b3f perf(app): performance improvements 2026-01-26 06:08:09 -06:00
GitHub Action
9a33b1ec88 ignore: update download stats 2026-01-26 2026-01-26 12:06:03 +00:00
70 changed files with 3385 additions and 1760 deletions

View File

@@ -211,3 +211,4 @@
| 2026-01-23 | 6,096,236 (+329,896) | 2,096,235 (+66,748) | 8,192,471 (+396,644) |
| 2026-01-24 | 6,371,019 (+274,783) | 2,156,870 (+60,635) | 8,527,889 (+335,418) |
| 2026-01-25 | 6,639,082 (+268,063) | 2,187,853 (+30,983) | 8,826,935 (+299,046) |
| 2026-01-26 | 6,941,620 (+302,538) | 2,232,115 (+44,262) | 9,173,735 (+346,800) |

View File

@@ -296,8 +296,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.74",
"@opentui/solid": "0.1.74",
"@opentui/core": "0.1.75",
"@opentui/solid": "0.1.75",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -1227,21 +1227,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.74", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.74", "@opentui/core-darwin-x64": "0.1.74", "@opentui/core-linux-arm64": "0.1.74", "@opentui/core-linux-x64": "0.1.74", "@opentui/core-win32-arm64": "0.1.74", "@opentui/core-win32-x64": "0.1.74", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-g4W16ymv12JdgZ+9B4t7mpIICvzWy2+eHERfmDf80ALduOQCUedKQdULcBFhVCYUXIkDRtIy6CID5thMAah3FA=="],
"@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.74", "", { "os": "darwin", "cpu": "x64" }, "sha512-WAD8orsDV0ZdW/5GwjOOB4FY96772xbkz+rcV7WRzEFUVaqoBaC04IuqYzS9d5s+cjkbT5Cpj47hrVYkkVQKng=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.74", "", { "os": "linux", "cpu": "arm64" }, "sha512-lgmHzrzLy4e+rgBS+lhtsMLLgIMLbtLNMm6EzVPyYVDlLDGjM7+ulXMem7AtpaRrWrUUl4REiG9BoQUsCFDwYA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.74", "", { "os": "linux", "cpu": "x64" }, "sha512-8Mn2WbdBQ29xCThuPZezjDhd1N3+fXwKkGvCBOdTI0le6h2A/vCNbfUVjwfr/EGZSRXxCG+Yapol34BAULGpOA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.74", "", { "os": "win32", "cpu": "arm64" }, "sha512-dvYUXz03avnI6ZluyLp00HPmR0UT/IE/6QS97XBsgJlUTtpnbKkBtB5jD1NHwWkElaRj1Qv2QP36ngFoJqbl9g=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.74", "", { "os": "win32", "cpu": "x64" }, "sha512-3wfWXaAKOIlDQz6ZZIESf2M+YGZ7uFHijjTEM8w/STRlLw8Y6+QyGYi1myHSM4d6RSO+/s2EMDxvjDf899W9vQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
"@opentui/solid": ["@opentui/solid@0.1.74", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.74", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-Vz82cI8T9YeJjGsVg4ULp6ral4N+xyt1j9A6Tbu3aaQgEKiB74LW03EXREehfjPr1irOFxtKfWPbx5NKH0Upag=="],
"@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-olTZ+tKugAY3LxizsJMlbK3TW78HZUoM03PigvQLP4A=",
"aarch64-linux": "sha256-xdKDeqMEnYM2+vGySfb8pbcYyo/xMmgxG/ZhPCKaZEg=",
"aarch64-darwin": "sha256-fihCTrHIiUG+py4vuqdr+YshqSKm2/B5onY50b97sPM=",
"x86_64-darwin": "sha256-inlQQPNAOdkmKK6HQAMI2bG/ZFlfwmUQu9a6vm6Q0jQ="
"x86_64-linux": "sha256-AkI3guNjnE+bLZQVfzm0z14UENOECv2QBqMo5Lzkvt8=",
"aarch64-linux": "sha256-dBfdyVTqW+fBZKCxC9Ld+1m3cP+nIbS6UDo0tUfPOSk=",
"aarch64-darwin": "sha256-tOw31AMnHkW2cEDi+iqT3P93lU3SiMve26TEIqPz97k=",
"x86_64-darwin": "sha256-wL/DmdZmxCmh+r4dsS1XGXuj8VPwR4pUqy5VIA76jl0="
}
}

View File

@@ -14,13 +14,14 @@ import { GlobalSyncProvider } from "@/context/global-sync"
import { PermissionProvider } from "@/context/permission"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { ServerProvider, useServer } from "@/context/server"
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { LanguageProvider, useLanguage } from "@/context/language"
@@ -85,8 +86,19 @@ function ServerKey(props: ParentProps) {
}
export function AppInterface(props: { defaultUrl?: string }) {
const platform = usePlatform()
const stored = (() => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
})()
const defaultServerUrl = () => {
if (props.defaultUrl) return props.defaultUrl
if (stored) return stored
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
@@ -105,9 +117,11 @@ export function AppInterface(props: { defaultUrl?: string }) {
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
<ModelsProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>

View File

@@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
@@ -29,35 +29,34 @@ export function DialogEditProject(props: { project: LocalProject }) {
iconUrl: props.project.icon?.override || "",
startup: props.project.commands?.start ?? "",
saving: false,
dragOver: false,
iconHover: false,
})
const [dragOver, setDragOver] = createSignal(false)
const [iconHover, setIconHover] = createSignal(false)
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
setStore("iconUrl", e.target?.result as string)
setIconHover(false)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
}
function handleDrop(e: DragEvent) {
e.preventDefault()
setDragOver(false)
setStore("dragOver", false)
const file = e.dataTransfer?.files[0]
if (file) handleFileSelect(file)
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
setDragOver(true)
setStore("dragOver", true)
}
function handleDragLeave() {
setDragOver(false)
setStore("dragOver", false)
}
function handleInputChange(e: Event) {
@@ -116,19 +115,23 @@ export function DialogEditProject(props: { project: LocalProject }) {
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.icon")}</label>
<div class="flex gap-3 items-start">
<div class="relative" onMouseEnter={() => setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
<div
class="relative"
onMouseEnter={() => setStore("iconHover", true)}
onMouseLeave={() => setStore("iconHover", false)}
>
<div
class="relative size-16 rounded-md transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
"border-border-base hover:border-border-strong": !dragOver(),
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
"border-border-base hover:border-border-strong": !store.dragOver,
"overflow-hidden": !!store.iconUrl,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
if (store.iconUrl && iconHover()) {
if (store.iconUrl && store.iconHover) {
clearIcon()
} else {
document.getElementById("icon-upload")?.click()
@@ -166,7 +169,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
"border-radius": "6px",
"z-index": 10,
"pointer-events": "none",
opacity: iconHover() && !store.iconUrl ? 1 : 0,
opacity: store.iconHover && !store.iconUrl ? 1 : 0,
display: "flex",
"align-items": "center",
"justify-content": "center",
@@ -185,7 +188,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
"border-radius": "6px",
"z-index": 10,
"pointer-events": "none",
opacity: iconHover() && store.iconUrl ? 1 : 0,
opacity: store.iconHover && store.iconUrl ? 1 : 0,
display: "flex",
"align-items": "center",
"justify-content": "center",

View File

@@ -155,7 +155,7 @@ export function DialogSelectServer() {
},
{ initialValue: null },
)
const isDesktop = platform.platform === "desktop"
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
@@ -505,7 +505,7 @@ export function DialogSelectServer() {
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={isDesktop && defaultUrl() !== i}>
<Show when={canDefault() && defaultUrl() !== i}>
<DropdownMenu.Item
onSelect={async () => {
await platform.setDefaultServerUrl?.(i)
@@ -517,7 +517,7 @@ export function DialogSelectServer() {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={isDesktop && defaultUrl() === i}>
<Show when={canDefault() && defaultUrl() === i}>
<DropdownMenu.Item
onSelect={async () => {
await platform.setDefaultServerUrl?.(null)

View File

@@ -6,12 +6,8 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsPermissions } from "./settings-permissions"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsAgents } from "./settings-agents"
import { SettingsCommands } from "./settings-commands"
import { SettingsMcp } from "./settings-mcp"
export const DialogSettings: Component = () => {
const language = useLanguage()
@@ -45,6 +41,10 @@ export const DialogSettings: Component = () => {
<Icon name="server" />
{language.t("settings.providers.title")}
</Tabs.Trigger>
<Tabs.Trigger value="models">
<Icon name="server" />
{language.t("settings.models.title")}
</Tabs.Trigger>
</div>
</div>
</div>
@@ -64,9 +64,9 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders />
</Tabs.Content>
{/* <Tabs.Content value="models" class="no-scrollbar"> */}
{/* <SettingsModels /> */}
{/* </Tabs.Content> */}
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
{/* <SettingsAgents /> */}
{/* </Tabs.Content> */}

View File

@@ -1,111 +1,207 @@
import { useLocal, type LocalFile } from "@/context/local"
import { useFile } from "@/context/file"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
import {
createEffect,
createMemo,
For,
Match,
splitProps,
Switch,
untrack,
type ComponentProps,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
export default function FileTree(props: {
path: string
class?: string
nodeClass?: string
level?: number
onFileClick?: (file: LocalFile) => void
allowed?: readonly string[]
modified?: readonly string[]
draggable?: boolean
tooltip?: boolean
onFileClick?: (file: FileNode) => void
}) {
const local = useLocal()
const file = useFile()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const tooltip = () => props.tooltip ?? true
const Node = (p: ParentProps & ComponentProps<"div"> & { node: LocalFile; as?: "div" | "button" }) => (
<Dynamic
component={p.as ?? "div"}
classList={{
"p-0.5 w-full flex items-center gap-x-2 hover:bg-background-element": true,
// "bg-background-element": local.file.active()?.path === p.node.path,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
style={`padding-left: ${level * 10}px`}
draggable={true}
onDragStart={(e: any) => {
const evt = e as globalThis.DragEvent
evt.dataTransfer!.effectAllowed = "copy"
evt.dataTransfer!.setData("text/plain", `file:${p.node.path}`)
const filter = createMemo(() => {
const allowed = props.allowed
if (!allowed) return
// Create custom drag image without margins
const dragImage = document.createElement("div")
dragImage.className =
"flex items-center gap-x-2 px-2 py-1 bg-background-element rounded-md border border-border-1"
dragImage.style.position = "absolute"
dragImage.style.top = "-1000px"
const files = new Set(allowed)
const dirs = new Set<string>()
// Copy only the icon and text content without padding
const icon = e.currentTarget.querySelector("svg")
const text = e.currentTarget.querySelector("span")
if (icon && text) {
dragImage.innerHTML = icon.outerHTML + text.outerHTML
}
for (const item of allowed) {
const parts = item.split("/")
const parents = parts.slice(0, -1)
for (const [idx] of parents.entries()) {
const dir = parents.slice(0, idx + 1).join("/")
if (dir) dirs.add(dir)
}
}
document.body.appendChild(dragImage)
evt.dataTransfer!.setDragImage(dragImage, 0, 12)
setTimeout(() => document.body.removeChild(dragImage), 0)
}}
{...p}
>
{p.children}
<span
return { files, dirs }
})
const marks = createMemo(() => {
const modified = props.modified
if (!modified || modified.length === 0) return
return new Set(modified)
})
createEffect(() => {
const current = filter()
if (!current) return
if (level !== 0) return
for (const dir of current.dirs) {
const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false
if (expanded) continue
file.tree.expand(dir)
}
})
createEffect(() => {
void file.tree.list(props.path)
})
const nodes = createMemo(() => {
const nodes = file.tree.children(props.path)
const current = filter()
if (!current) return nodes
return nodes.filter((node) => {
if (node.type === "file") return current.files.has(node.path)
return current.dirs.has(node.path)
})
})
const Node = (
p: ParentProps &
ComponentProps<"div"> &
ComponentProps<"button"> & {
node: FileNode
as?: "div" | "button"
},
) => {
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
return (
<Dynamic
component={local.as ?? "div"}
classList={{
"text-xs whitespace-nowrap truncate": true,
"text-text-muted/40": p.node.ignored,
"text-text-muted/80": !p.node.ignored,
// "!text-text": local.file.active()?.path === p.node.path,
// "!text-primary": local.file.changed(p.node.path),
"w-full min-w-0 flex items-center justify-start gap-x-2 rounded-md px-2 py-1 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
style={`padding-left: ${8 + level * 12}px`}
draggable={draggable()}
onDragStart={(e: DragEvent) => {
if (!draggable()) return
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
const dragImage = document.createElement("div")
dragImage.className =
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
dragImage.style.position = "absolute"
dragImage.style.top = "-1000px"
const icon =
(e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
(e.currentTarget as HTMLElement).querySelector("svg")
const text = (e.currentTarget as HTMLElement).querySelector("span")
if (icon && text) {
dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
}
document.body.appendChild(dragImage)
e.dataTransfer?.setDragImage(dragImage, 0, 12)
setTimeout(() => document.body.removeChild(dragImage), 0)
}}
{...rest}
>
{p.node.name}
</span>
{/* <Show when={local.file.changed(p.node.path)}> */}
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
{/* </Show> */}
</Dynamic>
)
{local.children}
<span
classList={{
"flex-1 min-w-0 text-12-regular whitespace-nowrap truncate": true,
"text-text-weaker": local.node.ignored,
"text-text-weak": !local.node.ignored,
}}
>
{local.node.name}
</span>
{local.node.type === "file" && marks()?.has(local.node.path) ? (
<div class="shrink-0 size-1.5 rounded-full bg-surface-warning-strong" />
) : null}
</Dynamic>
)
}
return (
<div class={`flex flex-col ${props.class}`}>
<For each={local.file.children(props.path)}>
{(node) => (
<Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right">
<div class={`flex flex-col ${props.class ?? ""}`}>
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
const Wrapper = (p: ParentProps) => {
if (!tooltip()) return p.children
return (
<Tooltip forceMount={false} openDelay={2000} value={node.path} placement="right" class="w-full">
{p.children}
</Tooltip>
)
}
return (
<Switch>
<Match when={node.type === "directory"}>
<Collapsible
variant="ghost"
class="w-full"
forceMount={false}
// open={local.file.node(node.path)?.expanded}
onOpenChange={(open) => (open ? local.file.expand(node.path) : local.file.collapse(node.path))}
open={expanded()}
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
>
<Collapsible.Trigger>
<Node node={node}>
<Collapsible.Arrow class="text-text-muted/60 ml-1" />
<FileIcon
node={node}
// expanded={local.file.node(node.path).expanded}
class="text-text-muted/60 -ml-1"
/>
</Node>
<Wrapper>
<Node node={node}>
<Collapsible.Arrow class="text-icon-weak ml-1" />
<FileIcon node={node} expanded={expanded()} class="text-icon-weak -ml-1 size-4" />
</Node>
</Wrapper>
</Collapsible.Trigger>
<Collapsible.Content>
<FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
<FileTree
path={node.path}
level={level + 1}
allowed={props.allowed}
modified={props.modified}
draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick}
/>
</Collapsible.Content>
</Collapsible>
</Match>
<Match when={node.type === "file"}>
<Node node={node} as="button" onClick={() => props.onFileClick?.(node)}>
<div class="w-4 shrink-0" />
<FileIcon node={node} class="text-primary" />
</Node>
<Wrapper>
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
<div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" />
</Node>
</Wrapper>
</Match>
</Switch>
</Tooltip>
)}
)
}}
</For>
</div>
)

View File

@@ -131,7 +131,7 @@ export function SessionHeader() {
<Portal mount={mount()}>
<button
type="button"
class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
@@ -280,6 +280,32 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
</div>
<div class="hidden md:block shrink-0">
<Tooltip value="Toggle file tree" placement="bottom">
<Button
variant="ghost"
class="group/file-tree-toggle size-5 p-0"
onClick={() => {
const opening = !layout.fileTree.opened()
if (opening && !view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.toggle()
}}
aria-label="Toggle file tree"
aria-expanded={layout.fileTree.opened()}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name="bullet-list"
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</Tooltip>
</div>
<div class="hidden md:block shrink-0">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button

View File

@@ -1,5 +1,6 @@
import type { JSX } from "solid-js"
import { createSignal, Show } from "solid-js"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
@@ -12,11 +13,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
const terminal = useTerminal()
const language = useLanguage()
const sortable = createSortable(props.terminal.id)
const [editing, setEditing] = createSignal(false)
const [title, setTitle] = createSignal(props.terminal.title)
const [menuOpen, setMenuOpen] = createSignal(false)
const [menuPosition, setMenuPosition] = createSignal({ x: 0, y: 0 })
const [blurEnabled, setBlurEnabled] = createSignal(false)
const [store, setStore] = createStore({
editing: false,
title: props.terminal.title,
menuOpen: false,
menuPosition: { x: 0, y: 0 },
blurEnabled: false,
})
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
@@ -47,7 +50,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
}
const focus = () => {
if (editing()) return
if (store.editing) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
@@ -71,26 +74,26 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
e.preventDefault()
}
setBlurEnabled(false)
setTitle(props.terminal.title)
setEditing(true)
setStore("blurEnabled", false)
setStore("title", props.terminal.title)
setStore("editing", true)
setTimeout(() => {
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
if (!input) return
input.focus()
input.select()
setTimeout(() => setBlurEnabled(true), 100)
setTimeout(() => setStore("blurEnabled", true), 100)
}, 10)
}
const save = () => {
if (!blurEnabled()) return
if (!store.blurEnabled) return
const value = title().trim()
const value = store.title.trim()
if (value && value !== props.terminal.title) {
terminal.update({ id: props.terminal.id, title: value })
}
setEditing(false)
setStore("editing", false)
}
const keydown = (e: KeyboardEvent) => {
@@ -101,14 +104,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
}
if (e.key === "Escape") {
e.preventDefault()
setEditing(false)
setStore("editing", false)
}
}
const menu = (e: MouseEvent) => {
e.preventDefault()
setMenuPosition({ x: e.clientX, y: e.clientY })
setMenuOpen(true)
setStore("menuPosition", { x: e.clientX, y: e.clientY })
setStore("menuOpen", true)
}
return (
@@ -143,17 +146,17 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
/>
}
>
<span onDblClick={edit} style={{ visibility: editing() ? "hidden" : "visible" }}>
<span onDblClick={edit} style={{ visibility: store.editing ? "hidden" : "visible" }}>
{label()}
</span>
</Tabs.Trigger>
<Show when={editing()}>
<Show when={store.editing}>
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
<input
id={`terminal-title-input-${props.terminal.id}`}
type="text"
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
value={store.title}
onInput={(e) => setStore("title", e.currentTarget.value)}
onBlur={save}
onKeyDown={keydown}
onMouseDown={(e) => e.stopPropagation()}
@@ -161,13 +164,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
/>
</div>
</Show>
<DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
<DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{
position: "fixed",
left: `${menuPosition().x}px`,
top: `${menuPosition().y}px`,
left: `${store.menuPosition.x}px`,
top: `${store.menuPosition.y}px`,
}}
>
<DropdownMenu.Item onSelect={edit}>

View File

@@ -1,4 +1,5 @@
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -111,24 +112,26 @@ export const SettingsKeybinds: Component = () => {
const language = useLanguage()
const settings = useSettings()
const [active, setActive] = createSignal<string | null>(null)
const [filter, setFilter] = createSignal("")
const [store, setStore] = createStore({
active: null as string | null,
filter: "",
})
const stop = () => {
if (!active()) return
setActive(null)
if (!store.active) return
setStore("active", null)
command.keybinds(true)
}
const start = (id: string) => {
if (active() === id) {
if (store.active === id) {
stop()
return
}
if (active()) stop()
if (store.active) stop()
setActive(id)
setStore("active", id)
command.keybinds(false)
}
@@ -203,7 +206,7 @@ export const SettingsKeybinds: Component = () => {
})
const filtered = createMemo(() => {
const query = filter().toLowerCase().trim()
const query = store.filter.toLowerCase().trim()
if (!query) return grouped()
const map = list()
@@ -285,7 +288,7 @@ export const SettingsKeybinds: Component = () => {
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = active()
const id = store.active
if (!id) return
event.preventDefault()
@@ -345,7 +348,7 @@ export const SettingsKeybinds: Component = () => {
})
onCleanup(() => {
if (active()) command.keybinds(true)
if (store.active) command.keybinds(true)
})
return (
@@ -370,8 +373,8 @@ export const SettingsKeybinds: Component = () => {
<TextField
variant="ghost"
type="text"
value={filter()}
onChange={setFilter}
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
@@ -379,8 +382,8 @@ export const SettingsKeybinds: Component = () => {
autocapitalize="off"
class="flex-1"
/>
<Show when={filter()}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
@@ -402,13 +405,13 @@ export const SettingsKeybinds: Component = () => {
classList={{
"h-8 px-3 rounded-md text-12-regular": true,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
active() !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id,
store.active !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id,
}}
onClick={() => start(id)}
>
<Show
when={active() === id}
when={store.active === id}
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
@@ -423,11 +426,11 @@ export const SettingsKeybinds: Component = () => {
)}
</For>
<Show when={filter() && !hasResults()}>
<Show when={store.filter && !hasResults()}>
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{language.t("settings.shortcuts.search.empty")}</span>
<Show when={filter()}>
<span class="text-14-regular text-text-strong mt-1">"{filter()}"</span>
<Show when={store.filter}>
<span class="text-14-regular text-text-strong mt-1">"{store.filter}"</span>
</Show>
</div>
</Show>

View File

@@ -1,14 +1,135 @@
import { Component } from "solid-js"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models"
import { popularProviders } from "@/hooks/use-providers"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
export const SettingsModels: Component = () => {
const language = useLanguage()
const models = useModels()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aIndex = popularProviders.indexOf(a.category)
const bIndex = popularProviders.indexOf(b.category)
const aPopular = aIndex >= 0
const bPopular = bIndex >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
if (aPopular && bPopular) return aIndex - bIndex
const aName = a.items[0].provider.name
const bName = b.items[0].provider.name
return aName.localeCompare(bName)
},
})
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.models.description")}</p>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
<div
class="sticky top-0 z-10"
style={{
background:
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
}}
>
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={list.filter()}
onChange={list.onInput}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={list.filter()}>
<IconButton icon="circle-x" variant="ghost" onClick={list.clear} />
</Show>
</div>
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<Show
when={!list.grouped.loading}
fallback={
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</span>
</div>
}
>
<Show
when={list.flat().length > 0}
fallback={
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{language.t("dialog.model.empty")}</span>
<Show when={list.filter()}>
<span class="text-14-regular text-text-strong mt-1">&quot;{list.filter()}&quot;</span>
</Show>
</div>
}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 pb-2">
<ProviderIcon id={group.category as IconName} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
</div>
<div class="bg-surface-raised-base px-4 rounded-lg">
<For each={group.items}>
{(item) => {
const key = { providerID: item.provider.id, modelID: item.id }
return (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="min-w-0">
<span class="text-14-regular text-text-strong truncate block">{item.name}</span>
</div>
<div class="flex-shrink-0">
<Switch
checked={models.visible(key)}
onChange={(checked) => {
models.setVisibility(key, checked)
}}
hideLabel
>
{item.name}
</Switch>
</div>
</div>
)
}}
</For>
</div>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
)

View File

@@ -39,9 +39,10 @@ export function StatusPopover() {
const language = useLanguage()
const navigate = useNavigate()
const [loading, setLoading] = createSignal<string | null>(null)
const [store, setStore] = createStore({
status: {} as Record<string, ServerStatus | undefined>,
loading: null as string | null,
defaultServerUrl: undefined as string | undefined,
})
const servers = createMemo(() => {
@@ -97,8 +98,8 @@ export function StatusPopover() {
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
const toggleMcp = async (name: string) => {
if (loading()) return
setLoading(name)
if (store.loading) return
setStore("loading", name)
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
@@ -107,7 +108,7 @@ export function StatusPopover() {
}
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
setLoading(null)
setStore("loading", null)
}
const lspItems = createMemo(() => sync.data.lsp ?? [])
@@ -123,15 +124,21 @@ export function StatusPopover() {
const serverCount = createMemo(() => sortedServers().length)
const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>()
createEffect(() => {
const refreshDefaultServerUrl = () => {
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) {
result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined))
if (!result) {
setStore("defaultServerUrl", undefined)
return
}
if (result) setDefaultServerUrl(normalizeServerUrl(result))
if (result instanceof Promise) {
result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
return
}
setStore("defaultServerUrl", normalizeServerUrl(result))
}
createEffect(() => {
refreshDefaultServerUrl()
})
return (
@@ -212,7 +219,7 @@ export function StatusPopover() {
<For each={sortedServers()}>
{(url) => {
const isActive = () => url === server.url
const isDefault = () => url === defaultServerUrl()
const isDefault = () => url === store.defaultServerUrl
const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false
const [truncated, setTruncated] = createSignal(false)
@@ -294,7 +301,7 @@ export function StatusPopover() {
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />)}
onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)}
>
{language.t("status.popover.action.manageServers")}
</Button>
@@ -321,7 +328,7 @@ export function StatusPopover() {
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => toggleMcp(item.name)}
disabled={loading() === item.name}
disabled={store.loading === item.name}
>
<div
classList={{
@@ -337,7 +344,7 @@ export function StatusPopover() {
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={loading() === item.name}
disabled={store.loading === item.name}
onChange={() => toggleMcp(item.name)}
/>
</div>

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -24,6 +24,15 @@ function normalizeKey(key: string) {
return key.toLowerCase()
}
function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
return `${key}:${mask}`
}
function signatureFromEvent(event: KeyboardEvent) {
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
}
export type KeybindConfig = string
export interface Keybind {
@@ -156,8 +165,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const dialog = useDialog()
const settings = useSettings()
const language = useLanguage()
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const [store, setStore] = createStore({
registrations: [] as Accessor<CommandOption[]>[],
suspendCount: 0,
})
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
@@ -175,7 +186,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const seen = new Set<string>()
const all: CommandOption[] = []
for (const reg of registrations()) {
for (const reg of store.registrations) {
for (const opt of reg()) {
if (seen.has(opt.id)) continue
seen.add(opt.id)
@@ -221,7 +232,31 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
]
})
const suspended = () => suspendCount() > 0
const suspended = () => store.suspendCount > 0
const palette = createMemo(() => {
const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
const keybinds = parseKeybind(config)
return new Set(keybinds.map((kb) => signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)))
})
const keymap = createMemo(() => {
const map = new Map<string, CommandOption>()
for (const option of options()) {
if (option.id.startsWith(SUGGESTED_PREFIX)) continue
if (option.disabled) continue
if (!option.keybind) continue
const keybinds = parseKeybind(option.keybind)
for (const kb of keybinds) {
if (!kb.key) continue
const sig = signature(kb.key, kb.ctrl, kb.meta, kb.shift, kb.alt)
if (map.has(sig)) continue
map.set(sig, option)
}
}
return map
})
const run = (id: string, source?: "palette" | "keybind" | "slash") => {
for (const option of options()) {
@@ -239,24 +274,18 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended() || dialog.active) return
const paletteKeybinds = parseKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
if (matchKeybind(paletteKeybinds, event)) {
const sig = signatureFromEvent(event)
if (palette().has(sig)) {
event.preventDefault()
showPalette()
return
}
for (const option of options()) {
if (option.disabled) continue
if (!option.keybind) continue
const keybinds = parseKeybind(option.keybind)
if (matchKeybind(keybinds, event)) {
event.preventDefault()
option.onSelect?.("keybind")
return
}
}
const option = keymap().get(sig)
if (!option) return
event.preventDefault()
option.onSelect?.("keybind")
}
onMount(() => {
@@ -270,9 +299,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return {
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
setRegistrations((arr) => [results, ...arr])
setStore("registrations", (arr) => [results, ...arr])
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results))
setStore("registrations", (arr) => arr.filter((x) => x !== results))
})
},
trigger(id: string, source?: "palette" | "keybind" | "slash") {
@@ -294,7 +323,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
},
show: showPalette,
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
},
suspended,
get catalog() {

View File

@@ -1,4 +1,4 @@
import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
@@ -37,8 +37,16 @@ function createCommentSession(dir: string, id: string | undefined) {
}),
)
const [focus, setFocus] = createSignal<CommentFocus | null>(null)
const [active, setActive] = createSignal<CommentFocus | null>(null)
const [state, setState] = createStore({
focus: null as CommentFocus | null,
active: null as CommentFocus | null,
})
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
setState("focus", value)
const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
setState("active", value)
const list = (file: string) => store.comments[file] ?? []
@@ -74,10 +82,10 @@ function createCommentSession(dir: string, id: string | undefined) {
all,
add,
remove,
focus: createMemo(() => focus()),
focus: createMemo(() => state.focus),
setFocus,
clearFocus: () => setFocus(null),
active: createMemo(() => active()),
active: createMemo(() => state.active),
setActive,
clearActive: () => setActive(null),
}

View File

@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2"
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
@@ -39,6 +39,14 @@ export type FileState = {
content?: FileContent
}
type DirectoryState = {
expanded: boolean
loaded?: boolean
loading?: boolean
error?: string
children?: string[]
}
function stripFileProtocol(input: string) {
if (!input.startsWith("file://")) return input
return input.slice("file://".length)
@@ -57,6 +65,62 @@ function stripQueryAndHash(input: string) {
return input
}
function unquoteGitPath(input: string) {
if (!input.startsWith('"')) return input
if (!input.endsWith('"')) return input
const body = input.slice(1, -1)
const bytes: number[] = []
for (let i = 0; i < body.length; i++) {
const char = body[i]!
if (char !== "\\") {
bytes.push(char.charCodeAt(0))
continue
}
const next = body[i + 1]
if (!next) {
bytes.push("\\".charCodeAt(0))
continue
}
if (next >= "0" && next <= "7") {
const chunk = body.slice(i + 1, i + 4)
const match = chunk.match(/^[0-7]{1,3}/)
if (!match) {
bytes.push(next.charCodeAt(0))
i++
continue
}
bytes.push(parseInt(match[0], 8))
i += match[0].length
continue
}
const escaped =
next === "n"
? "\n"
: next === "r"
? "\r"
: next === "t"
? "\t"
: next === "b"
? "\b"
: next === "f"
? "\f"
: next === "v"
? "\v"
: next === "\\" || next === '"'
? next
: undefined
bytes.push((escaped ?? next).charCodeAt(0))
i++
}
return new TextDecoder().decode(new Uint8Array(bytes))
}
export function selectionFromLines(range: SelectedLineRange): FileSelection {
const startLine = Math.min(range.start, range.end)
const endLine = Math.max(range.start, range.end)
@@ -197,7 +261,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const root = directory()
const prefix = root.endsWith("/") ? root : root + "/"
let path = stripQueryAndHash(stripFileProtocol(input))
let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
if (path.startsWith(prefix)) {
path = path.slice(prefix.length)
@@ -229,6 +293,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}
const inflight = new Map<string, Promise<void>>()
const treeInflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{
file: Record<string, FileState>
@@ -236,10 +301,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {},
})
const [tree, setTree] = createStore<{
node: Record<string, FileNode>
dir: Record<string, DirectoryState>
}>({
node: {},
dir: { "": { expanded: true } },
})
createEffect(() => {
scope()
inflight.clear()
treeInflight.clear()
setStore("file", {})
setTree("node", {})
setTree("dir", { "": { expanded: true } })
})
const viewCache = new Map<string, ViewCacheEntry>()
@@ -351,14 +427,156 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise
}
function normalizeDir(input: string) {
return normalize(input).replace(/\/+$/, "")
}
function ensureDir(path: string) {
if (tree.dir[path]) return
setTree("dir", path, { expanded: false })
}
function listDir(input: string, options?: { force?: boolean }) {
const dir = normalizeDir(input)
ensureDir(dir)
const current = tree.dir[dir]
if (!options?.force && current?.loaded) return Promise.resolve()
const pending = treeInflight.get(dir)
if (pending) return pending
setTree(
"dir",
dir,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
const directory = scope()
const promise = sdk.client.file
.list({ path: dir })
.then((x) => {
if (scope() !== directory) return
const nodes = x.data ?? []
const prevChildren = tree.dir[dir]?.children ?? []
const nextChildren = nodes.map((node) => node.path)
const nextSet = new Set(nextChildren)
setTree(
"node",
produce((draft) => {
const removedDirs: string[] = []
for (const child of prevChildren) {
if (nextSet.has(child)) continue
const existing = draft[child]
if (existing?.type === "directory") removedDirs.push(child)
delete draft[child]
}
if (removedDirs.length > 0) {
const keys = Object.keys(draft)
for (const key of keys) {
for (const removed of removedDirs) {
if (!key.startsWith(removed + "/")) continue
delete draft[key]
break
}
}
}
for (const node of nodes) {
draft[node.path] = node
}
}),
)
setTree(
"dir",
dir,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.children = nextChildren
}),
)
})
.catch((e) => {
if (scope() !== directory) return
setTree(
"dir",
dir,
produce((draft) => {
draft.loading = false
draft.error = e.message
}),
)
showToast({
variant: "error",
title: "Failed to list files",
description: e.message,
})
})
.finally(() => {
treeInflight.delete(dir)
})
treeInflight.set(dir, promise)
return promise
}
function expandDir(input: string) {
const dir = normalizeDir(input)
ensureDir(dir)
setTree("dir", dir, "expanded", true)
void listDir(dir)
}
function collapseDir(input: string) {
const dir = normalizeDir(input)
ensureDir(dir)
setTree("dir", dir, "expanded", false)
}
function dirState(input: string) {
const dir = normalizeDir(input)
return tree.dir[dir]
}
function children(input: string) {
const dir = normalizeDir(input)
const ids = tree.dir[dir]?.children
if (!ids) return []
const out: FileNode[] = []
for (const id of ids) {
const node = tree.node[id]
if (node) out.push(node)
}
return out
}
const stop = sdk.event.listen((e) => {
const event = e.details
if (event.type !== "file.watcher.updated") return
const path = normalize(event.properties.file)
if (!path) return
if (path.startsWith(".git/")) return
if (!store.file[path]) return
load(path, { force: true })
if (store.file[path]) {
load(path, { force: true })
}
const kind = event.properties.event
if (kind !== "add" && kind !== "unlink") return
const parent = path.split("/").slice(0, -1).join("/")
if (!tree.dir[parent]?.loaded) return
listDir(parent, { force: true })
})
const get = (input: string) => store.file[normalize(input)]
@@ -392,6 +610,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
normalize,
tab,
pathFromTab,
tree: {
list: listDir,
refresh: (input: string) => listDir(input, { force: true }),
state: dirState,
children,
expand: expandDir,
collapse: collapseDir,
toggle(input: string) {
if (dirState(input)?.expanded) {
collapseDir(input)
return
}
expandDir(input)
},
},
get,
load,
scrollTop,

View File

@@ -82,6 +82,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
diffStyle: "split" as ReviewDiffStyle,
panelOpened: true,
},
fileTree: {
opened: false,
width: 260,
},
session: {
width: 600,
},
@@ -218,7 +222,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = globalSync.child(project.worktree)
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? globalSync.data.project.find((x) => x.id === projectID)
@@ -449,6 +453,38 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "diffStyle", diffStyle)
},
},
fileTree: {
opened: createMemo(() => store.fileTree?.opened ?? false),
width: createMemo(() => store.fileTree?.width ?? 260),
open() {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: 260 })
return
}
setStore("fileTree", "opened", true)
},
close() {
if (!store.fileTree) {
setStore("fileTree", { opened: false, width: 260 })
return
}
setStore("fileTree", "opened", false)
},
toggle() {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: 260 })
return
}
setStore("fileTree", "opened", (x) => !x)
},
resize(width: number) {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width })
return
}
setStore("fileTree", "width", width)
},
},
session: {
width: createMemo(() => store.session?.width ?? 600),
resize(width: number) {

View File

@@ -1,14 +1,12 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
import { Persist, persisted } from "@/utils/persist"
import { useModels } from "@/context/models"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
@@ -112,18 +110,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})()
const model = (() => {
const [store, setStore, _, modelReady] = persisted(
Persist.global("model", ["model.v1"]),
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
variant?: Record<string, string | undefined>
}>({
user: [],
recent: [],
variant: {},
}),
)
const models = useModels()
const [ephemeral, setEphemeral] = createStore<{
model: Record<string, ModelKey | undefined>
@@ -131,57 +118,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model: {},
})
const available = createMemo(() =>
providers.connected().flatMap((p) =>
Object.values(p.models).map((m) => ({
...m,
provider: p,
})),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
models,
groupBy((x) => x.family),
values(),
(groups) =>
groups.flatMap((g) => {
const first = firstBy(g, [(x) => x.release_date, "desc"])
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
}),
),
),
values(),
flat(),
),
)
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
const userVisibilityMap = createMemo(() => {
const map = new Map<string, "show" | "hide">()
for (const item of store.user) {
map.set(`${item.providerID}:${item.modelID}`, item.visibility)
}
return map
})
const list = createMemo(() =>
available().map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
latest: m.name.includes("(latest)"),
})),
)
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
const fallbackModel = createMemo<ModelKey | undefined>(() => {
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
@@ -193,7 +129,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
for (const item of store.recent) {
for (const item of models.recent.list()) {
if (isModelValid(item)) {
return item
}
@@ -225,10 +161,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
fallbackModel,
)
if (!key) return undefined
return find(key)
return models.find(key)
})
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const cycle = (direction: 1 | -1) => {
const recentList = recent()
@@ -253,54 +189,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
}
function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
setStore("user", index, { visibility })
} else {
setStore("user", store.user.length, { ...model, visibility })
}
}
return {
ready: modelReady,
ready: models.ready,
current,
recent,
list,
list: models.list,
cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
const currentAgent = agent.current()
const next = model ?? fallbackModel()
if (currentAgent) setEphemeral("model", currentAgent.name, next)
if (model) updateVisibility(model, "show")
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setStore("recent", uniq)
}
if (model) models.setVisibility(model, true)
if (options?.recent && model) models.recent.push(model)
})
},
visible(model: ModelKey) {
const key = `${model.providerID}:${model.modelID}`
const visibility = userVisibilityMap().get(key)
if (visibility === "hide") return false
if (visibility === "show") return true
if (latestSet().has(key)) return true
// For models without valid release_date (e.g. custom models), show by default
const m = find(model)
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
return false
return models.visible(model)
},
setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide")
models.setVisibility(model, visible)
},
variant: {
current() {
const m = current()
if (!m) return undefined
const key = `${m.provider.id}/${m.id}`
return store.variant?.[key]
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
},
list() {
const m = current()
@@ -311,12 +225,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
set(value: string | undefined) {
const m = current()
if (!m) return
const key = `${m.provider.id}/${m.id}`
if (!store.variant) {
setStore("variant", { [key]: value })
} else {
setStore("variant", key, value)
}
models.variant.set({ providerID: m.provider.id, modelID: m.id }, value)
},
cycle() {
const variants = this.list()

View File

@@ -0,0 +1,140 @@
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist"
export type ModelKey = { providerID: string; modelID: string }
type Visibility = "show" | "hide"
type User = ModelKey & { visibility: Visibility; favorite?: boolean }
type Store = {
user: User[]
recent: ModelKey[]
variant?: Record<string, string | undefined>
}
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models",
init: () => {
const providers = useProviders()
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),
createStore<Store>({
user: [],
recent: [],
variant: {},
}),
)
const available = createMemo(() =>
providers.connected().flatMap((p) =>
Object.values(p.models).map((m) => ({
...m,
provider: p,
})),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
models,
groupBy((x) => x.family),
values(),
(groups) =>
groups.flatMap((g) => {
const first = firstBy(g, [(x) => x.release_date, "desc"])
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
}),
),
),
values(),
flat(),
),
)
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
const visibility = createMemo(() => {
const map = new Map<string, Visibility>()
for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
return map
})
const list = createMemo(() =>
available().map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
latest: m.name.includes("(latest)"),
})),
)
const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
function update(model: ModelKey, state: Visibility) {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
setStore("user", index, { visibility: state })
return
}
setStore("user", store.user.length, { ...model, visibility: state })
}
const visible = (model: ModelKey) => {
const key = `${model.providerID}:${model.modelID}`
const state = visibility().get(key)
if (state === "hide") return false
if (state === "show") return true
if (latestSet().has(key)) return true
const m = find(model)
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
return false
}
const setVisibility = (model: ModelKey, state: boolean) => {
update(model, state ? "show" : "hide")
}
const push = (model: ModelKey) => {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setStore("recent", uniq)
}
const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
setStore("variant", { [key]: value })
return
}
setStore("variant", key, value)
}
return {
ready,
list,
find,
visible,
setVisibility,
recent: {
list: createMemo(() => store.recent),
push,
},
variant: {
get: getVariant,
set: setVariant,
},
}
},
})

View File

@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createEffect, onCleanup } from "solid-js"
import { createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
@@ -52,6 +52,15 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const settings = useSettings()
const language = useLanguage()
const empty: Notification[] = []
const currentDirectory = createMemo(() => {
if (!params.dir) return
return base64Decode(params.dir)
})
const currentSession = createMemo(() => params.id)
const [store, setStore, _, ready] = persisted(
Persist.global("notification", ["notification.v1"]),
createStore({
@@ -72,13 +81,59 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
setStore("list", (list) => pruneNotifications([...list, notification]))
}
const index = createMemo(() => {
const sessionAll = new Map<string, Notification[]>()
const sessionUnseen = new Map<string, Notification[]>()
const projectAll = new Map<string, Notification[]>()
const projectUnseen = new Map<string, Notification[]>()
for (const notification of store.list) {
const session = notification.session
if (session) {
const list = sessionAll.get(session)
if (list) list.push(notification)
else sessionAll.set(session, [notification])
if (!notification.viewed) {
const unseen = sessionUnseen.get(session)
if (unseen) unseen.push(notification)
else sessionUnseen.set(session, [notification])
}
}
const directory = notification.directory
if (directory) {
const list = projectAll.get(directory)
if (list) list.push(notification)
else projectAll.set(directory, [notification])
if (!notification.viewed) {
const unseen = projectUnseen.get(directory)
if (unseen) unseen.push(notification)
else projectUnseen.set(directory, [notification])
}
}
}
return {
session: {
all: sessionAll,
unseen: sessionUnseen,
},
project: {
all: projectAll,
unseen: projectUnseen,
},
}
})
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
const activeDirectory = params.dir ? base64Decode(params.dir) : undefined
const activeSession = params.id
const viewed = (sessionID?: string) => {
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeDirectory) return false
if (!activeSession) return false
if (!sessionID) return false
@@ -88,7 +143,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
switch (event.type) {
case "session.idle": {
const sessionID = event.properties.sessionID
const [syncStore] = globalSync.child(directory)
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
const session = match.found ? syncStore.session[match.index] : undefined
if (session?.parentID) break
@@ -115,7 +170,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
}
case "session.error": {
const sessionID = event.properties.sessionID
const [syncStore] = globalSync.child(directory)
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
if (session?.parentID) break
@@ -148,10 +203,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
ready,
session: {
all(session: string) {
return store.list.filter((n) => n.session === session)
return index().session.all.get(session) ?? empty
},
unseen(session: string) {
return store.list.filter((n) => n.session === session && !n.viewed)
return index().session.unseen.get(session) ?? empty
},
markViewed(session: string) {
setStore("list", (n) => n.session === session, "viewed", true)
@@ -159,10 +214,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
},
project: {
all(directory: string) {
return store.list.filter((n) => n.directory === directory)
return index().project.all.get(directory) ?? empty
},
unseen(directory: string) {
return store.list.filter((n) => n.directory === directory && !n.viewed)
return index().project.unseen.get(directory) ?? empty
},
markViewed(directory: string) {
setStore("list", (n) => n.directory === directory, "viewed", true)

View File

@@ -41,11 +41,11 @@ export type Platform = {
/** Fetch override */
fetch?: typeof fetch
/** Get the configured default server URL (desktop only) */
getDefaultServerUrl?(): Promise<string | null>
/** Get the configured default server URL (platform-specific) */
getDefaultServerUrl?(): Promise<string | null> | string | null
/** Set the default server URL to use on app startup (desktop only) */
setDefaultServerUrl?(url: string | null): Promise<void>
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string>

View File

@@ -1,6 +1,6 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
@@ -40,12 +40,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}),
)
const [active, setActiveRaw] = createSignal("")
const [state, setState] = createStore({
active: "",
healthy: undefined as boolean | undefined,
})
const healthy = () => state.healthy
function setActive(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
setActiveRaw(url)
setState("active", url)
}
function add(input: string) {
@@ -54,7 +59,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const fallback = normalizeServerUrl(props.defaultUrl)
if (fallback && url === fallback) {
setActiveRaw(url)
setState("active", url)
return
}
@@ -62,7 +67,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
setActiveRaw(url)
setState("active", url)
})
}
@@ -71,25 +76,23 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (!url) return
const list = store.list.filter((x) => x !== url)
const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active()
const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
batch(() => {
setStore("list", list)
setActiveRaw(next)
setState("active", next)
})
}
createEffect(() => {
if (!ready()) return
if (active()) return
if (state.active) return
const url = normalizeServerUrl(props.defaultUrl)
if (!url) return
setActiveRaw(url)
setState("active", url)
})
const isReady = createMemo(() => ready() && !!active())
const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
const isReady = createMemo(() => ready() && !!state.active)
const check = (url: string) => {
const sdk = createOpencodeClient({
@@ -104,10 +107,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
createEffect(() => {
const url = active()
const url = state.active
if (!url) return
setHealthy(undefined)
setState("healthy", undefined)
let alive = true
let busy = false
@@ -118,7 +121,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
void check(url)
.then((next) => {
if (!alive) return
setHealthy(next)
setState("healthy", next)
})
.finally(() => {
busy = false
@@ -134,7 +137,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
})
})
const origin = createMemo(() => projectsKey(active()))
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const isLocal = createMemo(() => origin() === "local")
@@ -143,10 +146,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
healthy,
isLocal,
get url() {
return active()
return state.active
},
get name() {
return serverDisplayName(active())
return serverDisplayName(state.active)
},
get list() {
return store.list

View File

@@ -6,6 +6,8 @@ import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import pkg from "../package.json"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
const locale = (() => {
@@ -62,6 +64,26 @@ const platform: Platform = {
})
.catch(() => undefined)
},
getDefaultServerUrl: () => {
if (typeof localStorage === "undefined") return null
try {
return localStorage.getItem(DEFAULT_SERVER_URL_KEY)
} catch {
return null
}
},
setDefaultServerUrl: (url) => {
if (typeof localStorage === "undefined") return
try {
if (url) {
localStorage.setItem(DEFAULT_SERVER_URL_KEY, url)
return
}
localStorage.removeItem(DEFAULT_SERVER_URL_KEY)
} catch {
return
}
},
}
render(

View File

@@ -23,6 +23,11 @@ export default function Home() {
const server = useServer()
const language = useLanguage()
const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => {
return sync.data.project
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)
})
function openProject(directory: string) {
layout.projects.open(directory)
@@ -84,11 +89,7 @@ export default function Home() {
</Button>
</div>
<ul class="flex flex-col gap-2">
<For
each={sync.data.project
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)}
>
<For each={recent()}>
{(project) => (
<Button
size="large"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { createSignal, onCleanup } from "solid-js"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
// Minimal types to avoid relying on non-standard DOM typings
type RecognitionResult = {
@@ -59,9 +60,15 @@ export function createSpeechRecognition(opts?: {
typeof window !== "undefined" &&
Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
const [isRecording, setIsRecording] = createSignal(false)
const [committed, setCommitted] = createSignal("")
const [interim, setInterim] = createSignal("")
const [store, setStore] = createStore({
isRecording: false,
committed: "",
interim: "",
})
const isRecording = () => store.isRecording
const committed = () => store.committed
const interim = () => store.interim
let recognition: Recognition | undefined
let shouldContinue = false
@@ -82,7 +89,7 @@ export function createSpeechRecognition(opts?: {
const nextCommitted = appendSegment(committedText, segment)
if (nextCommitted === committedText) return
committedText = nextCommitted
setCommitted(committedText)
setStore("committed", committedText)
if (opts?.onFinal) opts.onFinal(segment.trim())
}
@@ -98,7 +105,7 @@ export function createSpeechRecognition(opts?: {
pendingHypothesis = ""
lastInterimSuffix = ""
shrinkCandidate = undefined
setInterim("")
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
}
@@ -107,7 +114,7 @@ export function createSpeechRecognition(opts?: {
pendingHypothesis = hypothesis
lastInterimSuffix = suffix
shrinkCandidate = undefined
setInterim(suffix)
setStore("interim", suffix)
if (opts?.onInterim) {
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
}
@@ -122,7 +129,7 @@ export function createSpeechRecognition(opts?: {
pendingHypothesis = ""
lastInterimSuffix = ""
shrinkCandidate = undefined
setInterim("")
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
}, COMMIT_DELAY)
}
@@ -162,7 +169,7 @@ export function createSpeechRecognition(opts?: {
pendingHypothesis = ""
lastInterimSuffix = ""
shrinkCandidate = undefined
setInterim("")
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
return
}
@@ -211,7 +218,7 @@ export function createSpeechRecognition(opts?: {
lastInterimSuffix = ""
shrinkCandidate = undefined
if (e.error === "no-speech" && shouldContinue) {
setInterim("")
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
setTimeout(() => {
try {
@@ -221,7 +228,7 @@ export function createSpeechRecognition(opts?: {
return
}
shouldContinue = false
setIsRecording(false)
setStore("isRecording", false)
}
recognition.onstart = () => {
@@ -230,16 +237,16 @@ export function createSpeechRecognition(opts?: {
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setInterim("")
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
setIsRecording(true)
setStore("isRecording", true)
}
recognition.onend = () => {
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setIsRecording(false)
setStore("isRecording", false)
if (shouldContinue) {
setTimeout(() => {
try {
@@ -258,7 +265,7 @@ export function createSpeechRecognition(opts?: {
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setInterim("")
setStore("interim", "")
try {
recognition.start()
} catch {}
@@ -271,7 +278,7 @@ export function createSpeechRecognition(opts?: {
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setInterim("")
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
try {
recognition.stop()
@@ -284,7 +291,7 @@ export function createSpeechRecognition(opts?: {
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setInterim("")
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
try {
recognition?.stop()

View File

@@ -20,6 +20,9 @@ type HighlightGroup = {
items: HighlightItem[]
}
const ok = "public, max-age=1, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400"
const error = "public, max-age=1, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400"
function parseHighlights(body: string): HighlightGroup[] {
const groups = new Map<string, HighlightItem[]>()
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
@@ -90,25 +93,48 @@ export async function GET() {
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenCode-Console",
},
})
cf: {
// best-effort edge caching (ignored outside Cloudflare)
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any).catch(() => undefined)
if (!response.ok) {
return { releases: [] }
}
const fail = () =>
new Response(JSON.stringify({ releases: [] }), {
status: 503,
headers: {
"Content-Type": "application/json",
"Cache-Control": error,
},
})
const releases = (await response.json()) as Release[]
if (!response?.ok) return fail()
return {
releases: releases.map((release) => {
const parsed = parseMarkdown(release.body || "")
return {
tag: release.tag_name,
name: release.name,
date: release.published_at,
url: release.html_url,
highlights: parsed.highlights,
sections: parsed.sections,
}
const data = await response.json().catch(() => undefined)
if (!Array.isArray(data)) return fail()
const releases = data as Release[]
return new Response(
JSON.stringify({
releases: releases.map((release) => {
const parsed = parseMarkdown(release.body || "")
return {
tag: release.tag_name,
name: release.name,
date: release.published_at,
url: release.html_url,
highlights: parsed.highlights,
sections: parsed.sections,
}
}),
}),
}
{
headers: {
"Content-Type": "application/json",
"Cache-Control": ok,
},
},
)
}

View File

@@ -371,7 +371,7 @@
top: 80px;
align-self: start;
background: var(--color-background);
padding: 8px 0;
padding: 44px 0 8px;
@media (max-width: 50rem) {
position: static;

View File

@@ -1,44 +1,12 @@
import "./index.css"
import { Title, Meta, Link } from "@solidjs/meta"
import { createAsync, query } from "@solidjs/router"
import { createAsync } from "@solidjs/router"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { config } from "~/config"
import { For, Show, createSignal } from "solid-js"
type Release = {
tag_name: string
name: string
body: string
published_at: string
html_url: string
}
const getReleases = query(async () => {
"use server"
const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenCode-Console",
},
cf: {
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any)
if (!response.ok) return []
return response.json() as Promise<Release[]>
}, "releases.get")
function formatDate(dateString: string) {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
}
import { getRequestEvent } from "solid-js/web"
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
@@ -54,68 +22,33 @@ type HighlightGroup = {
items: HighlightItem[]
}
function parseHighlights(body: string): HighlightGroup[] {
const groups = new Map<string, HighlightItem[]>()
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
let match
while ((match = regex.exec(body)) !== null) {
const source = match[1]
const content = match[2]
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="[^"]*"\s+src="([^"]+)"/)
const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
let media: HighlightMedia | undefined
if (videoMatch) {
media = { type: "video", src: videoMatch[1] }
} else if (imgMatch) {
media = { type: "image", src: imgMatch[3], width: imgMatch[1], height: imgMatch[2] }
}
if (titleMatch && media) {
const item: HighlightItem = {
title: titleMatch[1],
description: pMatch?.[2] || "",
shortDescription: pMatch?.[1],
media,
}
if (!groups.has(source)) {
groups.set(source, [])
}
groups.get(source)!.push(item)
}
}
return Array.from(groups.entries()).map(([source, items]) => ({ source, items }))
type ChangelogRelease = {
tag: string
name: string
date: string
url: string
highlights: HighlightGroup[]
sections: { title: string; items: string[] }[]
}
function parseMarkdown(body: string) {
const lines = body.split("\n")
const sections: { title: string; items: string[] }[] = []
let current: { title: string; items: string[] } | null = null
let skip = false
async function getReleases() {
const event = getRequestEvent()
const url = event ? new URL("/changelog.json", event.request.url).toString() : "/changelog.json"
for (const line of lines) {
if (line.startsWith("## ")) {
if (current) sections.push(current)
const title = line.slice(3).trim()
current = { title, items: [] }
skip = false
} else if (line.startsWith("**Thank you")) {
skip = true
} else if (line.startsWith("- ") && !skip) {
current?.items.push(line.slice(2).trim())
}
}
if (current) sections.push(current)
const response = await fetch(url).catch(() => undefined)
if (!response?.ok) return []
const highlights = parseHighlights(body)
const json = await response.json().catch(() => undefined)
return Array.isArray(json?.releases) ? (json.releases as ChangelogRelease[]) : []
}
return { sections, highlights }
function formatDate(dateString: string) {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
}
function ReleaseItem(props: { item: string }) {
@@ -217,28 +150,27 @@ export default function Changelog() {
<section data-component="releases">
<For each={releases()}>
{(release) => {
const parsed = () => parseMarkdown(release.body || "")
return (
<article data-component="release">
<header>
<div data-slot="version">
<a href={release.html_url} target="_blank" rel="noopener noreferrer">
{release.tag_name}
<a href={release.url} target="_blank" rel="noopener noreferrer">
{release.tag}
</a>
</div>
<time dateTime={release.published_at}>{formatDate(release.published_at)}</time>
<time dateTime={release.date}>{formatDate(release.date)}</time>
</header>
<div data-slot="content">
<Show when={parsed().highlights.length > 0}>
<Show when={release.highlights.length > 0}>
<div data-component="highlights">
<For each={parsed().highlights}>{(group) => <HighlightSection group={group} />}</For>
<For each={release.highlights}>{(group) => <HighlightSection group={group} />}</For>
</div>
</Show>
<Show when={parsed().highlights.length > 0 && parsed().sections.length > 0}>
<CollapsibleSections sections={parsed().sections} />
<Show when={release.highlights.length > 0 && release.sections.length > 0}>
<CollapsibleSections sections={release.sections} />
</Show>
<Show when={parsed().highlights.length === 0}>
<For each={parsed().sections}>
<Show when={release.highlights.length === 0}>
<For each={release.sections}>
{(section) => (
<div data-component="section">
<h3>{section.title}</h3>
@@ -255,9 +187,9 @@ export default function Changelog() {
}}
</For>
</section>
<Footer />
</div>
<Footer />
</div>
<Legal />

View File

@@ -17,7 +17,7 @@
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh"></div>
<div id="root" class="flex flex-col h-dvh p-px"></div>
<div data-tauri-decorum-tb class="w-0 h-0 hidden" />
<script src="/src/index.tsx" type="module"></script>
</body>

View File

@@ -328,18 +328,23 @@ render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
if (link?.href) {
e.preventDefault()
platform.openLink(link.href)
}
}
onMount(() => {
document.addEventListener("click", handleClick)
// Handle external links - open in system browser instead of webview
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
const link = target.closest("a") as HTMLAnchorElement | null
if (link?.href && !link.href.startsWith("javascript:") && !link.href.startsWith("#")) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
void shellOpen(link.href).catch(() => undefined)
}
}
document.addEventListener("click", handleClick, true)
onCleanup(() => {
document.removeEventListener("click", handleClick)
document.removeEventListener("click", handleClick, true)
})
})

View File

@@ -20,6 +20,7 @@ import { createStore } from "solid-js/store"
import z from "zod"
import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
import { clientOnly } from "@solidjs/start"
@@ -362,6 +363,15 @@ export default function () {
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<Show when={messages().length > 1}>
<MessageNav
class="sticky top-0 shrink-0 py-2 pl-4"
messages={messages()}
current={activeMessage()}
size="compact"
onMessageSelect={setActiveMessage}
/>
</Show>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}

View File

@@ -82,8 +82,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.74",
"@opentui/solid": "0.1.74",
"@opentui/core": "0.1.75",
"@opentui/solid": "0.1.75",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",

View File

@@ -73,6 +73,7 @@ export namespace Agent {
const result: Record<string, Info> = {
build: {
name: "build",
description: "The default agent. Executes tools based on configured permissions.",
options: {},
permission: PermissionNext.merge(
defaults,
@@ -87,6 +88,7 @@ export namespace Agent {
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: PermissionNext.merge(
defaults,

View File

@@ -153,6 +153,7 @@ async function createToolContext(agent: Agent.Info) {
callID: Identifier.ascending("part"),
agent: agent.name,
abort: new AbortController().signal,
messages: [],
metadata: () => {},
async ask(req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) {
for (const pattern of req.patterns) {

View File

@@ -570,6 +570,16 @@ function App() {
dialog.clear()
},
},
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
dialog.clear()
},
},
])
createEffect(() => {

View File

@@ -145,7 +145,7 @@ export function Session() {
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
const wide = createMemo(() => dimensions().width > 120)
@@ -503,7 +503,7 @@ export function Session() {
},
},
{
title: "Toggle code concealment",
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
keybind: "messages_toggle_conceal" as any,
category: "Session",
@@ -538,18 +538,6 @@ export function Session() {
dialog.clear()
},
},
{
title: "Toggle diff wrapping",
value: "session.toggle.diffwrap",
category: "Session",
slash: {
name: "diffwrap",
},
onSelect: (dialog) => {
setDiffWrapMode((prev) => (prev === "word" ? "none" : "word"))
dialog.clear()
},
},
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
@@ -1705,10 +1693,29 @@ function Glob(props: ToolProps<typeof GlobTool>) {
}
function Read(props: ToolProps<typeof ReadTool>) {
const { theme } = useTheme()
const loaded = createMemo(() => {
if (props.part.state.status !== "completed") return []
if (props.part.state.time.compacted) return []
const value = props.metadata.loaded
if (!value || !Array.isArray(value)) return []
return value.filter((p): p is string => typeof p === "string")
})
return (
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
</InlineTool>
<>
<InlineTool icon="→" pending="Reading file..." complete={props.input.filePath} part={props.part}>
Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])}
</InlineTool>
<For each={loaded()}>
{(filepath) => (
<box paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}>
Loaded {normalizePath(filepath)}
</text>
</box>
)}
</For>
</>
)
}

View File

@@ -206,7 +206,11 @@ export namespace File {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = await $`git diff --numstat HEAD`.cwd(Instance.directory).quiet().nothrow().text()
const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const changedFiles: Info[] = []
@@ -223,7 +227,7 @@ export namespace File {
}
}
const untrackedOutput = await $`git ls-files --others --exclude-standard`
const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard`
.cwd(Instance.directory)
.quiet()
.nothrow()
@@ -248,7 +252,7 @@ export namespace File {
}
// Get deleted files
const deletedOutput = await $`git diff --name-only --diff-filter=D HEAD`
const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()

View File

@@ -0,0 +1,164 @@
import path from "path"
import os from "os"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "../util/log"
import type { MessageV2 } from "./message-v2"
const log = Log.create({ service: "instruction" })
const FILES = [
"AGENTS.md",
"CLAUDE.md",
"CONTEXT.md", // deprecated
]
function globalFiles() {
const files = [path.join(Global.Path.config, "AGENTS.md")]
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
if (Flag.OPENCODE_CONFIG_DIR) {
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
}
return files
}
async function resolveRelative(instruction: string): Promise<string[]> {
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
}
if (!Flag.OPENCODE_CONFIG_DIR) {
log.warn(
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
)
return []
}
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
}
export namespace InstructionPrompt {
export async function systemPaths() {
const config = await Config.get()
const paths = new Set<string>()
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((p) => paths.add(path.resolve(p)))
break
}
}
}
for (const file of globalFiles()) {
if (await Bun.file(file).exists()) {
paths.add(path.resolve(file))
break
}
}
if (config.instructions) {
for (let instruction of config.instructions) {
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
if (instruction.startsWith("~/")) {
instruction = path.join(os.homedir(), instruction.slice(2))
}
const matches = path.isAbsolute(instruction)
? await Array.fromAsync(
new Bun.Glob(path.basename(instruction)).scan({
cwd: path.dirname(instruction),
absolute: true,
onlyFiles: true,
}),
).catch(() => [])
: await resolveRelative(instruction)
matches.forEach((p) => paths.add(path.resolve(p)))
}
}
return paths
}
export async function system() {
const config = await Config.get()
const paths = await systemPaths()
const files = Array.from(paths).map(async (p) => {
const content = await Bun.file(p)
.text()
.catch(() => "")
return content ? "Instructions from: " + p + "\n" + content : ""
})
const urls: string[] = []
if (config.instructions) {
for (const instruction of config.instructions) {
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
urls.push(instruction)
}
}
}
const fetches = urls.map((url) =>
fetch(url, { signal: AbortSignal.timeout(5000) })
.then((res) => (res.ok ? res.text() : ""))
.catch(() => "")
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
)
return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
}
export function loaded(messages: MessageV2.WithParts[]) {
const paths = new Set<string>()
for (const msg of messages) {
for (const part of msg.parts) {
if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
if (part.state.time.compacted) continue
const loaded = part.state.metadata?.loaded
if (!loaded || !Array.isArray(loaded)) continue
for (const p of loaded) {
if (typeof p === "string") paths.add(p)
}
}
}
}
return paths
}
export async function find(dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (await Bun.file(filepath).exists()) return filepath
}
}
export async function resolve(messages: MessageV2.WithParts[], filepath: string) {
const system = await systemPaths()
const already = loaded(messages)
const results: { filepath: string; content: string }[] = []
let current = path.dirname(path.resolve(filepath))
const root = path.resolve(Instance.directory)
while (current.startsWith(root)) {
const found = await find(current)
if (found && !system.has(found) && !already.has(found)) {
const content = await Bun.file(found)
.text()
.catch(() => undefined)
if (content) {
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
}
}
if (current === root) break
current = path.dirname(current)
}
return results
}
}

View File

@@ -631,7 +631,7 @@ export namespace MessageV2 {
sessionID: Identifier.schema("session"),
messageID: Identifier.schema("message"),
}),
async (input) => {
async (input): Promise<WithParts> => {
return {
info: await Storage.read<MessageV2.Info>(["message", input.sessionID, input.messageID]),
parts: await parts(input.messageID),

View File

@@ -15,6 +15,7 @@ import { Instance } from "../project/instance"
import { Bus } from "../bus"
import { ProviderTransform } from "../provider/transform"
import { SystemPrompt } from "./system"
import { InstructionPrompt } from "./instruction"
import { Plugin } from "../plugin"
import PROMPT_PLAN from "../session/prompt/plan.txt"
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
@@ -386,6 +387,7 @@ export namespace SessionPrompt {
abort,
callID: part.callID,
extra: { bypassAgentCheck: true },
messages: msgs,
async metadata(input) {
await Session.updatePart({
...part,
@@ -561,6 +563,7 @@ export namespace SessionPrompt {
tools: lastUser.tools,
processor,
bypassAgentCheck,
messages: msgs,
})
if (step === 1) {
@@ -598,7 +601,7 @@ export namespace SessionPrompt {
agent,
abort,
sessionID,
system: [...(await SystemPrompt.environment(model)), ...(await SystemPrompt.custom())],
system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())],
messages: [
...MessageV2.toModelMessages(sessionMessages, model),
...(isLastStep
@@ -650,6 +653,7 @@ export namespace SessionPrompt {
tools?: Record<string, boolean>
processor: SessionProcessor.Info
bypassAgentCheck: boolean
messages: MessageV2.WithParts[]
}) {
using _ = log.time("resolveTools")
const tools: Record<string, AITool> = {}
@@ -661,6 +665,7 @@ export namespace SessionPrompt {
callID: options.toolCallId,
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
agent: input.agent.name,
messages: input.messages,
metadata: async (val: { title?: string; metadata?: any }) => {
const match = input.processor.partFromToolCall(options.toolCallId)
if (match && match.state.status === "running") {
@@ -1008,6 +1013,7 @@ export namespace SessionPrompt {
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true, model },
messages: [],
metadata: async () => {},
ask: async () => {},
}
@@ -1069,6 +1075,7 @@ export namespace SessionPrompt {
agent: input.agent!,
messageID: info.id,
extra: { bypassCwdCheck: true },
messages: [],
metadata: async () => {},
ask: async () => {},
}
@@ -1349,7 +1356,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const session = await Session.get(input.sessionID)
if (session.revert) {
SessionRevert.cleanup(session)
await SessionRevert.cleanup(session)
}
const agent = await Agent.get(input.agent)
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))

View File

@@ -20,6 +20,62 @@ import { Agent } from "@/agent/agent"
export namespace SessionSummary {
const log = Log.create({ service: "session.summary" })
function unquoteGitPath(input: string) {
if (!input.startsWith('"')) return input
if (!input.endsWith('"')) return input
const body = input.slice(1, -1)
const bytes: number[] = []
for (let i = 0; i < body.length; i++) {
const char = body[i]!
if (char !== "\\") {
bytes.push(char.charCodeAt(0))
continue
}
const next = body[i + 1]
if (!next) {
bytes.push("\\".charCodeAt(0))
continue
}
if (next >= "0" && next <= "7") {
const chunk = body.slice(i + 1, i + 4)
const match = chunk.match(/^[0-7]{1,3}/)
if (!match) {
bytes.push(next.charCodeAt(0))
i++
continue
}
bytes.push(parseInt(match[0], 8))
i += match[0].length
continue
}
const escaped =
next === "n"
? "\n"
: next === "r"
? "\r"
: next === "t"
? "\t"
: next === "b"
? "\b"
: next === "f"
? "\f"
: next === "v"
? "\v"
: next === "\\" || next === '"'
? next
: undefined
bytes.push((escaped ?? next).charCodeAt(0))
i++
}
return Buffer.from(bytes).toString()
}
export const summarize = fn(
z.object({
sessionID: z.string(),
@@ -116,7 +172,18 @@ export namespace SessionSummary {
messageID: Identifier.schema("message").optional(),
}),
async (input) => {
return Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
const next = diffs.map((item) => {
const file = unquoteGitPath(item.file)
if (file === item.file) return item
return {
...item,
file,
}
})
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
if (changed) Storage.write(["session_diff", input.sessionID], next).catch(() => {})
return next
},
)

View File

@@ -1,37 +1,14 @@
import { Ripgrep } from "../file/ripgrep"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import path from "path"
import os from "os"
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
import PROMPT_ANTHROPIC_WITHOUT_TODO from "./prompt/qwen.txt"
import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
import PROMPT_CODEX from "./prompt/codex_header.txt"
import type { Provider } from "@/provider/provider"
import { Flag } from "@/flag/flag"
const log = Log.create({ service: "system-prompt" })
async function resolveRelativeInstruction(instruction: string): Promise<string[]> {
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
}
if (!Flag.OPENCODE_CONFIG_DIR) {
log.warn(
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
)
return []
}
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
}
export namespace SystemPrompt {
export function instructions() {
@@ -72,81 +49,4 @@ export namespace SystemPrompt {
].join("\n"),
]
}
const LOCAL_RULE_FILES = [
"AGENTS.md",
"CLAUDE.md",
"CONTEXT.md", // deprecated
]
const GLOBAL_RULE_FILES = [path.join(Global.Path.config, "AGENTS.md")]
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
GLOBAL_RULE_FILES.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
if (Flag.OPENCODE_CONFIG_DIR) {
GLOBAL_RULE_FILES.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
}
export async function custom() {
const config = await Config.get()
const paths = new Set<string>()
// Only scan local rule files when project discovery is enabled
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const localRuleFile of LOCAL_RULE_FILES) {
const matches = await Filesystem.findUp(localRuleFile, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((path) => paths.add(path))
break
}
}
}
for (const globalRuleFile of GLOBAL_RULE_FILES) {
if (await Bun.file(globalRuleFile).exists()) {
paths.add(globalRuleFile)
break
}
}
const urls: string[] = []
if (config.instructions) {
for (let instruction of config.instructions) {
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
urls.push(instruction)
continue
}
if (instruction.startsWith("~/")) {
instruction = path.join(os.homedir(), instruction.slice(2))
}
let matches: string[] = []
if (path.isAbsolute(instruction)) {
matches = await Array.fromAsync(
new Bun.Glob(path.basename(instruction)).scan({
cwd: path.dirname(instruction),
absolute: true,
onlyFiles: true,
}),
).catch(() => [])
} else {
matches = await resolveRelativeInstruction(instruction)
}
matches.forEach((path) => paths.add(path))
}
}
const foundFiles = Array.from(paths).map((p) =>
Bun.file(p)
.text()
.catch(() => "")
.then((x) => "Instructions from: " + p + "\n" + x),
)
const foundUrls = urls.map((url) =>
fetch(url, { signal: AbortSignal.timeout(5000) })
.then((res) => (res.ok ? res.text() : ""))
.catch(() => "")
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
)
return Promise.all([...foundFiles, ...foundUrls]).then((result) => result.filter(Boolean))
}
}

View File

@@ -163,7 +163,7 @@ export namespace Snapshot {
const git = gitdir()
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`.quiet().cwd(Instance.directory).nothrow()
const result =
await $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
.quiet()
.cwd(Instance.worktree)
.nothrow()
@@ -196,7 +196,7 @@ export namespace Snapshot {
export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
const git = gitdir()
const result: FileDiff[] = []
for await (const line of $`git -c core.autocrlf=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()

View File

@@ -8,6 +8,7 @@ import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -59,6 +60,8 @@ export const ReadTool = Tool.define("read", {
throw new Error(`File not found: ${filepath}`)
}
const instructions = await InstructionPrompt.resolve(ctx.messages, filepath)
// Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
const isImage =
file.type.startsWith("image/") && file.type !== "image/svg+xml" && file.type !== "image/vnd.fastbidsheet"
@@ -72,6 +75,7 @@ export const ReadTool = Tool.define("read", {
metadata: {
preview: msg,
truncated: false,
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
},
attachments: [
{
@@ -133,12 +137,17 @@ export const ReadTool = Tool.define("read", {
LSP.touchFile(filepath, false)
FileTime.read(ctx.sessionID, filepath)
if (instructions.length > 0) {
output += `\n\n<system-reminder>\n${instructions.map((i) => i.content).join("\n\n")}\n</system-reminder>`
}
return {
title,
output,
metadata: {
preview,
truncated,
...(instructions.length > 0 && { loaded: instructions.map((i) => i.filepath) }),
},
}
},

View File

@@ -20,6 +20,7 @@ export namespace Tool {
abort: AbortSignal
callID?: string
extra?: { [key: string]: any }
messages: MessageV2.WithParts[]
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">): Promise<void>
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { InstructionPrompt } from "../../src/session/instruction"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
describe("InstructionPrompt.resolve", () => {
test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Root Instructions")
await Bun.write(path.join(dir, "src", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const system = await InstructionPrompt.systemPaths()
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"))
expect(results).toEqual([])
},
})
})
test("returns AGENTS.md from subdirectory (not in systemPaths)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const system = await InstructionPrompt.systemPaths()
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
const results = await InstructionPrompt.resolve([], path.join(tmp.path, "subdir", "nested", "file.ts"))
expect(results.length).toBe(1)
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
},
})
})
})

View File

@@ -11,6 +11,7 @@ const baseCtx = {
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
}

View File

@@ -12,6 +12,7 @@ const ctx = {
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}

View File

@@ -11,6 +11,7 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
}

View File

@@ -10,6 +10,7 @@ const ctx = {
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}

View File

@@ -9,6 +9,7 @@ const ctx = {
callID: "test-call",
agent: "test-agent",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}

View File

@@ -14,6 +14,7 @@ const ctx = {
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
@@ -330,3 +331,26 @@ root_type Monster;`
})
})
})
describe("tool.read loaded instructions", () => {
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Test Instructions\nDo something special.")
await Bun.write(path.join(dir, "subdir", "nested", "test.txt"), "test content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "subdir", "nested", "test.txt") }, ctx)
expect(result.output).toContain("test content")
expect(result.output).toContain("system-reminder")
expect(result.output).toContain("Test Instructions")
expect(result.metadata.loaded).toBeDefined()
expect(result.metadata.loaded).toContain(path.join(tmp.path, "subdir", "AGENTS.md"))
},
})
})
})

View File

@@ -46,7 +46,7 @@
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
}
&:focus:not(:disabled) {
&:focus-visible:not(:disabled) {
background-color: var(--surface-raised-base-hover);
}
&:active:not(:disabled) {

View File

@@ -128,20 +128,56 @@ export function Code<T>(props: CodeProps<T>) {
}
}
const notifyRendered = () => {
if (!local.onRendered) return
const lineCount = () => {
const text = local.file.contents
const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0)
return Math.max(1, total)
}
const applySelection = (range: SelectedLineRange | null) => {
const root = getRoot()
if (!root) return false
const lines = lineCount()
if (root.querySelectorAll("[data-line]").length < lines) return false
if (!range) {
file().setSelectedLines(null)
return true
}
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
if (start < 1 || end > lines) {
file().setSelectedLines(null)
return true
}
if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
file().setSelectedLines(null)
return true
}
const normalized = (() => {
if (range.endSide != null) return { start: range.start, end: range.end }
if (range.side !== "deletions") return range
if (root.querySelector("[data-deletions]") != null) return range
return { start: range.start, end: range.end }
})()
file().setSelectedLines(normalized)
return true
}
const notifyRendered = () => {
observer?.disconnect()
observer = undefined
renderToken++
const token = renderToken
const lines = (() => {
const text = local.file.contents
const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0)
return Math.max(1, total)
})()
const lines = lineCount()
const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
@@ -152,6 +188,7 @@ export function Code<T>(props: CodeProps<T>) {
observer = undefined
requestAnimationFrame(() => {
if (token !== renderToken) return
applySelection(lastSelection)
local.onRendered?.()
})
}
@@ -241,7 +278,7 @@ export function Code<T>(props: CodeProps<T>) {
const setSelectedLines = (range: SelectedLineRange | null) => {
lastSelection = range
file().setSelectedLines(range)
applySelection(range)
}
const scheduleSelectionUpdate = () => {

View File

@@ -38,6 +38,77 @@ export function Diff<T>(props: SSRDiffProps<T>) {
fileDiffRef.removeAttribute("data-color-scheme")
}
const lineIndex = (split: boolean, element: HTMLElement) => {
const raw = element.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((value) => parseInt(value, 10))
.filter((value) => !Number.isNaN(value))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (nodes.length === 0) return
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findSide(node) === targetSide) return lineIndex(split, node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
}
}
const fixSelection = (range: SelectedLineRange | null) => {
if (!range) return range
const root = getRoot()
if (!root) return
const diffs = root.querySelector("[data-diffs]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.type === "split"
const start = rowIndex(root, split, range.start, range.side)
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
if (start === undefined || end === undefined) {
if (root.querySelector("[data-line], [data-alt-line]") == null) return
return null
}
if (start <= end) return range
const side = range.endSide ?? range.side
const swapped: SelectedLineRange = {
start: range.end,
end: range.start,
}
if (side) swapped.side = side
if (range.endSide && range.side) swapped.endSide = range.side
return swapped
}
const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => {
const diff = fileDiffInstance
if (!diff) return
const fixed = fixSelection(range)
if (fixed === undefined) {
if (attempt >= 120) return
requestAnimationFrame(() => setSelectedLines(range, attempt + 1))
return
}
diff.setSelectedLines(fixed)
}
const findSide = (element: HTMLElement): "additions" | "deletions" => {
const line = element.closest("[data-line], [data-alt-line]")
if (line instanceof HTMLElement) {
@@ -159,14 +230,14 @@ export function Diff<T>(props: SSRDiffProps<T>) {
containerWrapper: container,
})
fileDiffInstance.setSelectedLines(local.selectedLines ?? null)
setSelectedLines(local.selectedLines ?? null)
createEffect(() => {
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
})
createEffect(() => {
fileDiffInstance?.setSelectedLines(local.selectedLines ?? null)
setSelectedLines(local.selectedLines ?? null)
})
createEffect(() => {

View File

@@ -115,9 +115,64 @@ export function Diff<T>(props: DiffProps<T>) {
host.removeAttribute("data-color-scheme")
}
const notifyRendered = () => {
if (!local.onRendered) return
const lineIndex = (split: boolean, element: HTMLElement) => {
const raw = element.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((value) => parseInt(value, 10))
.filter((value) => !Number.isNaN(value))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (nodes.length === 0) return
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findSide(node) === targetSide) return lineIndex(split, node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
}
}
const fixSelection = (range: SelectedLineRange | null) => {
if (!range) return range
const root = getRoot()
if (!root) return
const diffs = root.querySelector("[data-diffs]")
if (!(diffs instanceof HTMLElement)) return
const split = diffs.dataset.type === "split"
const start = rowIndex(root, split, range.start, range.side)
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
if (start === undefined || end === undefined) {
if (root.querySelector("[data-line], [data-alt-line]") == null) return
return null
}
if (start <= end) return range
const side = range.endSide ?? range.side
const swapped: SelectedLineRange = {
start: range.end,
end: range.start,
}
if (side) swapped.side = side
if (range.endSide && range.side) swapped.endSide = range.side
return swapped
}
const notifyRendered = () => {
observer?.disconnect()
observer = undefined
renderToken++
@@ -134,6 +189,7 @@ export function Diff<T>(props: DiffProps<T>) {
observer = undefined
requestAnimationFrame(() => {
if (token !== renderToken) return
setSelectedLines(lastSelection)
local.onRendered?.()
})
}
@@ -173,7 +229,8 @@ export function Diff<T>(props: DiffProps<T>) {
const root = getRoot()
if (typeof MutationObserver === "undefined") {
if (!root || !isReady(root)) return
local.onRendered()
setSelectedLines(lastSelection)
local.onRendered?.()
return
}
@@ -214,41 +271,14 @@ export function Diff<T>(props: DiffProps<T>) {
)
if (code.length === 0) return
const lineIndex = (element: HTMLElement) => {
const raw = element.dataset.lineIndex
if (!raw) return
const values = raw
.split(",")
.map((value) => parseInt(value, 10))
.filter((value) => !Number.isNaN(value))
if (values.length === 0) return
if (!split) return values[0]
if (values.length === 2) return values[1]
return values[0]
}
const rowIndex = (line: number, side: SelectionSide | undefined) => {
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
(node): node is HTMLElement => node instanceof HTMLElement,
)
if (nodes.length === 0) return
const targetSide = side ?? "additions"
for (const node of nodes) {
if (findSide(node) === targetSide) return lineIndex(node)
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
}
}
for (const range of ranges) {
const start = rowIndex(range.start, range.side)
const start = rowIndex(root, split, range.start, range.side)
if (start === undefined) continue
const end = (() => {
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
if (same) return start
return rowIndex(range.end, range.endSide ?? range.side)
return rowIndex(root, split, range.end, range.endSide ?? range.side)
})()
if (end === undefined) continue
@@ -258,7 +288,7 @@ export function Diff<T>(props: DiffProps<T>) {
for (const block of code) {
for (const element of Array.from(block.children)) {
if (!(element instanceof HTMLElement)) continue
const idx = lineIndex(element)
const idx = lineIndex(split, element)
if (idx === undefined) continue
if (idx > last) break
if (idx < first) continue
@@ -275,8 +305,15 @@ export function Diff<T>(props: DiffProps<T>) {
const setSelectedLines = (range: SelectedLineRange | null) => {
const active = current()
if (!active) return
lastSelection = range
active.setSelectedLines(range)
const fixed = fixSelection(range)
if (fixed === undefined) {
lastSelection = range
return
}
lastSelection = fixed
active.setSelectedLines(fixed)
}
const updateSelection = () => {

View File

@@ -3,19 +3,20 @@ import { ComponentProps, JSXElement, ParentProps, splitProps } from "solid-js"
export interface HoverCardProps extends ParentProps, Omit<ComponentProps<typeof Kobalte>, "children"> {
trigger: JSXElement
mount?: HTMLElement
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
}
export function HoverCard(props: HoverCardProps) {
const [local, rest] = splitProps(props, ["trigger", "class", "classList", "children"])
const [local, rest] = splitProps(props, ["trigger", "mount", "class", "classList", "children"])
return (
<Kobalte gutter={4} {...rest}>
<Kobalte.Trigger as="div" data-slot="hover-card-trigger">
{local.trigger}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Portal mount={local.mount}>
<Kobalte.Content
data-component="hover-card-content"
classList={{

View File

@@ -90,8 +90,8 @@
/* color: var(--icon-hover); */
/* } */
}
&:focus:not(:disabled) {
background-color: var(--surface-focus);
&:focus-visible:not(:disabled) {
background-color: var(--surface-raised-base-hover);
}
&:active:not(:disabled) {
background-color: var(--surface-raised-base-active);

View File

@@ -40,7 +40,7 @@
transition: opacity 0.15s ease;
&:hover:not(:disabled),
&:focus:not(:disabled),
&:focus-visible:not(:disabled),
&:active:not(:disabled) {
background-color: transparent;
opacity: 0.7;
@@ -91,7 +91,7 @@
transition: opacity 0.15s ease;
&:hover:not(:disabled),
&:focus:not(:disabled),
&:focus-visible:not(:disabled),
&:active:not(:disabled) {
background-color: transparent;
opacity: 0.7;

View File

@@ -21,6 +21,12 @@
transform: translateX(50%);
cursor: col-resize;
&[data-edge="start"] {
inset-inline-start: 0;
inset-inline-end: auto;
transform: translateX(-50%);
}
&::after {
width: 3px;
inset-block: 0;
@@ -36,6 +42,12 @@
transform: translateY(-50%);
cursor: row-resize;
&[data-edge="end"] {
inset-block-start: auto;
inset-block-end: 0;
transform: translateY(50%);
}
&::after {
height: 3px;
inset-inline: 0;

View File

@@ -2,6 +2,7 @@ import { splitProps, type JSX } from "solid-js"
export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "onResize"> {
direction: "horizontal" | "vertical"
edge?: "start" | "end"
size: number
min: number
max: number
@@ -13,6 +14,7 @@ export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElemen
export function ResizeHandle(props: ResizeHandleProps) {
const [local, rest] = splitProps(props, [
"direction",
"edge",
"size",
"min",
"max",
@@ -25,6 +27,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault()
const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end")
const start = local.direction === "horizontal" ? e.clientX : e.clientY
const startSize = local.size
let current = startSize
@@ -34,7 +37,14 @@ export function ResizeHandle(props: ResizeHandleProps) {
const onMouseMove = (moveEvent: MouseEvent) => {
const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY
const delta = local.direction === "vertical" ? start - pos : pos - start
const delta =
local.direction === "vertical"
? edge === "end"
? pos - start
: start - pos
: edge === "start"
? start - pos
: pos - start
current = startSize + delta
const clamped = Math.min(local.max, Math.max(local.min, current))
local.onResize(clamped)
@@ -61,6 +71,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
{...rest}
data-component="resize-handle"
data-direction={local.direction}
data-edge={local.edge ?? (local.direction === "vertical" ? "start" : "end")}
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,

View File

@@ -31,7 +31,7 @@
}
}
&:not([data-expanded]):focus {
&:not([data-expanded]):focus-visible {
&[data-variant="secondary"] {
background-color: var(--button-secondary-base);
}

View File

@@ -9,6 +9,7 @@ import { StickyAccordionHeader } from "./sticky-accordion-header"
import { useDiffComponent } from "../context/diff"
import { useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
@@ -118,6 +119,12 @@ function dataUrlFromValue(value: unknown): string | undefined {
return `data:${mime};base64,${content}`
}
function diffId(file: string): string | undefined {
const sum = checksum(file)
if (!sum) return
return `session-review-diff-${sum}`
}
type SessionReviewSelection = {
file: string
range: SelectedLineRange
@@ -489,7 +496,12 @@ export const SessionReview = (props: SessionReviewProps) => {
}
return (
<Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
<Accordion.Item
value={diff.file}
id={diffId(diff.file)}
data-file={diff.file}
data-slot="session-review-accordion-item"
>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">

View File

@@ -212,6 +212,58 @@
/* } */
}
&[data-variant="pill"][data-orientation="horizontal"] {
background-color: transparent;
[data-slot="tabs-list"] {
height: auto;
padding: 6px 0;
gap: 4px;
background-color: var(--background-base);
&::after {
display: none;
}
}
[data-slot="tabs-trigger-wrapper"] {
height: 32px;
border: none;
border-radius: var(--radius-sm);
background-color: transparent;
gap: 0;
/* text-13-medium */
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
[data-slot="tabs-trigger"] {
height: 100%;
width: 100%;
padding: 0 12px;
background-color: transparent;
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
color: var(--text-strong);
}
&:has([data-selected]) {
background-color: var(--surface-raised-base-active);
color: var(--text-strong);
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-active);
}
}
}
}
&[data-orientation="vertical"] {
flex-direction: row;

View File

@@ -3,7 +3,7 @@ import { Show, splitProps, type JSX } from "solid-js"
import type { ComponentProps, ParentProps, Component } from "solid-js"
export interface TabsProps extends ComponentProps<typeof Kobalte> {
variant?: "normal" | "alt" | "settings"
variant?: "normal" | "alt" | "pill" | "settings"
orientation?: "horizontal" | "vertical"
}
export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {}

View File

@@ -33,18 +33,18 @@ export const auraTheme = auraThemeJson as DesktopTheme
export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
"oc-1": oc1Theme,
tokyonight: tokyonightTheme,
dracula: draculaTheme,
monokai: monokaiTheme,
solarized: solarizedTheme,
nord: nordTheme,
catppuccin: catppuccinTheme,
aura: auraTheme,
ayu: ayuTheme,
carbonfox: carbonfoxTheme,
catppuccin: catppuccinTheme,
dracula: draculaTheme,
gruvbox: gruvboxTheme,
monokai: monokaiTheme,
nightowl: nightowlTheme,
nord: nordTheme,
onedarkpro: oneDarkProTheme,
shadesofpurple: shadesOfPurpleTheme,
nightowl: nightowlTheme,
solarized: solarizedTheme,
tokyonight: tokyonightTheme,
vesper: vesperTheme,
carbonfox: carbonfoxTheme,
gruvbox: gruvboxTheme,
aura: auraTheme,
}

View File

@@ -88,7 +88,7 @@ export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills
When opencode starts, it looks for rule files in this order:
1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`, or `CONTEXT.md`)
1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`)
2. **Global file** at `~/.config/opencode/AGENTS.md`
3. **Claude Code file** at `~/.claude/CLAUDE.md` (unless disabled)

View File

@@ -4,6 +4,35 @@ import { $ } from "bun"
import { Script } from "@opencode-ai/script"
import { buildNotes, getLatestRelease } from "./changelog"
const highlightsTemplate = `## Highlights
<!--
Add highlights before publishing. Delete this section if no highlights.
- For multiple highlights, use multiple <highlight> tags
- Highlights with the same source attribute get grouped together
-->
<!--
<highlight source="SourceName (TUI/Desktop/Web/Core)">
<h2>Feature title goes here</h2>
<p short="Short description used for Desktop Recap">
Full description of the feature or change
</p>
https://github.com/user-attachments/assets/uuid-for-video (you will want to drag & drop the video or picture)
<img
width="1912"
height="1164"
alt="image"
src="https://github.com/user-attachments/assets/uuid-for-image"
/>
</highlight>
-->
`
let notes: string[] = []
console.log("=== publishing ===\n")
@@ -11,6 +40,7 @@ console.log("=== publishing ===\n")
if (!Script.preview) {
const previous = await getLatestRelease()
notes = await buildNotes(previous, "HEAD")
notes.unshift(highlightsTemplate)
}
const pkgjsons = await Array.fromAsync(