Przeglądaj źródła

Merge branch 'dev' into kit/question-httpapi-spike

Kit Langton 1 tydzień temu
rodzic
commit
ff95ce7e62

+ 7 - 0
bun.lock

@@ -371,6 +371,7 @@
         "bonjour-service": "1.3.0",
         "bun-pty": "0.4.8",
         "chokidar": "4.0.3",
+        "cli-sound": "1.1.3",
         "clipboardy": "4.0.0",
         "cross-spawn": "catalog:",
         "decimal.js": "10.5.0",
@@ -2668,6 +2669,8 @@
 
     "cli-cursor": ["[email protected]", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
 
+    "cli-sound": ["[email protected]", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="],
+
     "cli-spinners": ["[email protected]", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
 
     "cli-truncate": ["[email protected]", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
@@ -3092,6 +3095,8 @@
 
     "find-babel-config": ["[email protected]", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
 
+    "find-exec": ["[email protected]", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="],
+
     "find-my-way": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
 
     "find-my-way-ts": ["[email protected]", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
@@ -4412,6 +4417,8 @@
 
     "shebang-regex": ["[email protected]", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
 
+    "shell-quote": ["[email protected]", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
+
     "shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
 
     "shikiji": ["[email protected]", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],

+ 1 - 0
packages/opencode/package.json

@@ -128,6 +128,7 @@
     "bonjour-service": "1.3.0",
     "bun-pty": "0.4.8",
     "chokidar": "4.0.3",
+    "cli-sound": "1.1.3",
     "clipboardy": "4.0.0",
     "cross-spawn": "catalog:",
     "decimal.js": "10.5.0",

+ 4 - 0
packages/opencode/src/audio.d.ts

@@ -0,0 +1,4 @@
+declare module "*.wav" {
+  const file: string
+  export default file
+}

BIN
packages/opencode/src/cli/cmd/tui/asset/charge.wav


BIN
packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav


BIN
packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav


BIN
packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav


+ 600 - 52
packages/opencode/src/cli/cmd/tui/component/logo.tsx

@@ -1,82 +1,630 @@
-import { TextAttributes, RGBA } from "@opentui/core"
-import { For, type JSX } from "solid-js"
+import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
+import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
 import { useTheme, tint } from "@tui/context/theme"
-import { logo, marks } from "@/cli/logo"
+import { Sound } from "@tui/util/sound"
+import { logo } from "@/cli/logo"
 
 // Shadow markers (rendered chars in parens):
 // _ = full shadow cell (space with bg=shadow)
 // ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
 // ~ = shadow top only (▀ with fg=shadow)
-const SHADOW_MARKER = new RegExp(`[${marks}]`)
+const GAP = 1
+const WIDTH = 0.76
+const GAIN = 2.3
+const FLASH = 2.15
+const TRAIL = 0.28
+const SWELL = 0.24
+const WIDE = 1.85
+const DRIFT = 1.45
+const EXPAND = 1.62
+const LIFE = 1020
+const CHARGE = 3000
+const HOLD = 90
+const SINK = 40
+const ARC = 2.2
+const FORK = 1.2
+const DIM = 1.04
+const KICK = 0.86
+const LAG = 60
+const SUCK = 0.34
+const SHIMMER_IN = 60
+const SHIMMER_OUT = 2.8
+const TRACE = 0.033
+const TAIL = 1.8
+const TRACE_IN = 200
+const GLOW_OUT = 1600
+const PEAK = RGBA.fromInts(255, 255, 255)
+
+type Ring = {
+  x: number
+  y: number
+  at: number
+  force: number
+  kick: number
+}
+
+type Hold = {
+  x: number
+  y: number
+  at: number
+  glyph: number | undefined
+}
+
+type Release = {
+  x: number
+  y: number
+  at: number
+  glyph: number | undefined
+  level: number
+  rise: number
+}
+
+type Glow = {
+  glyph: number
+  at: number
+  force: number
+}
+
+type Frame = {
+  t: number
+  list: Ring[]
+  hold: Hold | undefined
+  release: Release | undefined
+  glow: Glow | undefined
+  spark: number
+}
+
+const LEFT = logo.left[0]?.length ?? 0
+const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
+const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
+const NEAR = [
+  [1, 0],
+  [1, 1],
+  [0, 1],
+  [-1, 1],
+  [-1, 0],
+  [-1, -1],
+  [0, -1],
+  [1, -1],
+] as const
+
+type Trace = {
+  glyph: number
+  i: number
+  l: number
+}
+
+function clamp(n: number) {
+  return Math.max(0, Math.min(1, n))
+}
+
+function lerp(a: number, b: number, t: number) {
+  return a + (b - a) * clamp(t)
+}
+
+function ease(t: number) {
+  const p = clamp(t)
+  return p * p * (3 - 2 * p)
+}
+
+function push(t: number) {
+  const p = clamp(t)
+  return ease(p * p)
+}
+
+function ramp(t: number, start: number, end: number) {
+  if (end <= start) return ease(t >= end ? 1 : 0)
+  return ease((t - start) / (end - start))
+}
+
+function glow(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
+  const mid = tint(base, theme.primary, 0.84)
+  const top = tint(theme.primary, PEAK, 0.96)
+  if (n <= 1) return tint(base, mid, Math.min(1, Math.sqrt(Math.max(0, n)) * 1.14))
+  return tint(mid, top, Math.min(1, 1 - Math.exp(-2.4 * (n - 1))))
+}
+
+function shade(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
+  if (n >= 0) return glow(base, theme, n)
+  return tint(base, theme.background, Math.min(0.82, -n * 0.64))
+}
+
+function ghost(n: number, scale: number) {
+  if (n < 0) return n
+  return n * scale
+}
+
+function noise(x: number, y: number, t: number) {
+  const n = Math.sin(x * 12.9898 + y * 78.233 + t * 0.043) * 43758.5453
+  return n - Math.floor(n)
+}
+
+function lit(char: string) {
+  return char !== " " && char !== "_" && char !== "~"
+}
+
+function key(x: number, y: number) {
+  return `${x},${y}`
+}
+
+function route(list: Array<{ x: number; y: number }>) {
+  const left = new Map(list.map((item) => [key(item.x, item.y), item]))
+  const path: Array<{ x: number; y: number }> = []
+  let cur = [...left.values()].sort((a, b) => a.y - b.y || a.x - b.x)[0]
+  let dir = { x: 1, y: 0 }
+
+  while (cur) {
+    path.push(cur)
+    left.delete(key(cur.x, cur.y))
+    if (!left.size) return path
+
+    const next = NEAR.map(([dx, dy]) => left.get(key(cur.x + dx, cur.y + dy)))
+      .filter((item): item is { x: number; y: number } => !!item)
+      .sort((a, b) => {
+        const ax = a.x - cur.x
+        const ay = a.y - cur.y
+        const bx = b.x - cur.x
+        const by = b.y - cur.y
+        const adot = ax * dir.x + ay * dir.y
+        const bdot = bx * dir.x + by * dir.y
+        if (adot !== bdot) return bdot - adot
+        return Math.abs(ax) + Math.abs(ay) - (Math.abs(bx) + Math.abs(by))
+      })[0]
+
+    if (!next) {
+      cur = [...left.values()].sort((a, b) => {
+        const da = (a.x - cur.x) ** 2 + (a.y - cur.y) ** 2
+        const db = (b.x - cur.x) ** 2 + (b.y - cur.y) ** 2
+        return da - db
+      })[0]
+      dir = { x: 1, y: 0 }
+      continue
+    }
+
+    dir = { x: next.x - cur.x, y: next.y - cur.y }
+    cur = next
+  }
+
+  return path
+}
+
+function mapGlyphs() {
+  const cells = [] as Array<{ x: number; y: number }>
+
+  for (let y = 0; y < FULL.length; y++) {
+    for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
+      if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
+    }
+  }
+
+  const all = new Map(cells.map((item) => [key(item.x, item.y), item]))
+  const seen = new Set<string>()
+  const glyph = new Map<string, number>()
+  const trace = new Map<string, Trace>()
+  const center = new Map<number, { x: number; y: number }>()
+  let id = 0
+
+  for (const item of cells) {
+    const start = key(item.x, item.y)
+    if (seen.has(start)) continue
+    const stack = [item]
+    const part = [] as Array<{ x: number; y: number }>
+    seen.add(start)
+
+    while (stack.length) {
+      const cur = stack.pop()!
+      part.push(cur)
+      glyph.set(key(cur.x, cur.y), id)
+      for (const [dx, dy] of NEAR) {
+        const next = all.get(key(cur.x + dx, cur.y + dy))
+        if (!next) continue
+        const mark = key(next.x, next.y)
+        if (seen.has(mark)) continue
+        seen.add(mark)
+        stack.push(next)
+      }
+    }
+
+    const path = route(part)
+    path.forEach((cell, i) => trace.set(key(cell.x, cell.y), { glyph: id, i, l: path.length }))
+    center.set(id, {
+      x: part.reduce((sum, item) => sum + item.x, 0) / part.length + 0.5,
+      y: (part.reduce((sum, item) => sum + item.y, 0) / part.length) * 2 + 1,
+    })
+    id++
+  }
+
+  return { glyph, trace, center }
+}
+
+const MAP = mapGlyphs()
+
+function shimmer(x: number, y: number, frame: Frame) {
+  return frame.list.reduce((best, item) => {
+    const age = frame.t - item.at
+    if (age < SHIMMER_IN || age > LIFE) return best
+    const dx = x + 0.5 - item.x
+    const dy = y * 2 + 1 - item.y
+    const dist = Math.hypot(dx, dy)
+    const p = age / LIFE
+    const r = SPAN * (1 - (1 - p) ** EXPAND)
+    const lag = r - dist
+    if (lag < 0.18 || lag > SHIMMER_OUT) return best
+    const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
+    const wobble = 0.5 + 0.5 * Math.sin(frame.t * 0.035 + x * 0.9 + y * 1.7)
+    const n = band * wobble * (1 - p) ** 1.45
+    if (n > best) return n
+    return best
+  }, 0)
+}
+
+function remain(x: number, y: number, item: Release, t: number) {
+  const age = t - item.at
+  if (age < 0 || age > LIFE) return 0
+  const p = age / LIFE
+  const dx = x + 0.5 - item.x - 0.5
+  const dy = y * 2 + 1 - item.y * 2 - 1
+  const dist = Math.hypot(dx, dy)
+  const r = SPAN * (1 - (1 - p) ** EXPAND)
+  if (dist > r) return 1
+  return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
+}
+
+function wave(x: number, y: number, frame: Frame, live: boolean) {
+  return frame.list.reduce((sum, item) => {
+    const age = frame.t - item.at
+    if (age < 0 || age > LIFE) return sum
+    const p = age / LIFE
+    const dx = x + 0.5 - item.x
+    const dy = y * 2 + 1 - item.y
+    const dist = Math.hypot(dx, dy)
+    const r = SPAN * (1 - (1 - p) ** EXPAND)
+    const fade = (1 - p) ** 1.32
+    const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
+    const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
+    const swell = Math.exp(-(((dist - Math.max(0, r - DRIFT)) / WIDE) ** 2)) * SWELL * fade * item.force
+    const trail = dist < r ? Math.exp(-(r - dist) / 2.4) * TRAIL * fade * item.force * lerp(0.92, 1.22, j) : 0
+    const flash = Math.exp(-(dist * dist) / 3.2) * FLASH * item.force * Math.max(0, 1 - age / 140) * lerp(0.95, 1.18, j)
+    const kick = Math.exp(-(dist * dist) / 2) * item.kick * Math.max(0, 1 - age / 100)
+    const suck = Math.exp(-(((dist - 1.25) / 0.75) ** 2)) * item.kick * SUCK * Math.max(0, 1 - age / 110)
+    const wake = live && dist < r ? Math.exp(-(r - dist) / 1.25) * 0.32 * fade : 0
+    return sum + edge + swell + trail + flash + wake - kick - suck
+  }, 0)
+}
+
+function field(x: number, y: number, frame: Frame) {
+  const held = frame.hold
+  const rest = frame.release
+  const item = held ?? rest
+  if (!item) return 0
+  const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
+  const level = held ? push(rise) : rest!.level
+  const body = rise
+  const storm = level * level
+  const sink = held ? ramp(frame.t - held.at, SINK, CHARGE) : rest!.rise
+  const dx = x + 0.5 - item.x - 0.5
+  const dy = y * 2 + 1 - item.y * 2 - 1
+  const dist = Math.hypot(dx, dy)
+  const angle = Math.atan2(dy, dx)
+  const spin = frame.t * lerp(0.008, 0.018, storm)
+  const dim = lerp(0, DIM, sink) * lerp(0.99, 1.01, 0.5 + 0.5 * Math.sin(frame.t * 0.014))
+  const core = Math.exp(-(dist * dist) / Math.max(0.22, lerp(0.22, 3.2, body))) * lerp(0.42, 2.45, body)
+  const shell =
+    Math.exp(-(((dist - lerp(0.16, 2.05, body)) / Math.max(0.18, lerp(0.18, 0.82, body))) ** 2)) * lerp(0.1, 0.95, body)
+  const ember =
+    Math.exp(-(((dist - lerp(0.45, 2.65, body)) / Math.max(0.14, lerp(0.14, 0.62, body))) ** 2)) *
+    lerp(0.02, 0.78, body)
+  const arc = Math.max(0, Math.cos(angle * 3 - spin + frame.spark * 2.2)) ** 8
+  const seam = Math.max(0, Math.cos(angle * 5 + spin * 1.55)) ** 12
+  const ring = Math.exp(-(((dist - lerp(1.05, 3, level)) / 0.48) ** 2)) * arc * lerp(0.03, 0.5 + ARC, storm)
+  const fork = Math.exp(-(((dist - (1.55 + storm * 2.1)) / 0.36) ** 2)) * seam * storm * FORK
+  const spark = Math.max(0, noise(x, y, frame.t) - lerp(0.94, 0.66, storm)) * lerp(0, 5.4, storm)
+  const glitch = spark * Math.exp(-dist / Math.max(1.2, 3.1 - storm))
+  const crack = Math.max(0, Math.cos((dx - dy) * 1.6 + spin * 2.1)) ** 18
+  const lash = crack * Math.exp(-(((dist - (1.95 + storm * 2)) / 0.28) ** 2)) * storm * 1.1
+  const flicker =
+    Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
+    Math.exp(-(dist * dist) / 0.15) *
+    lerp(0.08, 0.42, body)
+  const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
+  return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
+}
+
+function pick(x: number, y: number, frame: Frame) {
+  const held = frame.hold
+  const rest = frame.release
+  const item = held ?? rest
+  if (!item) return 0
+  const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
+  const dx = x + 0.5 - item.x - 0.5
+  const dy = y * 2 + 1 - item.y * 2 - 1
+  const dist = Math.hypot(dx, dy)
+  const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
+  return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
+}
+
+function select(x: number, y: number) {
+  const direct = MAP.glyph.get(key(x, y))
+  if (direct !== undefined) return direct
+
+  const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
+    (item): item is number => item !== undefined,
+  )
+  return near
+}
+
+function trace(x: number, y: number, frame: Frame) {
+  const held = frame.hold
+  const rest = frame.release
+  const item = held ?? rest
+  if (!item || item.glyph === undefined) return 0
+  const step = MAP.trace.get(key(x, y))
+  if (!step || step.glyph !== item.glyph || step.l < 2) return 0
+  const age = frame.t - item.at
+  const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
+  const appear = held ? ramp(age, 0, TRACE_IN) : 1
+  const speed = lerp(TRACE * 0.48, TRACE * 0.88, rise)
+  const head = (age * speed) % step.l
+  const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
+  const tail = (head - TAIL + step.l) % step.l
+  const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
+  const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
+  const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
+  const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
+  const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
+  return (core + glow + trail) * appear * fade
+}
+
+function bloom(x: number, y: number, frame: Frame) {
+  const item = frame.glow
+  if (!item) return 0
+  const glyph = MAP.glyph.get(key(x, y))
+  if (glyph !== item.glyph) return 0
+  const age = frame.t - item.at
+  if (age < 0 || age > GLOW_OUT) return 0
+  const p = age / GLOW_OUT
+  const flash = (1 - p) ** 2
+  const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
+  const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
+  const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
+  return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
+}
 
 export function Logo() {
   const { theme } = useTheme()
+  const [rings, setRings] = createSignal<Ring[]>([])
+  const [hold, setHold] = createSignal<Hold>()
+  const [release, setRelease] = createSignal<Release>()
+  const [glow, setGlow] = createSignal<Glow>()
+  const [now, setNow] = createSignal(0)
+  let box: BoxRenderable | undefined
+  let timer: ReturnType<typeof setInterval> | undefined
+  let hum = false
+
+  const stop = () => {
+    if (!timer) return
+    clearInterval(timer)
+    timer = undefined
+  }
 
-  const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
-    const shadow = tint(theme.background, fg, 0.25)
+  const tick = () => {
+    const t = performance.now()
+    setNow(t)
+    const item = hold()
+    if (item && !hum && t - item.at >= HOLD) {
+      hum = true
+      Sound.start()
+    }
+    if (item && t - item.at >= CHARGE) {
+      burst(item.x, item.y)
+    }
+    let live = false
+    setRings((list) => {
+      const next = list.filter((item) => t - item.at < LIFE)
+      live = next.length > 0
+      return next
+    })
+    const flash = glow()
+    if (flash && t - flash.at >= GLOW_OUT) {
+      setGlow(undefined)
+    }
+    if (!live) setRelease(undefined)
+    if (live || hold() || release() || glow()) return
+    stop()
+  }
+
+  const start = () => {
+    if (timer) return
+    timer = setInterval(tick, 16)
+  }
+
+  const hit = (x: number, y: number) => {
+    const char = FULL[y]?.[x]
+    return char !== undefined && char !== " "
+  }
+
+  const press = (x: number, y: number, t: number) => {
+    const last = hold()
+    if (last) burst(last.x, last.y)
+    setNow(t)
+    if (!last) setRelease(undefined)
+    setHold({ x, y, at: t, glyph: select(x, y) })
+    hum = false
+    start()
+  }
+
+  const burst = (x: number, y: number) => {
+    const item = hold()
+    if (!item) return
+    hum = false
+    const t = performance.now()
+    const age = t - item.at
+    const rise = ramp(age, HOLD, CHARGE)
+    const level = push(rise)
+    setHold(undefined)
+    setRelease({ x, y, at: t, glyph: item.glyph, level, rise })
+    if (item.glyph !== undefined) {
+      setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) })
+    }
+    setRings((list) => [
+      ...list,
+      {
+        x: x + 0.5,
+        y: y * 2 + 1,
+        at: t,
+        force: lerp(0.82, 2.55, level),
+        kick: lerp(0.32, 0.32 + KICK, level),
+      },
+    ])
+    setNow(t)
+    start()
+    Sound.pulse(lerp(0.8, 1, level))
+  }
+
+  const frame = createMemo(() => {
+    const t = now()
+    const item = hold()
+    return {
+      t,
+      list: rings(),
+      hold: item,
+      release: release(),
+      glow: glow(),
+      spark: item ? noise(item.x, item.y, t) : 0,
+    }
+  })
+
+  const dusk = createMemo(() => {
+    const base = frame()
+    const t = base.t - LAG
+    const item = base.hold
+    return {
+      t,
+      list: base.list,
+      hold: item,
+      release: base.release,
+      glow: base.glow,
+      spark: item ? noise(item.x, item.y, t) : 0,
+    }
+  })
+
+  const renderLine = (
+    line: string,
+    y: number,
+    ink: RGBA,
+    bold: boolean,
+    off: number,
+    frame: Frame,
+    dusk: Frame,
+  ): JSX.Element[] => {
+    const shadow = tint(theme.background, ink, 0.25)
     const attrs = bold ? TextAttributes.BOLD : undefined
-    const elements: JSX.Element[] = []
-    let i = 0
-
-    while (i < line.length) {
-      const rest = line.slice(i)
-      const markerIndex = rest.search(SHADOW_MARKER)
-
-      if (markerIndex === -1) {
-        elements.push(
-          <text fg={fg} attributes={attrs} selectable={false}>
-            {rest}
-          </text>,
+
+    return [...line].map((char, i) => {
+      const h = field(off + i, y, frame)
+      const n = wave(off + i, y, frame, lit(char)) + h
+      const s = wave(off + i, y, dusk, false) + h
+      const p = lit(char) ? pick(off + i, y, frame) : 0
+      const e = lit(char) ? trace(off + i, y, frame) : 0
+      const b = lit(char) ? bloom(off + i, y, frame) : 0
+      const q = shimmer(off + i, y, frame)
+
+      if (char === "_") {
+        return (
+          <text
+            fg={shade(ink, theme, s * 0.08)}
+            bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
+            attributes={attrs}
+            selectable={false}
+          >
+            {" "}
+          </text>
         )
-        break
       }
 
-      if (markerIndex > 0) {
-        elements.push(
-          <text fg={fg} attributes={attrs} selectable={false}>
-            {rest.slice(0, markerIndex)}
-          </text>,
+      if (char === "^") {
+        return (
+          <text
+            fg={shade(ink, theme, n + p + e + b)}
+            bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
+            attributes={attrs}
+            selectable={false}
+          >
+            ▀
+          </text>
         )
       }
 
-      const marker = rest[markerIndex]
-      switch (marker) {
-        case "_":
-          elements.push(
-            <text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
-              {" "}
-            </text>,
-          )
-          break
-        case "^":
-          elements.push(
-            <text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
-              ▀
-            </text>,
-          )
-          break
-        case "~":
-          elements.push(
-            <text fg={shadow} attributes={attrs} selectable={false}>
-              ▀
-            </text>,
-          )
-          break
+      if (char === "~") {
+        return (
+          <text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
+            ▀
+          </text>
+        )
       }
 
-      i += markerIndex + 1
+      if (char === " ") {
+        return (
+          <text fg={ink} attributes={attrs} selectable={false}>
+            {char}
+          </text>
+        )
+      }
+
+      return (
+        <text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
+          {char}
+        </text>
+      )
+    })
+  }
+
+  onCleanup(() => {
+    stop()
+    hum = false
+    Sound.dispose()
+  })
+
+  const mouse = (evt: MouseEvent) => {
+    if (!box) return
+    if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
+      const x = evt.x - box.x
+      const y = evt.y - box.y
+      if (!hit(x, y)) return
+      if (evt.type === "drag" && hold()) return
+      evt.preventDefault()
+      evt.stopPropagation()
+      const t = performance.now()
+      press(x, y, t)
+      return
     }
 
-    return elements
+    if (!hold()) return
+    if (evt.type === "up") {
+      const item = hold()
+      if (!item) return
+      burst(item.x, item.y)
+    }
   }
 
   return (
-    <box>
+    <box ref={(item: BoxRenderable) => (box = item)}>
+      <box
+        position="absolute"
+        top={0}
+        left={0}
+        width={FULL[0]?.length ?? 0}
+        height={FULL.length}
+        zIndex={1}
+        onMouse={mouse}
+      />
       <For each={logo.left}>
         {(line, index) => (
           <box flexDirection="row" gap={1}>
-            <box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
-            <box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
+            <box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
+            <box flexDirection="row">
+              {renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
+            </box>
           </box>
         )}
       </For>

+ 156 - 0
packages/opencode/src/cli/cmd/tui/util/sound.ts

@@ -0,0 +1,156 @@
+import { Player } from "cli-sound"
+import { mkdirSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { basename, join } from "node:path"
+import { Process } from "@/util/process"
+import { which } from "@/util/which"
+import pulseA from "../asset/pulse-a.wav" with { type: "file" }
+import pulseB from "../asset/pulse-b.wav" with { type: "file" }
+import pulseC from "../asset/pulse-c.wav" with { type: "file" }
+import charge from "../asset/charge.wav" with { type: "file" }
+
+const FILE = [pulseA, pulseB, pulseC]
+
+const HUM = charge
+const DIR = join(tmpdir(), "opencode-sfx")
+
+const LIST = [
+  "ffplay",
+  "mpv",
+  "mpg123",
+  "mpg321",
+  "mplayer",
+  "afplay",
+  "play",
+  "omxplayer",
+  "aplay",
+  "cmdmp3",
+  "cvlc",
+  "powershell.exe",
+] as const
+
+type Kind = (typeof LIST)[number]
+
+function args(kind: Kind, file: string, volume: number) {
+  if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file]
+  if (kind === "mpv")
+    return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file]
+  if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file]
+  if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file]
+  if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file]
+  if (kind === "play") return [kind, "-v", String(volume), file]
+  if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file]
+  return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
+}
+
+export namespace Sound {
+  let item: Player | null | undefined
+  let kind: Kind | null | undefined
+  let proc: Process.Child | undefined
+  let tail: ReturnType<typeof setTimeout> | undefined
+  let cache: Promise<{ hum: string; pulse: string[] }> | undefined
+  let seq = 0
+  let shot = 0
+
+  function load() {
+    if (item !== undefined) return item
+    try {
+      item = new Player({ volume: 0.35 })
+    } catch {
+      item = null
+    }
+    return item
+  }
+
+  async function file(path: string) {
+    mkdirSync(DIR, { recursive: true })
+    const next = join(DIR, basename(path))
+    const out = Bun.file(next)
+    if (await out.exists()) return next
+    await Bun.write(out, Bun.file(path))
+    return next
+  }
+
+  function asset() {
+    cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
+    return cache
+  }
+
+  function pick() {
+    if (kind !== undefined) return kind
+    kind = LIST.find((item) => which(item)) ?? null
+    return kind
+  }
+
+  function run(file: string, volume: number) {
+    const kind = pick()
+    if (!kind) return
+    return Process.spawn(args(kind, file, volume), {
+      stdin: "ignore",
+      stdout: "ignore",
+      stderr: "ignore",
+    })
+  }
+
+  function clear() {
+    if (!tail) return
+    clearTimeout(tail)
+    tail = undefined
+  }
+
+  function play(file: string, volume: number) {
+    const item = load()
+    if (!item) return run(file, volume)?.exited
+    return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
+  }
+
+  export function start() {
+    stop()
+    const id = ++seq
+    void asset().then(({ hum }) => {
+      if (id !== seq) return
+      const next = run(hum, 0.24)
+      if (!next) return
+      proc = next
+      void next.exited.then(
+        () => {
+          if (id !== seq) return
+          if (proc === next) proc = undefined
+        },
+        () => {
+          if (id !== seq) return
+          if (proc === next) proc = undefined
+        },
+      )
+    })
+  }
+
+  export function stop(delay = 0) {
+    seq++
+    clear()
+    if (!proc) return
+    const next = proc
+    if (delay <= 0) {
+      proc = undefined
+      void Process.stop(next).catch(() => undefined)
+      return
+    }
+    tail = setTimeout(() => {
+      tail = undefined
+      if (proc === next) proc = undefined
+      void Process.stop(next).catch(() => undefined)
+    }, delay)
+  }
+
+  export function pulse(scale = 1) {
+    stop(140)
+    const index = shot++ % FILE.length
+    void asset()
+      .then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
+      .catch(() => undefined)
+  }
+
+  export function dispose() {
+    stop()
+  }
+}

+ 5 - 1
packages/opencode/src/file/ripgrep.ts

@@ -330,6 +330,7 @@ export namespace Ripgrep {
       glob?: string[]
       limit?: number
       follow?: boolean
+      file?: string[]
     }) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
   }
 
@@ -351,6 +352,7 @@ export namespace Ripgrep {
         maxDepth?: number
         limit?: number
         pattern?: string
+        file?: string[]
       }) {
         const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
         if (input.follow) out.push("--follow")
@@ -363,7 +365,7 @@ export namespace Ripgrep {
         }
         if (input.limit) out.push(`--max-count=${input.limit}`)
         if (input.mode === "search") out.push("--no-messages")
-        if (input.pattern) out.push("--", input.pattern)
+        if (input.pattern) out.push("--", input.pattern, ...(input.file ?? []))
         return out
       })
 
@@ -405,6 +407,7 @@ export namespace Ripgrep {
         glob?: string[]
         limit?: number
         follow?: boolean
+        file?: string[]
       }) {
         return yield* Effect.scoped(
           Effect.gen(function* () {
@@ -414,6 +417,7 @@ export namespace Ripgrep {
               follow: input.follow,
               limit: input.limit,
               pattern: input.pattern,
+              file: input.file,
             })
 
             const handle = yield* spawner.spawn(

+ 0 - 15
packages/opencode/src/permission/index.ts

@@ -2,7 +2,6 @@ import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import { Config } from "@/config/config"
 import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
 import { ProjectID } from "@/project/schema"
 import { Instance } from "@/project/instance"
 import { MessageID, SessionID } from "@/session/schema"
@@ -308,18 +307,4 @@ export namespace Permission {
   }
 
   export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
-
-  export const { runPromise } = makeRuntime(Service, defaultLayer)
-
-  export async function ask(input: z.infer<typeof AskInput>) {
-    return runPromise((s) => s.ask(input))
-  }
-
-  export async function reply(input: z.infer<typeof ReplyInput>) {
-    return runPromise((s) => s.reply(input))
-  }
-
-  export async function list() {
-    return runPromise((s) => s.list())
-  }
 }

+ 11 - 6
packages/opencode/src/server/instance/permission.ts

@@ -1,6 +1,7 @@
 import { Hono } from "hono"
 import { describeRoute, validator, resolver } from "hono-openapi"
 import z from "zod"
+import { AppRuntime } from "@/effect/app-runtime"
 import { Permission } from "@/permission"
 import { PermissionID } from "@/permission/schema"
 import { errors } from "../error"
@@ -36,11 +37,15 @@ export const PermissionRoutes = lazy(() =>
       async (c) => {
         const params = c.req.valid("param")
         const json = c.req.valid("json")
-        await Permission.reply({
-          requestID: params.requestID,
-          reply: json.reply,
-          message: json.message,
-        })
+        await AppRuntime.runPromise(
+          Permission.Service.use((svc) =>
+            svc.reply({
+              requestID: params.requestID,
+              reply: json.reply,
+              message: json.message,
+            }),
+          ),
+        )
         return c.json(true)
       },
     )
@@ -62,7 +67,7 @@ export const PermissionRoutes = lazy(() =>
         },
       }),
       async (c) => {
-        const permissions = await Permission.list()
+        const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
         return c.json(permissions)
       },
     ),

+ 8 - 4
packages/opencode/src/server/instance/session.ts

@@ -1070,10 +1070,14 @@ export const SessionRoutes = lazy(() =>
       validator("json", z.object({ response: Permission.Reply })),
       async (c) => {
         const params = c.req.valid("param")
-        Permission.reply({
-          requestID: params.permissionID,
-          reply: c.req.valid("json").response,
-        })
+        await AppRuntime.runPromise(
+          Permission.Service.use((svc) =>
+            svc.reply({
+              requestID: params.permissionID,
+              reply: c.req.valid("json").response,
+            }),
+          ),
+        )
         return c.json(true)
       },
     ),

+ 345 - 331
packages/opencode/src/session/llm.ts

@@ -1,7 +1,6 @@
 import { Provider } from "@/provider/provider"
 import { Log } from "@/util/log"
-import { Cause, Effect, Layer, Record, Context } from "effect"
-import * as Queue from "effect/Queue"
+import { Context, Effect, Layer, Record } from "effect"
 import * as Stream from "effect/Stream"
 import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
 import { mergeDeep, pipe } from "remeda"
@@ -21,10 +20,13 @@ import { Wildcard } from "@/util/wildcard"
 import { SessionID } from "@/session/schema"
 import { Auth } from "@/auth"
 import { Installation } from "@/installation"
+import { makeRuntime } from "@/effect/run-service"
 
 export namespace LLM {
   const log = Log.create({ service: "llm" })
+  const perms = makeRuntime(Permission.Service, Permission.defaultLayer)
   export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
+  type Result = Awaited<ReturnType<typeof streamText>>
 
   export type StreamInput = {
     user: MessageV2.User
@@ -45,7 +47,7 @@ export namespace LLM {
     abort: AbortSignal
   }
 
-  export type Event = Awaited<ReturnType<typeof stream>>["fullStream"] extends AsyncIterable<infer T> ? T : never
+  export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
 
   export interface Interface {
     readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
@@ -53,356 +55,368 @@ export namespace LLM {
 
   export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
 
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      return Service.of({
-        stream(input) {
-          return Stream.scoped(
-            Stream.unwrap(
-              Effect.gen(function* () {
-                const ctrl = yield* Effect.acquireRelease(
-                  Effect.sync(() => new AbortController()),
-                  (ctrl) => Effect.sync(() => ctrl.abort()),
-                )
-
-                const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal }))
-
-                return Stream.fromAsyncIterable(result.fullStream, (e) =>
-                  e instanceof Error ? e : new Error(String(e)),
-                )
-              }),
-            ),
-          )
-        },
-      })
-    }),
-  )
-
-  export const defaultLayer = layer
-
-  export async function stream(input: StreamRequest) {
-    const l = log
-      .clone()
-      .tag("providerID", input.model.providerID)
-      .tag("modelID", input.model.id)
-      .tag("sessionID", input.sessionID)
-      .tag("small", (input.small ?? false).toString())
-      .tag("agent", input.agent.name)
-      .tag("mode", input.agent.mode)
-    l.info("stream", {
-      modelID: input.model.id,
-      providerID: input.model.providerID,
-    })
-    const [language, cfg, provider, info] = await Effect.runPromise(
+  export const layer: Layer.Layer<Service, never, Auth.Service | Config.Service | Provider.Service | Plugin.Service> =
+    Layer.effect(
+      Service,
       Effect.gen(function* () {
         const auth = yield* Auth.Service
-        const cfg = yield* Config.Service
+        const config = yield* Config.Service
         const provider = yield* Provider.Service
-        return yield* Effect.all(
-          [
-            provider.getLanguage(input.model),
-            cfg.get(),
-            provider.getProvider(input.model.providerID),
-            auth.get(input.model.providerID),
-          ],
-          { concurrency: "unbounded" },
-        )
-      }).pipe(Effect.provide(Layer.mergeAll(Auth.defaultLayer, Config.defaultLayer, Provider.defaultLayer))),
-    )
-    // TODO: move this to a proper hook
-    const isOpenaiOauth = provider.id === "openai" && info?.type === "oauth"
+        const plugin = yield* Plugin.Service
 
-    const system: string[] = []
-    system.push(
-      [
-        // use agent prompt otherwise provider prompt
-        ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
-        // any custom prompt passed into this call
-        ...input.system,
-        // any custom prompt from last user message
-        ...(input.user.system ? [input.user.system] : []),
-      ]
-        .filter((x) => x)
-        .join("\n"),
-    )
+        const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
+          const l = log
+            .clone()
+            .tag("providerID", input.model.providerID)
+            .tag("modelID", input.model.id)
+            .tag("sessionID", input.sessionID)
+            .tag("small", (input.small ?? false).toString())
+            .tag("agent", input.agent.name)
+            .tag("mode", input.agent.mode)
+          l.info("stream", {
+            modelID: input.model.id,
+            providerID: input.model.providerID,
+          })
 
-    const header = system[0]
-    await Plugin.trigger(
-      "experimental.chat.system.transform",
-      { sessionID: input.sessionID, model: input.model },
-      { system },
-    )
-    // rejoin to maintain 2-part structure for caching if header unchanged
-    if (system.length > 2 && system[0] === header) {
-      const rest = system.slice(1)
-      system.length = 0
-      system.push(header, rest.join("\n"))
-    }
+          const [language, cfg, item, info] = yield* Effect.all(
+            [
+              provider.getLanguage(input.model),
+              config.get(),
+              provider.getProvider(input.model.providerID),
+              auth.get(input.model.providerID),
+            ],
+            { concurrency: "unbounded" },
+          )
 
-    const variant =
-      !input.small && input.model.variants && input.user.model.variant
-        ? input.model.variants[input.user.model.variant]
-        : {}
-    const base = input.small
-      ? ProviderTransform.smallOptions(input.model)
-      : ProviderTransform.options({
-          model: input.model,
-          sessionID: input.sessionID,
-          providerOptions: provider.options,
-        })
-    const options: Record<string, any> = pipe(
-      base,
-      mergeDeep(input.model.options),
-      mergeDeep(input.agent.options),
-      mergeDeep(variant),
-    )
-    if (isOpenaiOauth) {
-      options.instructions = system.join("\n")
-    }
+          // TODO: move this to a proper hook
+          const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
 
-    const isWorkflow = language instanceof GitLabWorkflowLanguageModel
-    const messages = isOpenaiOauth
-      ? input.messages
-      : isWorkflow
-        ? input.messages
-        : [
-            ...system.map(
-              (x): ModelMessage => ({
-                role: "system",
-                content: x,
-              }),
-            ),
-            ...input.messages,
-          ]
+          const system: string[] = []
+          system.push(
+            [
+              // use agent prompt otherwise provider prompt
+              ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
+              // any custom prompt passed into this call
+              ...input.system,
+              // any custom prompt from last user message
+              ...(input.user.system ? [input.user.system] : []),
+            ]
+              .filter((x) => x)
+              .join("\n"),
+          )
 
-    const params = await Plugin.trigger(
-      "chat.params",
-      {
-        sessionID: input.sessionID,
-        agent: input.agent.name,
-        model: input.model,
-        provider,
-        message: input.user,
-      },
-      {
-        temperature: input.model.capabilities.temperature
-          ? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
-          : undefined,
-        topP: input.agent.topP ?? ProviderTransform.topP(input.model),
-        topK: ProviderTransform.topK(input.model),
-        maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
-        options,
-      },
-    )
+          const header = system[0]
+          yield* plugin.trigger(
+            "experimental.chat.system.transform",
+            { sessionID: input.sessionID, model: input.model },
+            { system },
+          )
+          // rejoin to maintain 2-part structure for caching if header unchanged
+          if (system.length > 2 && system[0] === header) {
+            const rest = system.slice(1)
+            system.length = 0
+            system.push(header, rest.join("\n"))
+          }
 
-    const { headers } = await Plugin.trigger(
-      "chat.headers",
-      {
-        sessionID: input.sessionID,
-        agent: input.agent.name,
-        model: input.model,
-        provider,
-        message: input.user,
-      },
-      {
-        headers: {},
-      },
-    )
+          const variant =
+            !input.small && input.model.variants && input.user.model.variant
+              ? input.model.variants[input.user.model.variant]
+              : {}
+          const base = input.small
+            ? ProviderTransform.smallOptions(input.model)
+            : ProviderTransform.options({
+                model: input.model,
+                sessionID: input.sessionID,
+                providerOptions: item.options,
+              })
+          const options: Record<string, any> = pipe(
+            base,
+            mergeDeep(input.model.options),
+            mergeDeep(input.agent.options),
+            mergeDeep(variant),
+          )
+          if (isOpenaiOauth) {
+            options.instructions = system.join("\n")
+          }
 
-    const tools = resolveTools(input)
+          const isWorkflow = language instanceof GitLabWorkflowLanguageModel
+          const messages = isOpenaiOauth
+            ? input.messages
+            : isWorkflow
+              ? input.messages
+              : [
+                  ...system.map(
+                    (x): ModelMessage => ({
+                      role: "system",
+                      content: x,
+                    }),
+                  ),
+                  ...input.messages,
+                ]
 
-    // LiteLLM and some Anthropic proxies require the tools parameter to be present
-    // when message history contains tool calls, even if no tools are being used.
-    // Add a dummy tool that is never called to satisfy this validation.
-    // This is enabled for:
-    // 1. Providers with "litellm" in their ID or API ID (auto-detected)
-    // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
-    const isLiteLLMProxy =
-      provider.options?.["litellmProxy"] === true ||
-      input.model.providerID.toLowerCase().includes("litellm") ||
-      input.model.api.id.toLowerCase().includes("litellm")
+          const params = yield* plugin.trigger(
+            "chat.params",
+            {
+              sessionID: input.sessionID,
+              agent: input.agent.name,
+              model: input.model,
+              provider: item,
+              message: input.user,
+            },
+            {
+              temperature: input.model.capabilities.temperature
+                ? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
+                : undefined,
+              topP: input.agent.topP ?? ProviderTransform.topP(input.model),
+              topK: ProviderTransform.topK(input.model),
+              maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
+              options,
+            },
+          )
 
-    // LiteLLM/Bedrock rejects requests where the message history contains tool
-    // calls but no tools param is present. When there are no active tools (e.g.
-    // during compaction), inject a stub tool to satisfy the validation requirement.
-    // The stub description explicitly tells the model not to call it.
-    if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
-      tools["_noop"] = tool({
-        description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
-        inputSchema: jsonSchema({
-          type: "object",
-          properties: {
-            reason: { type: "string", description: "Unused" },
-          },
-        }),
-        execute: async () => ({ output: "", title: "", metadata: {} }),
-      })
-    }
+          const { headers } = yield* plugin.trigger(
+            "chat.headers",
+            {
+              sessionID: input.sessionID,
+              agent: input.agent.name,
+              model: input.model,
+              provider: item,
+              message: input.user,
+            },
+            {
+              headers: {},
+            },
+          )
 
-    // Wire up toolExecutor for DWS workflow models so that tool calls
-    // from the workflow service are executed via opencode's tool system
-    // and results sent back over the WebSocket.
-    if (language instanceof GitLabWorkflowLanguageModel) {
-      const workflowModel = language as GitLabWorkflowLanguageModel & {
-        sessionID?: string
-        sessionPreapprovedTools?: string[]
-        approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
-      }
-      workflowModel.sessionID = input.sessionID
-      workflowModel.systemPrompt = system.join("\n")
-      workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
-        const t = tools[toolName]
-        if (!t || !t.execute) {
-          return { result: "", error: `Unknown tool: ${toolName}` }
-        }
-        try {
-          const result = await t.execute!(JSON.parse(argsJson), {
-            toolCallId: _requestID,
-            messages: input.messages,
-            abortSignal: input.abort,
-          })
-          const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
-          return {
-            result: output,
-            metadata: typeof result === "object" ? result?.metadata : undefined,
-            title: typeof result === "object" ? result?.title : undefined,
-          }
-        } catch (e: any) {
-          return { result: "", error: e.message ?? String(e) }
-        }
-      }
+          const tools = resolveTools(input)
 
-      const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
-      workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
-        const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
-        return !match || match.action !== "ask"
-      })
+          // LiteLLM and some Anthropic proxies require the tools parameter to be present
+          // when message history contains tool calls, even if no tools are being used.
+          // Add a dummy tool that is never called to satisfy this validation.
+          // This is enabled for:
+          // 1. Providers with "litellm" in their ID or API ID (auto-detected)
+          // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
+          const isLiteLLMProxy =
+            item.options?.["litellmProxy"] === true ||
+            input.model.providerID.toLowerCase().includes("litellm") ||
+            input.model.api.id.toLowerCase().includes("litellm")
 
-      const approvedToolsForSession = new Set<string>()
-      workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
-        const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
-        // Auto-approve tools that were already approved in this session
-        // (prevents infinite approval loops for server-side MCP tools)
-        if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
-          return { approved: true }
-        }
+          // LiteLLM/Bedrock rejects requests where the message history contains tool
+          // calls but no tools param is present. When there are no active tools (e.g.
+          // during compaction), inject a stub tool to satisfy the validation requirement.
+          // The stub description explicitly tells the model not to call it.
+          if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
+            tools["_noop"] = tool({
+              description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
+              inputSchema: jsonSchema({
+                type: "object",
+                properties: {
+                  reason: { type: "string", description: "Unused" },
+                },
+              }),
+              execute: async () => ({ output: "", title: "", metadata: {} }),
+            })
+          }
 
-        const id = PermissionID.ascending()
-        let reply: Permission.Reply | undefined
-        let unsub: (() => void) | undefined
-        try {
-          unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
-            if (evt.properties.requestID === id) reply = evt.properties.reply
-          })
-          const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
-            try {
-              const parsed = JSON.parse(t.args) as Record<string, unknown>
-              const title = (parsed?.title ?? parsed?.name ?? "") as string
-              return title ? `${t.name}: ${title}` : t.name
-            } catch {
-              return t.name
+          // Wire up toolExecutor for DWS workflow models so that tool calls
+          // from the workflow service are executed via opencode's tool system
+          // and results sent back over the WebSocket.
+          if (language instanceof GitLabWorkflowLanguageModel) {
+            const workflowModel = language as GitLabWorkflowLanguageModel & {
+              sessionID?: string
+              sessionPreapprovedTools?: string[]
+              approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
+            }
+            workflowModel.sessionID = input.sessionID
+            workflowModel.systemPrompt = system.join("\n")
+            workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
+              const t = tools[toolName]
+              if (!t || !t.execute) {
+                return { result: "", error: `Unknown tool: ${toolName}` }
+              }
+              try {
+                const result = await t.execute!(JSON.parse(argsJson), {
+                  toolCallId: _requestID,
+                  messages: input.messages,
+                  abortSignal: input.abort,
+                })
+                const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
+                return {
+                  result: output,
+                  metadata: typeof result === "object" ? result?.metadata : undefined,
+                  title: typeof result === "object" ? result?.title : undefined,
+                }
+              } catch (e: any) {
+                return { result: "", error: e.message ?? String(e) }
+              }
             }
-          })
-          const uniquePatterns = [...new Set(toolPatterns)] as string[]
-          await Permission.ask({
-            id,
-            sessionID: SessionID.make(input.sessionID),
-            permission: "workflow_tool_approval",
-            patterns: uniquePatterns,
-            metadata: { tools: approvalTools },
-            always: uniquePatterns,
-            ruleset: [],
-          })
-          for (const name of uniqueNames) approvedToolsForSession.add(name)
-          workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
-          return { approved: true }
-        } catch {
-          return { approved: false }
-        } finally {
-          unsub?.()
-        }
-      })
-    }
 
-    return streamText({
-      onError(error) {
-        l.error("stream error", {
-          error,
-        })
-      },
-      async experimental_repairToolCall(failed) {
-        const lower = failed.toolCall.toolName.toLowerCase()
-        if (lower !== failed.toolCall.toolName && tools[lower]) {
-          l.info("repairing tool call", {
-            tool: failed.toolCall.toolName,
-            repaired: lower,
-          })
-          return {
-            ...failed.toolCall,
-            toolName: lower,
+            const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
+            workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
+              const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
+              return !match || match.action !== "ask"
+            })
+
+            const approvedToolsForSession = new Set<string>()
+            workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
+              const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
+              // Auto-approve tools that were already approved in this session
+              // (prevents infinite approval loops for server-side MCP tools)
+              if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
+                return { approved: true }
+              }
+
+              const id = PermissionID.ascending()
+              let reply: Permission.Reply | undefined
+              let unsub: (() => void) | undefined
+              try {
+                unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
+                  if (evt.properties.requestID === id) reply = evt.properties.reply
+                })
+                const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
+                  try {
+                    const parsed = JSON.parse(t.args) as Record<string, unknown>
+                    const title = (parsed?.title ?? parsed?.name ?? "") as string
+                    return title ? `${t.name}: ${title}` : t.name
+                  } catch {
+                    return t.name
+                  }
+                })
+                const uniquePatterns = [...new Set(toolPatterns)] as string[]
+                await perms.runPromise((svc) =>
+                  svc.ask({
+                    id,
+                    sessionID: SessionID.make(input.sessionID),
+                    permission: "workflow_tool_approval",
+                    patterns: uniquePatterns,
+                    metadata: { tools: approvalTools },
+                    always: uniquePatterns,
+                    ruleset: [],
+                  }),
+                )
+                for (const name of uniqueNames) approvedToolsForSession.add(name)
+                workflowModel.sessionPreapprovedTools = [
+                  ...(workflowModel.sessionPreapprovedTools ?? []),
+                  ...uniqueNames,
+                ]
+                return { approved: true }
+              } catch {
+                return { approved: false }
+              } finally {
+                unsub?.()
+              }
+            })
           }
-        }
-        return {
-          ...failed.toolCall,
-          input: JSON.stringify({
-            tool: failed.toolCall.toolName,
-            error: failed.error.message,
-          }),
-          toolName: "invalid",
-        }
-      },
-      temperature: params.temperature,
-      topP: params.topP,
-      topK: params.topK,
-      providerOptions: ProviderTransform.providerOptions(input.model, params.options),
-      activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
-      tools,
-      toolChoice: input.toolChoice,
-      maxOutputTokens: params.maxOutputTokens,
-      abortSignal: input.abort,
-      headers: {
-        ...(input.model.providerID.startsWith("opencode")
-          ? {
-              "x-opencode-project": Instance.project.id,
-              "x-opencode-session": input.sessionID,
-              "x-opencode-request": input.user.id,
-              "x-opencode-client": Flag.OPENCODE_CLIENT,
-            }
-          : {
-              "x-session-affinity": input.sessionID,
-              ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
-              "User-Agent": `opencode/${Installation.VERSION}`,
-            }),
-        ...input.model.headers,
-        ...headers,
-      },
-      maxRetries: input.retries ?? 0,
-      messages,
-      model: wrapLanguageModel({
-        model: language,
-        middleware: [
-          {
-            specificationVersion: "v3" as const,
-            async transformParams(args) {
-              if (args.type === "stream") {
-                // @ts-expect-error
-                args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
+
+          return streamText({
+            onError(error) {
+              l.error("stream error", {
+                error,
+              })
+            },
+            async experimental_repairToolCall(failed) {
+              const lower = failed.toolCall.toolName.toLowerCase()
+              if (lower !== failed.toolCall.toolName && tools[lower]) {
+                l.info("repairing tool call", {
+                  tool: failed.toolCall.toolName,
+                  repaired: lower,
+                })
+                return {
+                  ...failed.toolCall,
+                  toolName: lower,
+                }
+              }
+              return {
+                ...failed.toolCall,
+                input: JSON.stringify({
+                  tool: failed.toolCall.toolName,
+                  error: failed.error.message,
+                }),
+                toolName: "invalid",
               }
-              return args.params
             },
-          },
-        ],
+            temperature: params.temperature,
+            topP: params.topP,
+            topK: params.topK,
+            providerOptions: ProviderTransform.providerOptions(input.model, params.options),
+            activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
+            tools,
+            toolChoice: input.toolChoice,
+            maxOutputTokens: params.maxOutputTokens,
+            abortSignal: input.abort,
+            headers: {
+              ...(input.model.providerID.startsWith("opencode")
+                ? {
+                    "x-opencode-project": Instance.project.id,
+                    "x-opencode-session": input.sessionID,
+                    "x-opencode-request": input.user.id,
+                    "x-opencode-client": Flag.OPENCODE_CLIENT,
+                  }
+                : {
+                    "x-session-affinity": input.sessionID,
+                    ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
+                    "User-Agent": `opencode/${Installation.VERSION}`,
+                  }),
+              ...input.model.headers,
+              ...headers,
+            },
+            maxRetries: input.retries ?? 0,
+            messages,
+            model: wrapLanguageModel({
+              model: language,
+              middleware: [
+                {
+                  specificationVersion: "v3" as const,
+                  async transformParams(args) {
+                    if (args.type === "stream") {
+                      // @ts-expect-error
+                      args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
+                    }
+                    return args.params
+                  },
+                },
+              ],
+            }),
+            experimental_telemetry: {
+              isEnabled: cfg.experimental?.openTelemetry,
+              metadata: {
+                userId: cfg.username ?? "unknown",
+                sessionId: input.sessionID,
+              },
+            },
+          })
+        })
+
+        const stream: Interface["stream"] = (input) =>
+          Stream.scoped(
+            Stream.unwrap(
+              Effect.gen(function* () {
+                const ctrl = yield* Effect.acquireRelease(
+                  Effect.sync(() => new AbortController()),
+                  (ctrl) => Effect.sync(() => ctrl.abort()),
+                )
+
+                const result = yield* run({ ...input, abort: ctrl.signal })
+
+                return Stream.fromAsyncIterable(result.fullStream, (e) =>
+                  e instanceof Error ? e : new Error(String(e)),
+                )
+              }),
+            ),
+          )
+
+        return Service.of({ stream })
       }),
-      experimental_telemetry: {
-        isEnabled: cfg.experimental?.openTelemetry,
-        metadata: {
-          userId: cfg.username ?? "unknown",
-          sessionId: input.sessionID,
-        },
-      },
-    })
-  }
+    )
+
+  export const defaultLayer = Layer.suspend(() =>
+    layer.pipe(
+      Layer.provide(Auth.defaultLayer),
+      Layer.provide(Config.defaultLayer),
+      Layer.provide(Provider.defaultLayer),
+      Layer.provide(Plugin.defaultLayer),
+    ),
+  )
 
   function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
     const disabled = Permission.disabled(

+ 4 - 0
packages/opencode/src/tool/glob.ts

@@ -40,6 +40,10 @@ export const GlobTool = Tool.define(
 
           let search = params.path ?? Instance.directory
           search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
+          const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
+          if (info?.type === "File") {
+            throw new Error(`glob path must be a directory: ${search}`)
+          }
           yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
 
           const limit = 100

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

@@ -51,19 +51,25 @@ export const GrepTool = Tool.define(
               ? (params.path ?? Instance.directory)
               : path.join(Instance.directory, params.path ?? "."),
           )
-          yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
+          const info = yield* fs.stat(searchPath).pipe(Effect.catch(() => Effect.succeed(undefined)))
+          const cwd = info?.type === "Directory" ? searchPath : path.dirname(searchPath)
+          const file = info?.type === "Directory" ? undefined : [searchPath]
+          yield* assertExternalDirectoryEffect(ctx, searchPath, {
+            kind: info?.type === "Directory" ? "directory" : "file",
+          })
 
           const result = yield* rg.search({
-            cwd: searchPath,
+            cwd,
             pattern: params.pattern,
             glob: params.include ? [params.include] : undefined,
+            file,
           })
 
           if (result.items.length === 0) return empty
 
           const rows = result.items.map((item) => ({
             path: AppFileSystem.resolve(
-              path.isAbsolute(item.path.text) ? item.path.text : path.join(searchPath, item.path.text),
+              path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text),
             ),
             line: item.line_number,
             text: item.lines.text,

+ 19 - 0
packages/opencode/test/file/ripgrep.test.ts

@@ -76,6 +76,25 @@ describe("Ripgrep.Service", () => {
     expect(result.items[0]?.lines.text).toContain("needle")
   })
 
+  test("search supports explicit file targets", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
+        await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
+      },
+    })
+
+    const file = path.join(tmp.path, "match.ts")
+    const result = await Effect.gen(function* () {
+      const rg = yield* Ripgrep.Service
+      return yield* rg.search({ cwd: tmp.path, pattern: "needle", file: [file] })
+    }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
+
+    expect(result.partial).toBe(false)
+    expect(result.items).toHaveLength(1)
+    expect(result.items[0]?.path.text).toBe(file)
+  })
+
   test("files returns stream of filenames", async () => {
     await using tmp = await tmpdir({
       init: async (dir) => {

Plik diff jest za duży
+ 412 - 469
packages/opencode/test/permission/next.test.ts


+ 13 - 106
packages/opencode/test/session/llm.test.ts

@@ -26,6 +26,12 @@ async function getModel(providerID: ProviderID, modelID: ModelID) {
   )
 }
 
+const llm = makeRuntime(LLM.Service, LLM.defaultLayer)
+
+async function drain(input: LLM.StreamInput) {
+  return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain))
+}
+
 describe("session.llm.hasToolCalls", () => {
   test("returns false for empty messages array", () => {
     expect(LLM.hasToolCalls([])).toBe(false)
@@ -355,20 +361,16 @@ describe("session.llm.stream", () => {
           model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" },
         } satisfies MessageV2.User
 
-        const stream = await LLM.stream({
+        await drain({
           user,
           sessionID,
           model: resolved,
           agent,
           system: ["You are a helpful assistant."],
-          abort: new AbortController().signal,
           messages: [{ role: "user", content: "Hello" }],
           tools: {},
         })
 
-        for await (const _ of stream.fullStream) {
-        }
-
         const capture = await request
         const body = capture.body
         const headers = capture.headers
@@ -393,80 +395,6 @@ describe("session.llm.stream", () => {
     })
   })
 
-  test("raw stream abort signal cancels provider response body promptly", async () => {
-    const server = state.server
-    if (!server) throw new Error("Server not initialized")
-
-    const providerID = "alibaba"
-    const modelID = "qwen-plus"
-    const fixture = await loadFixture(providerID, modelID)
-    const model = fixture.model
-    const pending = waitStreamingRequest("/chat/completions")
-
-    await using tmp = await tmpdir({
-      init: async (dir) => {
-        await Bun.write(
-          path.join(dir, "opencode.json"),
-          JSON.stringify({
-            $schema: "https://opencode.ai/config.json",
-            enabled_providers: [providerID],
-            provider: {
-              [providerID]: {
-                options: {
-                  apiKey: "test-key",
-                  baseURL: `${server.url.origin}/v1`,
-                },
-              },
-            },
-          }),
-        )
-      },
-    })
-
-    await Instance.provide({
-      directory: tmp.path,
-      fn: async () => {
-        const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id))
-        const sessionID = SessionID.make("session-test-raw-abort")
-        const agent = {
-          name: "test",
-          mode: "primary",
-          options: {},
-          permission: [{ permission: "*", pattern: "*", action: "allow" }],
-        } satisfies Agent.Info
-        const user = {
-          id: MessageID.make("user-raw-abort"),
-          sessionID,
-          role: "user",
-          time: { created: Date.now() },
-          agent: agent.name,
-          model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
-        } satisfies MessageV2.User
-
-        const ctrl = new AbortController()
-        const result = await LLM.stream({
-          user,
-          sessionID,
-          model: resolved,
-          agent,
-          system: ["You are a helpful assistant."],
-          abort: ctrl.signal,
-          messages: [{ role: "user", content: "Hello" }],
-          tools: {},
-        })
-
-        const iter = result.fullStream[Symbol.asyncIterator]()
-        await pending.request
-        await iter.next()
-        ctrl.abort()
-
-        await Promise.race([pending.responseCanceled, timeout(500)])
-        await Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined)
-        await iter.return?.()
-      },
-    })
-  })
-
   test("service stream cancellation cancels provider response body promptly", async () => {
     const server = state.server
     if (!server) throw new Error("Server not initialized")
@@ -518,8 +446,7 @@ describe("session.llm.stream", () => {
         } satisfies MessageV2.User
 
         const ctrl = new AbortController()
-        const { runPromiseExit } = makeRuntime(LLM.Service, LLM.defaultLayer)
-        const run = runPromiseExit(
+        const run = llm.runPromiseExit(
           (svc) =>
             svc
               .stream({
@@ -610,14 +537,13 @@ describe("session.llm.stream", () => {
           tools: { question: true },
         } satisfies MessageV2.User
 
-        const stream = await LLM.stream({
+        await drain({
           user,
           sessionID,
           model: resolved,
           agent,
           permission: [{ permission: "question", pattern: "*", action: "allow" }],
           system: ["You are a helpful assistant."],
-          abort: new AbortController().signal,
           messages: [{ role: "user", content: "Hello" }],
           tools: {
             question: tool({
@@ -628,9 +554,6 @@ describe("session.llm.stream", () => {
           },
         })
 
-        for await (const _ of stream.fullStream) {
-        }
-
         const capture = await request
         const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
         expect(tools?.some((item) => item.function?.name === "question")).toBe(true)
@@ -728,20 +651,16 @@ describe("session.llm.stream", () => {
           model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
         } satisfies MessageV2.User
 
-        const stream = await LLM.stream({
+        await drain({
           user,
           sessionID,
           model: resolved,
           agent,
           system: ["You are a helpful assistant."],
-          abort: new AbortController().signal,
           messages: [{ role: "user", content: "Hello" }],
           tools: {},
         })
 
-        for await (const _ of stream.fullStream) {
-        }
-
         const capture = await request
         const body = capture.body
 
@@ -847,13 +766,12 @@ describe("session.llm.stream", () => {
           model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
         } satisfies MessageV2.User
 
-        const stream = await LLM.stream({
+        await drain({
           user,
           sessionID,
           model: resolved,
           agent,
           system: ["You are a helpful assistant."],
-          abort: new AbortController().signal,
           messages: [
             {
               role: "user",
@@ -871,9 +789,6 @@ describe("session.llm.stream", () => {
           tools: {},
         })
 
-        for await (const _ of stream.fullStream) {
-        }
-
         const capture = await request
         expect(capture.url.pathname.endsWith("/responses")).toBe(true)
       },
@@ -972,20 +887,16 @@ describe("session.llm.stream", () => {
           model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
         } satisfies MessageV2.User
 
-        const stream = await LLM.stream({
+        await drain({
           user,
           sessionID,
           model: resolved,
           agent,
           system: ["You are a helpful assistant."],
-          abort: new AbortController().signal,
           messages: [{ role: "user", content: "Hello" }],
           tools: {},
         })
 
-        for await (const _ of stream.fullStream) {
-        }
-
         const capture = await request
         const body = capture.body
 
@@ -1073,20 +984,16 @@ describe("session.llm.stream", () => {
           model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
         } satisfies MessageV2.User
 
-        const stream = await LLM.stream({
+        await drain({
           user,
           sessionID,
           model: resolved,
           agent,
           system: ["You are a helpful assistant."],
-          abort: new AbortController().signal,
           messages: [{ role: "user", content: "Hello" }],
           tools: {},
         })
 
-        for await (const _ of stream.fullStream) {
-        }
-
         const capture = await request
         const body = capture.body
         const config = body.generationConfig as

+ 81 - 0
packages/opencode/test/tool/glob.test.ts

@@ -0,0 +1,81 @@
+import { describe, expect } from "bun:test"
+import path from "path"
+import { Cause, Effect, Exit, Layer } from "effect"
+import { GlobTool } from "../../src/tool/glob"
+import { SessionID, MessageID } from "../../src/session/schema"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { Ripgrep } from "../../src/file/ripgrep"
+import { AppFileSystem } from "../../src/filesystem"
+import { Truncate } from "../../src/tool/truncate"
+import { Agent } from "../../src/agent/agent"
+import { provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+const it = testEffect(
+  Layer.mergeAll(
+    CrossSpawnSpawner.defaultLayer,
+    AppFileSystem.defaultLayer,
+    Ripgrep.defaultLayer,
+    Truncate.defaultLayer,
+    Agent.defaultLayer,
+  ),
+)
+
+const ctx = {
+  sessionID: SessionID.make("ses_test"),
+  messageID: MessageID.make(""),
+  callID: "",
+  agent: "build",
+  abort: AbortSignal.any([]),
+  messages: [],
+  metadata: () => Effect.void,
+  ask: () => Effect.void,
+}
+
+describe("tool.glob", () => {
+  it.live("matches files from a directory path", () =>
+    provideTmpdirInstance((dir) =>
+      Effect.gen(function* () {
+        yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n"))
+        yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n"))
+        const info = yield* GlobTool
+        const glob = yield* info.init()
+        const result = yield* glob.execute(
+          {
+            pattern: "*.ts",
+            path: dir,
+          },
+          ctx,
+        )
+        expect(result.metadata.count).toBe(1)
+        expect(result.output).toContain(path.join(dir, "a.ts"))
+        expect(result.output).not.toContain(path.join(dir, "b.txt"))
+      }),
+    ),
+  )
+
+  it.live("rejects exact file paths", () =>
+    provideTmpdirInstance((dir) =>
+      Effect.gen(function* () {
+        const file = path.join(dir, "a.ts")
+        yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
+        const info = yield* GlobTool
+        const glob = yield* info.init()
+        const exit = yield* glob
+          .execute(
+            {
+              pattern: "*.ts",
+              path: file,
+            },
+            ctx,
+          )
+          .pipe(Effect.exit)
+        expect(Exit.isFailure(exit)).toBe(true)
+        if (Exit.isFailure(exit)) {
+          const err = Cause.squash(exit.cause)
+          expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
+        }
+      }),
+    ),
+  )
+})

+ 21 - 0
packages/opencode/test/tool/grep.test.ts

@@ -90,4 +90,25 @@ describe("tool.grep", () => {
       }),
     ),
   )
+
+  it.live("supports exact file paths", () =>
+    provideTmpdirInstance((dir) =>
+      Effect.gen(function* () {
+        const file = path.join(dir, "test.txt")
+        yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
+        const info = yield* GrepTool
+        const grep = yield* info.init()
+        const result = yield* grep.execute(
+          {
+            pattern: "line2",
+            path: file,
+          },
+          ctx,
+        )
+        expect(result.metadata.matches).toBe(1)
+        expect(result.output).toContain(file)
+        expect(result.output).toContain("Line 2: line2")
+      }),
+    ),
+  )
 })

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików