Просмотр исходного кода

Merge branch 'dev' into sqlite2

Dax 2 месяцев назад
Родитель
Сommit
8c30f551e2

+ 9 - 0
.github/workflows/deploy.yml

@@ -21,6 +21,15 @@ jobs:
         with:
           node-version: "24"
 
+      # Workaround for Pulumi version conflict:
+      # GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
+      # from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
+      # SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
+      # Removing the system language plugin forces SST to use its bundled compatible version.
+      # TODO: Remove when sst supports Pulumi >3.210.0
+      - name: Fix Pulumi version conflict
+        run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
+
       - run: bun sst deploy --stage=${{ github.ref_name }}
         env:
           CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

+ 1 - 7
.github/workflows/nix-hashes.yml

@@ -6,13 +6,7 @@ permissions:
 on:
   workflow_dispatch:
   push:
-    paths:
-      - "bun.lock"
-      - "package.json"
-      - "packages/*/package.json"
-      - "flake.lock"
-      - ".github/workflows/nix-hashes.yml"
-  pull_request:
+    branches: [dev]
     paths:
       - "bun.lock"
       - "package.json"

+ 2 - 0
.github/workflows/typecheck.yml

@@ -1,6 +1,8 @@
 name: typecheck
 
 on:
+  push:
+    branches: [dev]
   pull_request:
     branches: [dev]
   workflow_dispatch:

+ 1 - 0
AGENTS.md

@@ -13,6 +13,7 @@
 - Prefer single word variable names where possible
 - Use Bun APIs when possible, like `Bun.file()`
 - Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
+- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
 
 ### Naming
 

+ 10 - 10
bun.lock

@@ -298,8 +298,8 @@
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/util": "workspace:*",
         "@openrouter/ai-sdk-provider": "1.5.4",
-        "@opentui/core": "0.1.75",
-        "@opentui/solid": "0.1.75",
+        "@opentui/core": "0.1.76",
+        "@opentui/solid": "0.1.76",
         "@parcel/watcher": "2.5.1",
         "@pierre/diffs": "catalog:",
         "@solid-primitives/event-bus": "1.1.2",
@@ -1246,21 +1246,21 @@
 
     "@opentelemetry/api": ["@opentelemetry/[email protected]", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
 
-    "@opentui/core": ["@opentui/[email protected]5", "", { "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": ["@opentui/[email protected]6", "", { "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.76", "@opentui/core-darwin-x64": "0.1.76", "@opentui/core-linux-arm64": "0.1.76", "@opentui/core-linux-x64": "0.1.76", "@opentui/core-win32-arm64": "0.1.76", "@opentui/core-win32-x64": "0.1.76", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Y4f4KH6Mbj0J6+MorcvtHSeT+Lbs3YDPEQcTRTWsPOqWz3A0F5/+OPtZKho1EtLWQqJflCWdf/JQj5A3We3qRg=="],
 
-    "@opentui/core-darwin-arm64": ["@opentui/[email protected]5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="],
+    "@opentui/core-darwin-arm64": ["@opentui/[email protected]6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aRYNOPRKL6URovSPhRvXtBV7SqdmR7s6hmEBSdXiYo39AozTcvKviF8gJWXQATcKDEcOtRir6TsASzDq5Coheg=="],
 
-    "@opentui/core-darwin-x64": ["@opentui/[email protected]5", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="],
+    "@opentui/core-darwin-x64": ["@opentui/[email protected]6", "", { "os": "darwin", "cpu": "x64" }, "sha512-KFaRvVQ0Wr1PgaexUkF3KYt41pYmxGJW3otENeE6WDa/nXe2AElibPFRjqSEH54YrY5Q84SDI77/wGP4LZ/Wyg=="],
 
-    "@opentui/core-linux-arm64": ["@opentui/[email protected]5", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="],
+    "@opentui/core-linux-arm64": ["@opentui/[email protected]6", "", { "os": "linux", "cpu": "arm64" }, "sha512-s7v+GDwavfieZg8xZV4V07fXFrHfFq4UZ2JpYFDUgNs9vFp+++WUjh3pfbfE+2ldbhcG2iOtuiV9aG1tVCbTEg=="],
 
-    "@opentui/core-linux-x64": ["@opentui/[email protected]5", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="],
+    "@opentui/core-linux-x64": ["@opentui/[email protected]6", "", { "os": "linux", "cpu": "x64" }, "sha512-ugwuHpmvdKRHXKVsrC3zRYY6bg2JxVCzAQ1NOiWRLq3N3N4ha6BHAkHMCeHgR/ZI4R8MSRB6vtJRVI1F9VHxjA=="],
 
-    "@opentui/core-win32-arm64": ["@opentui/[email protected]5", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="],
+    "@opentui/core-win32-arm64": ["@opentui/[email protected]6", "", { "os": "win32", "cpu": "arm64" }, "sha512-wjpRWrerPItb5E1fP4SAcNMxQp1yEukbgvP4Azip836/ixxbghL6y0P57Ya/rv7QYLrkNZXoQ+tr9oXhPH5BVA=="],
 
-    "@opentui/core-win32-x64": ["@opentui/[email protected]5", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
+    "@opentui/core-win32-x64": ["@opentui/[email protected]6", "", { "os": "win32", "cpu": "x64" }, "sha512-2YjtZJdd3iO+SY9NKocE4/Pm9VolzAthUOXjpK4Pv5pnR9hBpPvX7FFSXJTfASj7y2j1tATWrlQLocZCFP/oMA=="],
 
-    "@opentui/solid": ["@opentui/[email protected]5", "", { "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=="],
+    "@opentui/solid": ["@opentui/[email protected]6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.76", "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-PiD62FGoPoVLFpY4g08i4UYlx4sGR2OmHUPj6CuZZwy2UJD4fKn1RYV+kAPHfUW4qN/88I1k/w/Dniz1WvXrAQ=="],
 
     "@oslojs/asn1": ["@oslojs/[email protected]", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
 

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-PrEuJ7fh/rmd8ewXsnKgQ/Zu8qYsrb3D7maL9ZlVAnE=",
-    "aarch64-linux": "sha256-7+ktHTXtHMWWA9tNxX8Fb1um1JFKHQuIjIJPuveqL94=",
-    "aarch64-darwin": "sha256-KirMU9LO7sB0ufVaWF9Y+DtxbBVyapE02GP2ytW9xLg=",
-    "x86_64-darwin": "sha256-wZJt3htmjWvwRCIoD0rkr3+8cW/xjfXfz8rdHxcFplo="
+    "x86_64-linux": "sha256-aRFzPzgu32XgNSk8S2z4glTlgHqEmOLZHlBQSIYIMvY=",
+    "aarch64-linux": "sha256-aCZLkmRrCa0bli0jgsaLcC5GlZdjQPbb6xD6Fc03eX8=",
+    "aarch64-darwin": "sha256-oZOOR6k8MmabNVDQNY5ywR06rRycdnXZL+gUucKSQ+g=",
+    "x86_64-darwin": "sha256-LXIcLnjn+1eTFWIsQ9W0U2orGm59P/L470O0KFFkRHg="
   }
 }

+ 2 - 2
packages/opencode/package.json

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

+ 1 - 0
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -186,6 +186,7 @@ function App() {
   const route = useRoute()
   const dimensions = useTerminalDimensions()
   const renderer = useRenderer()
+  Clipboard.setRenderer(renderer)
   renderer.disableStdoutInterception()
   const dialog = useDialog()
   const local = useLocal()

+ 2 - 8
packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

@@ -10,7 +10,7 @@ import { useSDK } from "../context/sdk"
 import { DialogSessionRename } from "./dialog-session-rename"
 import { useKV } from "../context/kv"
 import { createDebouncedSignal } from "../util/signal"
-import "opentui-spinner/solid"
+import { Spinner } from "./spinner"
 
 export function DialogSessionList() {
   const dialog = useDialog()
@@ -32,8 +32,6 @@ export function DialogSessionList() {
 
   const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
 
-  const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
-
   const sessions = createMemo(() => searchResults() ?? sync.data.session)
 
   const options = createMemo(() => {
@@ -56,11 +54,7 @@ export function DialogSessionList() {
           value: x.id,
           category,
           footer: Locale.time(x.time.updated),
-          gutter: isWorking ? (
-            <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
-              <spinner frames={spinnerFrames} interval={80} color={theme.primary} />
-            </Show>
-          ) : undefined,
+          gutter: isWorking ? <Spinner /> : undefined,
         }
       })
   })

+ 24 - 0
packages/opencode/src/cli/cmd/tui/component/spinner.tsx

@@ -0,0 +1,24 @@
+import { Show } from "solid-js"
+import { useTheme } from "../context/theme"
+import { useKV } from "../context/kv"
+import type { JSX } from "@opentui/solid"
+import type { RGBA } from "@opentui/core"
+import "opentui-spinner/solid"
+
+const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
+
+export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
+  const { theme } = useTheme()
+  const kv = useKV()
+  const color = () => props.color ?? theme.textMuted
+  return (
+    <Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
+      <box flexDirection="row" gap={1}>
+        <spinner frames={frames} interval={80} color={color()} />
+        <Show when={props.children}>
+          <text fg={color()}>{props.children}</text>
+        </Show>
+      </box>
+    </Show>
+  )
+}

+ 42 - 11
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -16,6 +16,7 @@ import path from "path"
 import { useRoute, useRouteData } from "@tui/context/route"
 import { useSync } from "@tui/context/sync"
 import { SplitBorder } from "@tui/component/border"
+import { Spinner } from "@tui/component/spinner"
 import { useTheme } from "@tui/context/theme"
 import {
   BoxRenderable,
@@ -1559,7 +1560,13 @@ function InlineTool(props: {
   )
 }
 
-function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void; part?: ToolPart }) {
+function BlockTool(props: {
+  title: string
+  children: JSX.Element
+  onClick?: () => void
+  part?: ToolPart
+  spinner?: boolean
+}) {
   const { theme } = useTheme()
   const renderer = useRenderer()
   const [hover, setHover] = createSignal(false)
@@ -1582,9 +1589,16 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
         props.onClick?.()
       }}
     >
-      <text paddingLeft={3} fg={theme.textMuted}>
-        {props.title}
-      </text>
+      <Show
+        when={props.spinner}
+        fallback={
+          <text paddingLeft={3} fg={theme.textMuted}>
+            {props.title}
+          </text>
+        }
+      >
+        <Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
+      </Show>
       {props.children}
       <Show when={error()}>
         <text fg={theme.error}>{error()}</text>
@@ -1799,9 +1813,21 @@ function Task(props: ToolProps<typeof TaskTool>) {
   const keybind = useKeybind()
   const { navigate } = useRoute()
   const local = useLocal()
+  const sync = useSync()
 
-  const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending"))
-  const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown"))
+  const tools = createMemo(() => {
+    const sessionID = props.metadata.sessionId
+    const msgs = sync.data.message[sessionID ?? ""] ?? []
+    return msgs.flatMap((msg) =>
+      (sync.data.part[msg.id] ?? [])
+        .filter((part): part is ToolPart => part.type === "tool")
+        .map((part) => ({ tool: part.tool, state: part.state })),
+    )
+  })
+
+  const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending"))
+
+  const isRunning = createMemo(() => props.part.state.status === "running")
 
   return (
     <Switch>
@@ -1814,16 +1840,21 @@ function Task(props: ToolProps<typeof TaskTool>) {
               : undefined
           }
           part={props.part}
+          spinner={isRunning()}
         >
           <box>
             <text style={{ fg: theme.textMuted }}>
-              {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls)
+              {props.input.description} ({tools().length} toolcalls)
             </text>
             <Show when={current()}>
-              <text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}>
-                └ {Locale.titlecase(current()!.tool)}{" "}
-                {current()!.state.status === "completed" ? current()!.state.title : ""}
-              </text>
+              {(item) => {
+                const title = item().state.status === "completed" ? (item().state as any).title : ""
+                return (
+                  <text style={{ fg: item().state.status === "error" ? theme.error : theme.textMuted }}>
+                    └ {Locale.titlecase(item().tool)} {title}
+                  </text>
+                )
+              }}
             </Show>
           </box>
           <Show when={props.metadata.sessionId}>

+ 11 - 15
packages/opencode/src/cli/cmd/tui/util/clipboard.ts

@@ -1,24 +1,12 @@
 import { $ } from "bun"
+import type { CliRenderer } from "@opentui/core"
 import { platform, release } from "os"
 import clipboardy from "clipboardy"
 import { lazy } from "../../../../util/lazy.js"
 import { tmpdir } from "os"
 import path from "path"
 
-/**
- * Writes text to clipboard via OSC 52 escape sequence.
- * This allows clipboard operations to work over SSH by having
- * the terminal emulator handle the clipboard locally.
- */
-function writeOsc52(text: string): void {
-  if (!process.stdout.isTTY) return
-  const base64 = Buffer.from(text).toString("base64")
-  const osc52 = `\x1b]52;c;${base64}\x07`
-  // tmux and screen require DCS passthrough wrapping
-  const passthrough = process.env["TMUX"] || process.env["STY"]
-  const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
-  process.stdout.write(sequence)
-}
+const rendererRef = { current: undefined as CliRenderer | undefined }
 
 export namespace Clipboard {
   export interface Content {
@@ -26,6 +14,10 @@ export namespace Clipboard {
     mime: string
   }
 
+  export function setRenderer(renderer: CliRenderer | undefined): void {
+    rendererRef.current = renderer
+  }
+
   export async function read(): Promise<Content | undefined> {
     const os = platform()
 
@@ -154,7 +146,11 @@ export namespace Clipboard {
   })
 
   export async function copy(text: string): Promise<void> {
-    writeOsc52(text)
+    const renderer = rendererRef.current
+    if (renderer) {
+      const copied = renderer.copyToClipboardOSC52(text)
+      if (copied) return
+    }
     await getCopyMethod()(text)
   }
 }

+ 33 - 77
packages/opencode/src/file/ripgrep.ts

@@ -275,100 +275,56 @@ export namespace Ripgrep {
     log.info("tree", input)
     const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
     interface Node {
-      path: string[]
-      children: Node[]
+      name: string
+      children: Map<string, Node>
     }
 
-    function getPath(node: Node, parts: string[], create: boolean) {
-      if (parts.length === 0) return node
-      let current = node
-      for (const part of parts) {
-        let existing = current.children.find((x) => x.path.at(-1) === part)
-        if (!existing) {
-          if (!create) return
-          existing = {
-            path: current.path.concat(part),
-            children: [],
-          }
-          current.children.push(existing)
-        }
-        current = existing
-      }
-      return current
+    function dir(node: Node, name: string) {
+      const existing = node.children.get(name)
+      if (existing) return existing
+      const next = { name, children: new Map() }
+      node.children.set(name, next)
+      return next
     }
 
-    const root: Node = {
-      path: [],
-      children: [],
-    }
+    const root: Node = { name: "", children: new Map() }
     for (const file of files) {
       if (file.includes(".opencode")) continue
       const parts = file.split(path.sep)
-      getPath(root, parts, true)
-    }
-
-    function sort(node: Node) {
-      node.children.sort((a, b) => {
-        if (!a.children.length && b.children.length) return 1
-        if (!b.children.length && a.children.length) return -1
-        return a.path.at(-1)!.localeCompare(b.path.at(-1)!)
-      })
-      for (const child of node.children) {
-        sort(child)
+      if (parts.length < 2) continue
+      let node = root
+      for (const part of parts.slice(0, -1)) {
+        node = dir(node, part)
       }
     }
-    sort(root)
-
-    let current = [root]
-    const result: Node = {
-      path: [],
-      children: [],
-    }
 
-    let processed = 0
-    const limit = input.limit ?? 50
-    while (current.length > 0) {
-      const next = []
-      for (const node of current) {
-        if (node.children.length) next.push(...node.children)
-      }
-      const max = Math.max(...current.map((x) => x.children.length))
-      for (let i = 0; i < max && processed < limit; i++) {
-        for (const node of current) {
-          const child = node.children[i]
-          if (!child) continue
-          getPath(result, child.path, true)
-          processed++
-          if (processed >= limit) break
-        }
-      }
-      if (processed >= limit) {
-        for (const node of [...current, ...next]) {
-          const compare = getPath(result, node.path, false)
-          if (!compare) continue
-          if (compare?.children.length !== node.children.length) {
-            const diff = node.children.length - compare.children.length
-            compare.children.push({
-              path: compare.path.concat(`[${diff} truncated]`),
-              children: [],
-            })
-          }
-        }
-        break
+    function count(node: Node): number {
+      let total = 0
+      for (const child of node.children.values()) {
+        total += 1 + count(child)
       }
-      current = next
+      return total
     }
 
+    const total = count(root)
+    const limit = input.limit ?? total
     const lines: string[] = []
+    const queue: { node: Node; path: string }[] = []
+    for (const child of Array.from(root.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
+      queue.push({ node: child, path: child.name })
+    }
 
-    function render(node: Node, depth: number) {
-      const indent = "\t".repeat(depth)
-      lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : ""))
-      for (const child of node.children) {
-        render(child, depth + 1)
+    let used = 0
+    for (let i = 0; i < queue.length && used < limit; i++) {
+      const { node, path } = queue[i]
+      lines.push(path)
+      used++
+      for (const child of Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
+        queue.push({ node: child, path: `${path}/${child.name}` })
       }
     }
-    result.children.map((x) => render(x, 0))
+
+    if (total > used) lines.push(`[${total - used} truncated]`)
 
     return lines.join("\n")
   }

+ 1 - 0
packages/opencode/src/session/processor.ts

@@ -379,6 +379,7 @@ export namespace SessionProcessor {
               sessionID: input.assistantMessage.sessionID,
               error: input.assistantMessage.error,
             })
+            SessionStatus.set(input.sessionID, { type: "idle" })
           }
           if (snapshot) {
             const patch = await Snapshot.patch(snapshot)

+ 7 - 4
packages/opencode/src/session/prompt.ts

@@ -62,7 +62,7 @@ export namespace SessionPrompt {
           abort: AbortController
           callbacks: {
             resolve(input: MessageV2.WithParts): void
-            reject(): void
+            reject(reason?: any): void
           }[]
         }
       > = {}
@@ -72,7 +72,7 @@ export namespace SessionPrompt {
       for (const item of Object.values(current)) {
         item.abort.abort()
         for (const callback of item.callbacks) {
-          callback.reject()
+          callback.reject(new DOMException("Aborted", "AbortError"))
         }
       }
     },
@@ -249,10 +249,13 @@ export namespace SessionPrompt {
     log.info("cancel", { sessionID })
     const s = state()
     const match = s[sessionID]
-    if (!match) return
+    if (!match) {
+      SessionStatus.set(sessionID, { type: "idle" })
+      return
+    }
     match.abort.abort()
     for (const item of match.callbacks) {
-      item.reject()
+      item.reject(new DOMException("Aborted", "AbortError"))
     }
     delete s[sessionID]
     SessionStatus.set(sessionID, { type: "idle" })

+ 3 - 3
packages/opencode/src/session/system.ts

@@ -36,16 +36,16 @@ export namespace SystemPrompt {
         `  Platform: ${process.platform}`,
         `  Today's date: ${new Date().toDateString()}`,
         `</env>`,
-        `<files>`,
+        `<directories>`,
         `  ${
           project.vcs === "git" && false
             ? await Ripgrep.tree({
                 cwd: Instance.directory,
-                limit: 200,
+                limit: 50,
               })
             : ""
         }`,
-        `</files>`,
+        `</directories>`,
       ].join("\n"),
     ]
   }

+ 0 - 1
packages/opencode/src/tool/task.ts

@@ -130,7 +130,6 @@ export const TaskTool = Tool.define("task", async (ctx) => {
         ctx.metadata({
           title: params.description,
           metadata: {
-            summary: Object.values(parts).sort((a, b) => a.id.localeCompare(b.id)),
             sessionId: session.id,
             model,
           },

+ 1 - 1
packages/opencode/test/snapshot/snapshot.test.ts

@@ -292,7 +292,7 @@ test("unicode filenames", async () => {
   })
 })
 
-test("unicode filenames modification and restore", async () => {
+test.skip("unicode filenames modification and restore", async () => {
   await using tmp = await bootstrap()
   await Instance.provide({
     directory: tmp.path,

+ 2 - 8
packages/plugin/package.json

@@ -9,14 +9,8 @@
     "build": "tsc"
   },
   "exports": {
-    ".": {
-      "types": "./dist/index.d.ts",
-      "import": "./dist/index.js"
-    },
-    "./tool": {
-      "types": "./dist/tool.d.ts",
-      "import": "./dist/tool.js"
-    }
+    ".": "./src/index.ts",
+    "./tool": "./src/tool.ts"
   },
   "files": [
     "dist"

+ 12 - 1
script/beta.ts

@@ -124,7 +124,18 @@ async function main() {
     throw new Error(`${failed.length} PR(s) failed to merge`)
   }
 
-  console.log("\nForce pushing beta branch...")
+  console.log("\nChecking if beta branch has changes...")
+  await $`git fetch origin beta`
+
+  const localTree = await $`git rev-parse beta^{tree}`.text()
+  const remoteTree = await $`git rev-parse origin/beta^{tree}`.text()
+
+  if (localTree.trim() === remoteTree.trim()) {
+    console.log("Beta branch has identical contents, no push needed")
+    return
+  }
+
+  console.log("Force pushing beta branch...")
   await $`git push origin beta --force --no-verify`
 
   console.log("Successfully synced beta branch")