Ver código fonte

Merge branch 'dev' into update-design-subscriptions

Aaron Iker 3 meses atrás
pai
commit
e258662178

+ 4 - 4
bun.lock

@@ -411,7 +411,7 @@
         "@solid-primitives/resize-observer": "2.1.3",
         "@solidjs/meta": "catalog:",
         "@typescript/native-preview": "catalog:",
-        "dompurify": "catalog:",
+        "dompurify": "3.3.1",
         "fuzzysort": "catalog:",
         "katex": "0.16.27",
         "luxon": "catalog:",
@@ -506,7 +506,7 @@
     "@tailwindcss/vite": "4.1.11",
     "@tsconfig/bun": "1.0.9",
     "@tsconfig/node22": "22.0.2",
-    "@types/bun": "1.3.6",
+    "@types/bun": "1.3.5",
     "@types/luxon": "3.7.1",
     "@types/node": "22.13.9",
     "@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -1774,7 +1774,7 @@
 
     "@types/braces": ["@types/[email protected]", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
 
-    "@types/bun": ["@types/[email protected].6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
+    "@types/bun": ["@types/[email protected].5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
 
     "@types/chai": ["@types/[email protected]", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
 
@@ -2076,7 +2076,7 @@
 
     "bun-pty": ["[email protected]", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
 
-    "bun-types": ["[email protected].6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
+    "bun-types": ["[email protected].5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
 
     "bun-webgpu": ["[email protected]", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
 

+ 2 - 2
nix/hashes.json

@@ -1,6 +1,6 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-hB6PWxkvRgb7o8vQO88SDHArG/FcdnLD7FR/BHvkYik=",
-    "aarch64-darwin": "sha256-P63bPPVm5F/YQ6DTaIQNB7SqDmQHQBhVsOh3Kd/c8Jw="
+    "x86_64-linux": "sha256-4ndHIlS9t1ynRdFszJ1nvcu3YhunhuOc7jcuHI1FbnM=",
+    "aarch64-darwin": "sha256-C0E9KAEj3GI83HwirIL2zlXYIe92T+7Iv6F51BB6slY="
   }
 }

+ 2 - 2
package.json

@@ -4,7 +4,7 @@
   "description": "AI-powered development tool",
   "private": true,
   "type": "module",
-  "packageManager": "[email protected].6",
+  "packageManager": "[email protected].5",
   "scripts": {
     "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
     "typecheck": "bun turbo typecheck",
@@ -21,7 +21,7 @@
       "packages/slack"
     ],
     "catalog": {
-      "@types/bun": "1.3.6",
+      "@types/bun": "1.3.5",
       "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
       "ulid": "3.0.1",

+ 7 - 4
packages/app/src/components/session/session-header.tsx

@@ -54,14 +54,17 @@ export function SessionHeader() {
           <Portal mount={mount()}>
             <button
               type="button"
-              class="hidden md:flex w-[320px] h-7 px-1.5 items-center gap-2 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] h-8 p-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"
               onClick={() => command.trigger("file.open")}
             >
-              <Icon name="magnifying-glass" size="small" class="text-text-weak" />
-              <span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span>
+              <div class="flex items-center gap-2">
+                <Icon name="magnifying-glass" size="normal" class="icon-base" />
+                <span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span>
+              </div>
+
               <Show when={hotkey()}>
                 {(keybind) => (
-                  <span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-md border border-border-weak-base bg-surface-base text-12-medium text-text-weak">
+                  <span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-[2px] border border-border-weak-base bg-surface-base text-12-medium text-text-weak">
                     {keybind()}
                   </span>
                 )}

+ 1 - 1
packages/app/src/components/titlebar.tsx

@@ -88,7 +88,7 @@ export function Titlebar() {
           onClick={layout.mobileSidebar.toggle}
         />
         <TooltipKeybind
-          class="hidden xl:flex shrink-0"
+          class="hidden xl:flex shrink-0 ml-14"
           placement="bottom"
           title="Toggle sidebar"
           keybind={command.keybind("sidebar.toggle")}

+ 85 - 57
packages/app/src/pages/layout.tsx

@@ -161,53 +161,64 @@ export default function Layout(props: ParentProps) {
   })
 
   onMount(() => {
+    const alerts = {
+      "permission.asked": {
+        title: "Permission required",
+        icon: "checklist" as const,
+        description: (sessionTitle: string, projectName: string) =>
+          `${sessionTitle} in ${projectName} needs permission`,
+      },
+      "question.asked": {
+        title: "Question",
+        icon: "bubble-5" as const,
+        description: (sessionTitle: string, projectName: string) => `${sessionTitle} in ${projectName} has a question`,
+      },
+    }
+
     const toastBySession = new Map<string, number>()
     const alertedAtBySession = new Map<string, number>()
-    const permissionAlertCooldownMs = 5000
+    const cooldownMs = 5000
 
     const unsub = globalSDK.event.listen((e) => {
-      if (e.details?.type !== "permission.asked") return
+      if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
+      const config = alerts[e.details.type]
       const directory = e.name
-      const perm = e.details.properties
-      if (permission.autoResponds(perm, directory)) return
+      const props = e.details.properties
+      if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
 
       const [store] = globalSync.child(directory)
-      const session = store.session.find((s) => s.id === perm.sessionID)
-      const sessionKey = `${directory}:${perm.sessionID}`
+      const session = store.session.find((s) => s.id === props.sessionID)
+      const sessionKey = `${directory}:${props.sessionID}`
 
       const sessionTitle = session?.title ?? "New session"
       const projectName = getFilename(directory)
-      const description = `${sessionTitle} in ${projectName} needs permission`
-      const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
+      const description = config.description(sessionTitle, projectName)
+      const href = `/${base64Encode(directory)}/session/${props.sessionID}`
 
       const now = Date.now()
       const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
-      if (now - lastAlerted < permissionAlertCooldownMs) return
+      if (now - lastAlerted < cooldownMs) return
       alertedAtBySession.set(sessionKey, now)
 
-      void platform.notify("Permission required", description, href)
+      void platform.notify(config.title, description, href)
 
       const currentDir = params.dir ? base64Decode(params.dir) : undefined
       const currentSession = params.id
-      if (directory === currentDir && perm.sessionID === currentSession) return
+      if (directory === currentDir && props.sessionID === currentSession) return
       if (directory === currentDir && session?.parentID === currentSession) return
 
       const existingToastId = toastBySession.get(sessionKey)
-      if (existingToastId !== undefined) {
-        toaster.dismiss(existingToastId)
-      }
+      if (existingToastId !== undefined) toaster.dismiss(existingToastId)
 
       const toastId = showToast({
         persistent: true,
-        icon: "checklist",
-        title: "Permission required",
+        icon: config.icon,
+        title: config.title,
         description,
         actions: [
           {
             label: "Go to session",
-            onClick: () => {
-              navigate(href)
-            },
+            onClick: () => navigate(href),
           },
           {
             label: "Dismiss",
@@ -848,7 +859,6 @@ export default function Layout(props: ParentProps) {
       return false
     })
     const isWorking = createMemo(() => {
-      if (props.session.id === params.id) return false
       if (hasPermissions()) return false
       const status = sessionStore.session_status[props.session.id]
       return status?.type === "busy" || status?.type === "retry"
@@ -871,9 +881,9 @@ export default function Layout(props: ParentProps) {
       <div
         data-session-id={props.session.id}
         class="group/session relative w-full rounded-md cursor-default transition-colors px-3
-               hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
+               hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
       >
-        <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
+        <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={16} openDelay={1000}>
           <A
             href={`${props.slug}/session/${props.session.id}`}
             class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
@@ -903,7 +913,13 @@ export default function Layout(props: ParentProps) {
               <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
                 {props.session.title}
               </span>
-              <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
+              <Show when={props.session.summary}>
+                {(summary) => (
+                  <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
+                    <DiffChanges changes={summary()} />
+                  </div>
+                )}
+              </Show>
             </div>
           </A>
         </Tooltip>
@@ -914,6 +930,7 @@ export default function Layout(props: ParentProps) {
             placement={props.mobile ? "bottom" : "right"}
             title="Archive session"
             keybind={command.keybind("session.archive")}
+            gutter={8}
           >
             <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
           </TooltipKeybind>
@@ -961,7 +978,7 @@ export default function Layout(props: ParentProps) {
         type="button"
         classList={{
           "flex items-center justify-center size-10 p-1 rounded-md border transition-colors cursor-default": true,
-          "bg-surface-base-hover border-icon-strong-base": selected(),
+          "bg-transparent border-icon-strong-base hover:bg-surface-base-hover": selected(),
           "bg-transparent border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": !selected(),
         }}
         onClick={() => navigateToProject(props.project.worktree)}
@@ -973,9 +990,9 @@ export default function Layout(props: ParentProps) {
     return (
       // @ts-ignore
       <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
-        <HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={10} trigger={trigger}>
+        <HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={8} trigger={trigger}>
           <div class="-m-3 flex flex-col w-72">
-            <div class="px-3 py-2 text-12-medium text-text-strong">Recent sessions</div>
+            <div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div>
             <div class="px-2 pb-2 flex flex-col gap-2">
               <Show
                 when={workspaceEnabled()}
@@ -999,7 +1016,7 @@ export default function Layout(props: ParentProps) {
                         <div class="shrink-0 size-6 flex items-center justify-center">
                           <Icon name="branch" size="small" class="text-icon-base" />
                         </div>
-                        <span class="truncate text-14-medium text-text-strong">{label(directory)}</span>
+                        <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
                       </div>
                       <For each={sessions(directory)}>
                         {(session) => (
@@ -1011,18 +1028,20 @@ export default function Layout(props: ParentProps) {
                 </For>
               </Show>
             </div>
-            <div class="px-2 py-2 border-t border-border-weak-base">
-              <Button
-                variant="ghost"
-                class="flex w-full text-left justify-start text-text-base px-2"
-                onClick={() => {
-                  layout.sidebar.open()
-                  navigateToProject(props.project.worktree)
-                }}
-              >
-                View all sessions
-              </Button>
-            </div>
+            <Show when={!selected()}>
+              <div class="px-2 py-2 border-t border-border-weak-base">
+                <Button
+                  variant="ghost"
+                  class="flex w-full text-left justify-start text-text-base px-2"
+                  onClick={() => {
+                    layout.sidebar.open()
+                    navigateToProject(props.project.worktree)
+                  }}
+                >
+                  View all sessions
+                </Button>
+              </div>
+            </Show>
           </div>
         </HoverCard>
       </div>
@@ -1104,7 +1123,7 @@ export default function Layout(props: ParentProps) {
                   <div class="flex items-center justify-center shrink-0 size-6">
                     <Icon name="branch" size="small" />
                   </div>
-                  <span class="truncate text-14-medium text-text-strong">{title()}</span>
+                  <span class="truncate text-14-medium text-text-base">{title()}</span>
                   <Icon
                     name={open() ? "chevron-down" : "chevron-right"}
                     size="small"
@@ -1113,17 +1132,20 @@ export default function Layout(props: ParentProps) {
                 </div>
               </Collapsible.Trigger>
               <div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
-                <Tooltip class="pointer-events-auto" value="More options" placement="top">
-                  <IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md" />
-                </Tooltip>
-                <Tooltip class="pointer-events-auto" value="New session" placement="top">
+                <IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
+                <TooltipKeybind
+                  class="pointer-events-auto"
+                  placement="right"
+                  title="New session"
+                  keybind={command.keybind("session.new")}
+                >
                   <IconButton
                     icon="plus-small"
                     variant="ghost"
                     class="size-6 rounded-md"
                     onClick={() => navigate(`/${slug()}/session`)}
                   />
-                </Tooltip>
+                </TooltipKeybind>
               </div>
             </div>
           </div>
@@ -1146,9 +1168,12 @@ export default function Layout(props: ParentProps) {
                 <div class="relative w-full py-1">
                   <Button
                     variant="ghost"
-                    class="flex w-full text-left justify-start text-12-medium text-text-weak px-10"
+                    class="flex w-full text-left justify-start text-14-regular text-text-weak px-10"
                     size="large"
-                    onClick={loadMore}
+                    onClick={(e: MouseEvent) => {
+                      loadMore()
+                      ;(e.currentTarget as HTMLButtonElement).blur()
+                    }}
                   >
                     Load more
                   </Button>
@@ -1191,9 +1216,12 @@ export default function Layout(props: ParentProps) {
             <div class="relative w-full py-1">
               <Button
                 variant="ghost"
-                class="flex w-full text-left justify-start text-12-medium text-text-weak px-10"
+                class="flex w-full text-left justify-start text-14-regular text-text-weak px-10"
                 size="large"
-                onClick={loadMore}
+                onClick={(e: MouseEvent) => {
+                  loadMore()
+                  ;(e.currentTarget as HTMLButtonElement).blur()
+                }}
               >
                 Load more
               </Button>
@@ -1312,7 +1340,7 @@ export default function Layout(props: ParentProps) {
               {(p) => (
                 <>
                   <div class="shrink-0 px-2 py-1">
-                    <div class="flex items-start justify-between gap-2 p-2">
+                    <div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
                       <div class="flex flex-col min-w-0">
                         <span class="text-16-medium text-text-strong truncate">{projectName()}</span>
                         <Tooltip placement="right" value={project()?.worktree} class="shrink-0">
@@ -1326,22 +1354,22 @@ export default function Layout(props: ParentProps) {
                           as={IconButton}
                           icon="dot-grid"
                           variant="ghost"
-                          class="shrink-0 size-6 rounded-md"
+                          class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
                         />
                         <DropdownMenu.Portal>
-                          <DropdownMenu.Content>
+                          <DropdownMenu.Content class="mt-1">
                             <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
-                              <DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
-                            </DropdownMenu.Item>
-                            <DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
-                              <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
+                              <DropdownMenu.ItemLabel>Edit</DropdownMenu.ItemLabel>
                             </DropdownMenu.Item>
-                            <DropdownMenu.Separator />
                             <DropdownMenu.Item onSelect={() => layout.sidebar.toggleWorkspaces(p.worktree)}>
                               <DropdownMenu.ItemLabel>
                                 {layout.sidebar.workspaces(p.worktree)() ? "Disable workspaces" : "Enable workspaces"}
                               </DropdownMenu.ItemLabel>
                             </DropdownMenu.Item>
+                            <DropdownMenu.Separator />
+                            <DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
+                              <DropdownMenu.ItemLabel>Close</DropdownMenu.ItemLabel>
+                            </DropdownMenu.Item>
                           </DropdownMenu.Content>
                         </DropdownMenu.Portal>
                       </DropdownMenu>

+ 1 - 1
packages/desktop/src-tauri/src/lib.rs

@@ -223,7 +223,7 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
 pub fn run() {
     let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
 
-    #[cfg(target_os = "macos")]
+    #[cfg(all(target_os = "macos", not(debug_assertions)))]
     let _ = std::process::Command::new("killall")
         .arg("opencode-cli")
         .output();

+ 3 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -23,6 +23,7 @@ import type { FilePart } from "@opencode-ai/sdk/v2"
 import { TuiEvent } from "../../event"
 import { iife } from "@/util/iife"
 import { Locale } from "@/util/locale"
+import { formatDuration } from "@/util/format"
 import { createColors, createFrames } from "../../ui/spinner.ts"
 import { useDialog } from "@tui/ui/dialog"
 import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
@@ -1037,7 +1038,8 @@ export function Prompt(props: PromptProps) {
                       if (!r) return ""
                       const baseMessage = message()
                       const truncatedHint = isTruncated() ? " (click to expand)" : ""
-                      const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]`
+                      const duration = formatDuration(seconds())
+                      const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]`
                       return baseMessage + truncatedHint + retryInfo
                     }
 

+ 1 - 1
packages/opencode/src/cli/error.ts

@@ -28,7 +28,7 @@ export function FormatError(input: unknown) {
     return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Rename the directory to "${input.data.suggestion}" or remove it. This is a common typo.`
   }
   if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
-    return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
+    return input.data.message
   }
   if (Config.InvalidError.isInstance(input))
     return [

+ 29 - 6
packages/opencode/src/config/config.ts

@@ -19,6 +19,8 @@ import { BunProc } from "@/bun"
 import { Installation } from "@/installation"
 import { ConfigMarkdown } from "./markdown"
 import { existsSync } from "fs"
+import { Bus } from "@/bus"
+import { Session } from "@/session"
 
 export namespace Config {
   const log = Log.create({ service: "config" })
@@ -231,8 +233,15 @@ export namespace Config {
       dot: true,
       cwd: dir,
     })) {
-      const md = await ConfigMarkdown.parse(item)
-      if (!md.data) continue
+      const md = await ConfigMarkdown.parse(item).catch((err) => {
+        const message = ConfigMarkdown.FrontmatterError.isInstance(err)
+          ? err.data.message
+          : `Failed to parse command ${item}`
+        Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+        log.error("failed to load command", { command: item, err })
+        return undefined
+      })
+      if (!md) continue
 
       const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
       const file = rel(item, patterns) ?? path.basename(item)
@@ -263,8 +272,15 @@ export namespace Config {
       dot: true,
       cwd: dir,
     })) {
-      const md = await ConfigMarkdown.parse(item)
-      if (!md.data) continue
+      const md = await ConfigMarkdown.parse(item).catch((err) => {
+        const message = ConfigMarkdown.FrontmatterError.isInstance(err)
+          ? err.data.message
+          : `Failed to parse agent ${item}`
+        Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+        log.error("failed to load agent", { agent: item, err })
+        return undefined
+      })
+      if (!md) continue
 
       const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
       const file = rel(item, patterns) ?? path.basename(item)
@@ -294,8 +310,15 @@ export namespace Config {
       dot: true,
       cwd: dir,
     })) {
-      const md = await ConfigMarkdown.parse(item)
-      if (!md.data) continue
+      const md = await ConfigMarkdown.parse(item).catch((err) => {
+        const message = ConfigMarkdown.FrontmatterError.isInstance(err)
+          ? err.data.message
+          : `Failed to parse mode ${item}`
+        Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+        log.error("failed to load mode", { mode: item, err })
+        return undefined
+      })
+      if (!md) continue
 
       const config = {
         name: path.basename(item, ".md"),

+ 54 - 2
packages/opencode/src/config/markdown.ts

@@ -14,8 +14,60 @@ export namespace ConfigMarkdown {
     return Array.from(template.matchAll(SHELL_REGEX))
   }
 
+  export function preprocessFrontmatter(content: string): string {
+    const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+    if (!match) return content
+
+    const frontmatter = match[1]
+    const lines = frontmatter.split("\n")
+    const result: string[] = []
+
+    for (const line of lines) {
+      // skip comments and empty lines
+      if (line.trim().startsWith("#") || line.trim() === "") {
+        result.push(line)
+        continue
+      }
+
+      // skip lines that are continuations (indented)
+      if (line.match(/^\s+/)) {
+        result.push(line)
+        continue
+      }
+
+      // match key: value pattern
+      const kvMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
+      if (!kvMatch) {
+        result.push(line)
+        continue
+      }
+
+      const key = kvMatch[1]
+      const value = kvMatch[2].trim()
+
+      // skip if value is empty, already quoted, or uses block scalar
+      if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) {
+        result.push(line)
+        continue
+      }
+
+      // if value contains a colon, convert to block scalar
+      if (value.includes(":")) {
+        result.push(`${key}: |`)
+        result.push(`  ${value}`)
+        continue
+      }
+
+      result.push(line)
+    }
+
+    const processed = result.join("\n")
+    return content.replace(frontmatter, () => processed)
+  }
+
   export async function parse(filePath: string) {
-    const template = await Bun.file(filePath).text()
+    const raw = await Bun.file(filePath).text()
+    const template = preprocessFrontmatter(raw)
 
     try {
       const md = matter(template)
@@ -24,7 +76,7 @@ export namespace ConfigMarkdown {
       throw new FrontmatterError(
         {
           path: filePath,
-          message: `Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
+          message: `${filePath}: Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
         },
         { cause: err },
       )

+ 5 - 1
packages/opencode/src/project/project.ts

@@ -272,7 +272,11 @@ export namespace Project {
 
   export async function list() {
     const keys = await Storage.list(["project"])
-    return await Promise.all(keys.map((x) => Storage.read<Info>(x)))
+    const projects = await Promise.all(keys.map((x) => Storage.read<Info>(x)))
+    return projects.map((project) => ({
+      ...project,
+      sandboxes: project.sandboxes?.filter((x) => existsSync(x)),
+    }))
   }
 
   export const update = fn(

+ 5 - 1
packages/opencode/src/provider/models.ts

@@ -81,7 +81,11 @@ export namespace ModelsDev {
     const file = Bun.file(filepath)
     const result = await file.json().catch(() => {})
     if (result) return result as Record<string, Provider>
-    const json = await data()
+    if (typeof data === "function") {
+      const json = await data()
+      return JSON.parse(json) as Record<string, Provider>
+    }
+    const json = await fetch("https://models.dev/api.json").then((x) => x.text())
     return JSON.parse(json) as Record<string, Provider>
   }
 

+ 14 - 4
packages/opencode/src/skill/skill.ts

@@ -1,4 +1,5 @@
 import z from "zod"
+import path from "path"
 import { Config } from "../config/config"
 import { Instance } from "../project/instance"
 import { NamedError } from "@opencode-ai/util/error"
@@ -7,6 +8,9 @@ import { Log } from "../util/log"
 import { Global } from "@/global"
 import { Filesystem } from "@/util/filesystem"
 import { Flag } from "@/flag/flag"
+import { Bus } from "@/bus"
+import { TuiEvent } from "@/cli/cmd/tui/event"
+import { Session } from "@/session"
 
 export namespace Skill {
   const log = Log.create({ service: "skill" })
@@ -42,10 +46,16 @@ export namespace Skill {
     const skills: Record<string, Info> = {}
 
     const addSkill = async (match: string) => {
-      const md = await ConfigMarkdown.parse(match)
-      if (!md) {
-        return
-      }
+      const md = await ConfigMarkdown.parse(match).catch((err) => {
+        const message = ConfigMarkdown.FrontmatterError.isInstance(err)
+          ? err.data.message
+          : `Failed to parse skill ${match}`
+        Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
+        log.error("failed to load skill", { skill: match, err })
+        return undefined
+      })
+
+      if (!md) return
 
       const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
       if (!parsed.success) return

+ 21 - 3
packages/opencode/src/tool/grep.ts

@@ -37,7 +37,15 @@ export const GrepTool = Tool.define("grep", {
     await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
 
     const rgPath = await Ripgrep.filepath()
-    const args = ["-nH", "--hidden", "--follow", "--field-match-separator=|", "--regexp", params.pattern]
+    const args = [
+      "-nH",
+      "--hidden",
+      "--follow",
+      "--no-messages",
+      "--field-match-separator=|",
+      "--regexp",
+      params.pattern,
+    ]
     if (params.include) {
       args.push("--glob", params.include)
     }
@@ -52,7 +60,10 @@ export const GrepTool = Tool.define("grep", {
     const errorOutput = await new Response(proc.stderr).text()
     const exitCode = await proc.exited
 
-    if (exitCode === 1) {
+    // Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
+    // With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
+    // Only fail if exit code is 2 AND no output was produced
+    if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
       return {
         title: params.pattern,
         metadata: { matches: 0, truncated: false },
@@ -60,10 +71,12 @@ export const GrepTool = Tool.define("grep", {
       }
     }
 
-    if (exitCode !== 0) {
+    if (exitCode !== 0 && exitCode !== 2) {
       throw new Error(`ripgrep failed: ${errorOutput}`)
     }
 
+    const hasErrors = exitCode === 2
+
     // Handle both Unix (\n) and Windows (\r\n) line endings
     const lines = output.trim().split(/\r?\n/)
     const matches = []
@@ -124,6 +137,11 @@ export const GrepTool = Tool.define("grep", {
       outputLines.push("(Results are truncated. Consider using a more specific path or pattern.)")
     }
 
+    if (hasErrors) {
+      outputLines.push("")
+      outputLines.push("(Some paths were inaccessible and skipped)")
+    }
+
     return {
       title: params.pattern,
       metadata: {

+ 20 - 0
packages/opencode/src/util/format.ts

@@ -0,0 +1,20 @@
+export function formatDuration(secs: number) {
+  if (secs <= 0) return ""
+  if (secs < 60) return `${secs}s`
+  if (secs < 3600) {
+    const mins = Math.floor(secs / 60)
+    const remaining = secs % 60
+    return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
+  }
+  if (secs < 86400) {
+    const hours = Math.floor(secs / 3600)
+    const remaining = Math.floor((secs % 3600) / 60)
+    return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
+  }
+  if (secs < 604800) {
+    const days = Math.floor(secs / 86400)
+    return days === 1 ? "~1 day" : `~${days} days`
+  }
+  const weeks = Math.floor(secs / 604800)
+  return weeks === 1 ? "~1 week" : `~${weeks} weeks`
+}

+ 4 - 0
packages/opencode/test/config/fixtures/empty-frontmatter.md

@@ -0,0 +1,4 @@
+---
+---
+
+Content

+ 28 - 0
packages/opencode/test/config/fixtures/frontmatter.md

@@ -0,0 +1,28 @@
+---
+description: "This is a description wrapped in quotes"
+# field: this is a commented out field that should be ignored
+occupation: This man has the following occupation: Software Engineer
+title: 'Hello World'
+name: John "Doe"
+
+family: He has no 'family'
+summary: >
+  This is a summary
+url: https://example.com:8080/path?query=value
+time: The time is 12:30:00 PM
+nested: First: Second: Third: Fourth
+quoted_colon: "Already quoted: no change needed"
+single_quoted_colon: 'Single quoted: also fine'
+mixed: He said "hello: world" and then left
+empty:
+dollar: Use $' and $& for special patterns
+---
+
+Content that should not be parsed:
+
+fake_field: this is not yaml
+another: neither is this
+time: 10:30:00 AM
+url: https://should-not-be-parsed.com:3000
+
+The above lines look like YAML but are just content.

+ 1 - 0
packages/opencode/test/config/fixtures/no-frontmatter.md

@@ -0,0 +1 @@
+Content

+ 164 - 61
packages/opencode/test/config/markdown.test.ts

@@ -1,89 +1,192 @@
-import { expect, test } from "bun:test"
+import { expect, test, describe } from "bun:test"
 import { ConfigMarkdown } from "../../src/config/markdown"
 
-const template = `This is a @valid/path/to/a/file and it should also match at
-the beginning of a line:
+describe("ConfigMarkdown: normal template", () => {
+  const template = `This is a @valid/path/to/a/file and it should also match at
+  the beginning of a line:
 
-@another-valid/path/to/a/file
+  @another-valid/path/to/a/file
 
-but this is not:
+  but this is not:
 
-   - Adds a "Co-authored-by:" footer which clarifies which AI agent
-     helped create this commit, using an appropriate \`noreply@...\`
-     or \`[email protected]\` email address.
+     - Adds a "Co-authored-by:" footer which clarifies which AI agent
+       helped create this commit, using an appropriate \`noreply@...\`
+       or \`[email protected]\` email address.
 
-We also need to deal with files followed by @commas, ones
-with @file-extensions.md, even @multiple.extensions.bak,
-hidden directories like @.config/ or files like @.bashrc
-and ones at the end of a sentence like @foo.md.
+  We also need to deal with files followed by @commas, ones
+  with @file-extensions.md, even @multiple.extensions.bak,
+  hidden directories like @.config/ or files like @.bashrc
+  and ones at the end of a sentence like @foo.md.
 
-Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
-as well as @~/home-files and @~/paths/under/home.txt.
+  Also shouldn't forget @/absolute/paths.txt with and @/without/extensions,
+  as well as @~/home-files and @~/paths/under/home.txt.
 
-If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
+  If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.`
 
-const matches = ConfigMarkdown.files(template)
+  const matches = ConfigMarkdown.files(template)
 
-test("should extract exactly 12 file references", () => {
-  expect(matches.length).toBe(12)
-})
+  test("should extract exactly 12 file references", () => {
+    expect(matches.length).toBe(12)
+  })
 
-test("should extract valid/path/to/a/file", () => {
-  expect(matches[0][1]).toBe("valid/path/to/a/file")
-})
+  test("should extract valid/path/to/a/file", () => {
+    expect(matches[0][1]).toBe("valid/path/to/a/file")
+  })
 
-test("should extract another-valid/path/to/a/file", () => {
-  expect(matches[1][1]).toBe("another-valid/path/to/a/file")
-})
+  test("should extract another-valid/path/to/a/file", () => {
+    expect(matches[1][1]).toBe("another-valid/path/to/a/file")
+  })
 
-test("should extract paths ignoring comma after", () => {
-  expect(matches[2][1]).toBe("commas")
-})
+  test("should extract paths ignoring comma after", () => {
+    expect(matches[2][1]).toBe("commas")
+  })
 
-test("should extract a path with a file extension and comma after", () => {
-  expect(matches[3][1]).toBe("file-extensions.md")
-})
+  test("should extract a path with a file extension and comma after", () => {
+    expect(matches[3][1]).toBe("file-extensions.md")
+  })
 
-test("should extract a path with multiple dots and comma after", () => {
-  expect(matches[4][1]).toBe("multiple.extensions.bak")
-})
+  test("should extract a path with multiple dots and comma after", () => {
+    expect(matches[4][1]).toBe("multiple.extensions.bak")
+  })
 
-test("should extract hidden directory", () => {
-  expect(matches[5][1]).toBe(".config/")
-})
+  test("should extract hidden directory", () => {
+    expect(matches[5][1]).toBe(".config/")
+  })
 
-test("should extract hidden file", () => {
-  expect(matches[6][1]).toBe(".bashrc")
-})
+  test("should extract hidden file", () => {
+    expect(matches[6][1]).toBe(".bashrc")
+  })
 
-test("should extract a file ignoring period at end of sentence", () => {
-  expect(matches[7][1]).toBe("foo.md")
-})
+  test("should extract a file ignoring period at end of sentence", () => {
+    expect(matches[7][1]).toBe("foo.md")
+  })
 
-test("should extract an absolute path with an extension", () => {
-  expect(matches[8][1]).toBe("/absolute/paths.txt")
-})
+  test("should extract an absolute path with an extension", () => {
+    expect(matches[8][1]).toBe("/absolute/paths.txt")
+  })
 
-test("should extract an absolute path without an extension", () => {
-  expect(matches[9][1]).toBe("/without/extensions")
-})
+  test("should extract an absolute path without an extension", () => {
+    expect(matches[9][1]).toBe("/without/extensions")
+  })
+
+  test("should extract an absolute path in home directory", () => {
+    expect(matches[10][1]).toBe("~/home-files")
+  })
 
-test("should extract an absolute path in home directory", () => {
-  expect(matches[10][1]).toBe("~/home-files")
+  test("should extract an absolute path under home directory", () => {
+    expect(matches[11][1]).toBe("~/paths/under/home.txt")
+  })
+
+  test("should not match when preceded by backtick", () => {
+    const backtickTest = "This `@should/not/match` should be ignored"
+    const backtickMatches = ConfigMarkdown.files(backtickTest)
+    expect(backtickMatches.length).toBe(0)
+  })
+
+  test("should not match email addresses", () => {
+    const emailTest = "Contact [email protected] for help"
+    const emailMatches = ConfigMarkdown.files(emailTest)
+    expect(emailMatches.length).toBe(0)
+  })
 })
 
-test("should extract an absolute path under home directory", () => {
-  expect(matches[11][1]).toBe("~/paths/under/home.txt")
+describe("ConfigMarkdown: frontmatter parsing", async () => {
+  const parsed = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/frontmatter.md")
+
+  test("should parse without throwing", () => {
+    expect(parsed).toBeDefined()
+    expect(parsed.data).toBeDefined()
+    expect(parsed.content).toBeDefined()
+  })
+
+  test("should extract description field", () => {
+    expect(parsed.data.description).toBe("This is a description wrapped in quotes")
+  })
+
+  test("should extract occupation field with colon in value", () => {
+    expect(parsed.data.occupation).toBe("This man has the following occupation: Software Engineer\n")
+  })
+
+  test("should extract title field with single quotes", () => {
+    expect(parsed.data.title).toBe("Hello World")
+  })
+
+  test("should extract name field with embedded quotes", () => {
+    expect(parsed.data.name).toBe('John "Doe"')
+  })
+
+  test("should extract family field with embedded single quotes", () => {
+    expect(parsed.data.family).toBe("He has no 'family'")
+  })
+
+  test("should extract multiline summary field", () => {
+    expect(parsed.data.summary).toBe("This is a summary\n")
+  })
+
+  test("should not include commented fields in data", () => {
+    expect(parsed.data.field).toBeUndefined()
+  })
+
+  test("should extract URL with port", () => {
+    expect(parsed.data.url).toBe("https://example.com:8080/path?query=value\n")
+  })
+
+  test("should extract time with colons", () => {
+    expect(parsed.data.time).toBe("The time is 12:30:00 PM\n")
+  })
+
+  test("should extract value with multiple colons", () => {
+    expect(parsed.data.nested).toBe("First: Second: Third: Fourth\n")
+  })
+
+  test("should preserve already double-quoted values with colons", () => {
+    expect(parsed.data.quoted_colon).toBe("Already quoted: no change needed")
+  })
+
+  test("should preserve already single-quoted values with colons", () => {
+    expect(parsed.data.single_quoted_colon).toBe("Single quoted: also fine")
+  })
+
+  test("should extract value with quotes and colons mixed", () => {
+    expect(parsed.data.mixed).toBe('He said "hello: world" and then left\n')
+  })
+
+  test("should handle empty values", () => {
+    expect(parsed.data.empty).toBeNull()
+  })
+
+  test("should handle dollar sign replacement patterns literally", () => {
+    expect(parsed.data.dollar).toBe("Use $' and $& for special patterns")
+  })
+
+  test("should not parse fake yaml from content", () => {
+    expect(parsed.data.fake_field).toBeUndefined()
+    expect(parsed.data.another).toBeUndefined()
+  })
+
+  test("should extract content after frontmatter without modification", () => {
+    expect(parsed.content).toContain("Content that should not be parsed:")
+    expect(parsed.content).toContain("fake_field: this is not yaml")
+    expect(parsed.content).toContain("url: https://should-not-be-parsed.com:3000")
+  })
 })
 
-test("should not match when preceded by backtick", () => {
-  const backtickTest = "This `@should/not/match` should be ignored"
-  const backtickMatches = ConfigMarkdown.files(backtickTest)
-  expect(backtickMatches.length).toBe(0)
+describe("ConfigMarkdown: frontmatter parsing w/ empty frontmatter", async () => {
+  const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/empty-frontmatter.md")
+
+  test("should parse without throwing", () => {
+    expect(result).toBeDefined()
+    expect(result.data).toEqual({})
+    expect(result.content.trim()).toBe("Content")
+  })
 })
 
-test("should not match email addresses", () => {
-  const emailTest = "Contact [email protected] for help"
-  const emailMatches = ConfigMarkdown.files(emailTest)
-  expect(emailMatches.length).toBe(0)
+describe("ConfigMarkdown: frontmatter parsing w/ no frontmatter", async () => {
+  const result = await ConfigMarkdown.parse(import.meta.dir + "/fixtures/no-frontmatter.md")
+
+  test("should parse without throwing", () => {
+    expect(result).toBeDefined()
+    expect(result.data).toEqual({})
+    expect(result.content.trim()).toBe("Content")
+  })
 })

+ 59 - 0
packages/opencode/test/util/format.test.ts

@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test"
+import { formatDuration } from "../../src/util/format"
+
+describe("util.format", () => {
+  describe("formatDuration", () => {
+    test("returns empty string for zero or negative values", () => {
+      expect(formatDuration(0)).toBe("")
+      expect(formatDuration(-1)).toBe("")
+      expect(formatDuration(-100)).toBe("")
+    })
+
+    test("formats seconds under a minute", () => {
+      expect(formatDuration(1)).toBe("1s")
+      expect(formatDuration(30)).toBe("30s")
+      expect(formatDuration(59)).toBe("59s")
+    })
+
+    test("formats minutes under an hour", () => {
+      expect(formatDuration(60)).toBe("1m")
+      expect(formatDuration(61)).toBe("1m 1s")
+      expect(formatDuration(90)).toBe("1m 30s")
+      expect(formatDuration(120)).toBe("2m")
+      expect(formatDuration(330)).toBe("5m 30s")
+      expect(formatDuration(3599)).toBe("59m 59s")
+    })
+
+    test("formats hours under a day", () => {
+      expect(formatDuration(3600)).toBe("1h")
+      expect(formatDuration(3660)).toBe("1h 1m")
+      expect(formatDuration(7200)).toBe("2h")
+      expect(formatDuration(8100)).toBe("2h 15m")
+      expect(formatDuration(86399)).toBe("23h 59m")
+    })
+
+    test("formats days under a week", () => {
+      expect(formatDuration(86400)).toBe("~1 day")
+      expect(formatDuration(172800)).toBe("~2 days")
+      expect(formatDuration(259200)).toBe("~3 days")
+      expect(formatDuration(604799)).toBe("~6 days")
+    })
+
+    test("formats weeks", () => {
+      expect(formatDuration(604800)).toBe("~1 week")
+      expect(formatDuration(1209600)).toBe("~2 weeks")
+      expect(formatDuration(1609200)).toBe("~2 weeks")
+    })
+
+    test("handles boundary values correctly", () => {
+      expect(formatDuration(59)).toBe("59s")
+      expect(formatDuration(60)).toBe("1m")
+      expect(formatDuration(3599)).toBe("59m 59s")
+      expect(formatDuration(3600)).toBe("1h")
+      expect(formatDuration(86399)).toBe("23h 59m")
+      expect(formatDuration(86400)).toBe("~1 day")
+      expect(formatDuration(604799)).toBe("~6 days")
+      expect(formatDuration(604800)).toBe("~1 week")
+    })
+  })
+})

+ 1 - 1
packages/ui/package.json

@@ -48,10 +48,10 @@
     "@solid-primitives/resize-observer": "2.1.3",
     "@solidjs/meta": "catalog:",
     "@typescript/native-preview": "catalog:",
+    "dompurify": "3.3.1",
     "fuzzysort": "catalog:",
     "katex": "0.16.27",
     "luxon": "catalog:",
-    "dompurify": "catalog:",
     "marked": "catalog:",
     "marked-katex-extension": "5.1.6",
     "marked-shiki": "catalog:",

+ 28 - 20
packages/ui/src/components/message-part.css

@@ -33,7 +33,7 @@
     border-radius: 6px;
     overflow: hidden;
     background: var(--surface-base);
-    border: 1px solid var(--border-base);
+    border: 1px solid var(--border-weak-base);
     transition: border-color 0.15s ease;
 
     &:hover {
@@ -416,7 +416,30 @@
     box-shadow: var(--shadow-xs-border-base);
     background-color: var(--surface-raised-base);
     overflow: visible;
+    overflow-anchor: none;
 
+    & > *:first-child {
+      border-top-left-radius: 6px;
+      border-top-right-radius: 6px;
+      overflow: hidden;
+    }
+
+    & > *:last-child {
+      border-bottom-left-radius: 6px;
+      border-bottom-right-radius: 6px;
+      overflow: hidden;
+    }
+
+    [data-component="collapsible"] {
+      border: none;
+    }
+
+    [data-component="card"] {
+      border: none;
+    }
+  }
+
+  &[data-permission="true"] {
     &::before {
       content: "";
       position: absolute;
@@ -438,26 +461,11 @@
       pointer-events: none;
       z-index: -1;
     }
+  }
 
-    & > *:first-child {
-      border-top-left-radius: 6px;
-      border-top-right-radius: 6px;
-      overflow: hidden;
-    }
-
-    & > *:last-child {
-      border-bottom-left-radius: 6px;
-      border-bottom-right-radius: 6px;
-      overflow: hidden;
-    }
-
-    [data-component="collapsible"] {
-      border: none;
-    }
-
-    [data-component="card"] {
-      border: none;
-    }
+  &[data-question="true"] {
+    background: var(--background-base);
+    border: 1px solid var(--border-weak-base);
   }
 }
 

+ 3 - 3
packages/ui/src/components/tooltip.css

@@ -5,7 +5,7 @@
 [data-slot="tooltip-keybind"] {
   display: flex;
   align-items: center;
-  gap: 8px;
+  gap: 12px;
 }
 
 [data-slot="tooltip-keybind-key"] {
@@ -18,11 +18,11 @@
 [data-component="tooltip"] {
   z-index: 1000;
   max-width: 320px;
-  border-radius: var(--radius-md);
+  border-radius: var(--radius-sm);
   background-color: var(--surface-float-base);
   color: var(--text-invert-strong);
   background: var(--surface-float-base);
-  padding: 6px 12px;
+  padding: 2px 8px;
   border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
 
   box-shadow: var(--shadow-md);