Merge branch 'dev' into kit/question-httpapi-spike
This commit is contained in:
commit
ff95ce7e62
21 changed files with 1754 additions and 1056 deletions
7
bun.lock
7
bun.lock
|
|
@ -371,6 +371,7 @@
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
|
"cli-sound": "1.1.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
|
|
@ -2668,6 +2669,8 @@
|
||||||
|
|
||||||
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
|
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
|
||||||
|
|
||||||
|
"cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="],
|
||||||
|
|
||||||
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||||
|
|
||||||
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||||
|
|
@ -3092,6 +3095,8 @@
|
||||||
|
|
||||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||||
|
|
||||||
|
"find-exec": ["find-exec@1.0.3", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="],
|
||||||
|
|
||||||
"find-my-way": ["find-my-way@9.5.0", "", { "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": ["find-my-way@9.5.0", "", { "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": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||||
|
|
@ -4412,6 +4417,8 @@
|
||||||
|
|
||||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||||
|
|
||||||
"shiki": ["shiki@3.20.0", "", { "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=="],
|
"shiki": ["shiki@3.20.0", "", { "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": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@
|
||||||
"bonjour-service": "1.3.0",
|
"bonjour-service": "1.3.0",
|
||||||
"bun-pty": "0.4.8",
|
"bun-pty": "0.4.8",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
|
"cli-sound": "1.1.3",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
"decimal.js": "10.5.0",
|
"decimal.js": "10.5.0",
|
||||||
|
|
|
||||||
4
packages/opencode/src/audio.d.ts
vendored
Normal file
4
packages/opencode/src/audio.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
declare module "*.wav" {
|
||||||
|
const file: string
|
||||||
|
export default file
|
||||||
|
}
|
||||||
BIN
packages/opencode/src/cli/cmd/tui/asset/charge.wav
Normal file
BIN
packages/opencode/src/cli/cmd/tui/asset/charge.wav
Normal file
Binary file not shown.
BIN
packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav
Normal file
BIN
packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav
Normal file
Binary file not shown.
BIN
packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav
Normal file
BIN
packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav
Normal file
Binary file not shown.
BIN
packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav
Normal file
BIN
packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav
Normal file
Binary file not shown.
|
|
@ -1,82 +1,630 @@
|
||||||
import { TextAttributes, RGBA } from "@opentui/core"
|
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||||
import { For, type JSX } from "solid-js"
|
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||||
import { useTheme, tint } from "@tui/context/theme"
|
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):
|
// Shadow markers (rendered chars in parens):
|
||||||
// _ = full shadow cell (space with bg=shadow)
|
// _ = full shadow cell (space with bg=shadow)
|
||||||
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
|
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
|
||||||
// ~ = shadow top only (▀ with fg=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() {
|
export function Logo() {
|
||||||
const { theme } = useTheme()
|
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 renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
const stop = () => {
|
||||||
const shadow = tint(theme.background, fg, 0.25)
|
if (!timer) return
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
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 attrs = bold ? TextAttributes.BOLD : undefined
|
||||||
const elements: JSX.Element[] = []
|
|
||||||
let i = 0
|
|
||||||
|
|
||||||
while (i < line.length) {
|
return [...line].map((char, i) => {
|
||||||
const rest = line.slice(i)
|
const h = field(off + i, y, frame)
|
||||||
const markerIndex = rest.search(SHADOW_MARKER)
|
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 (markerIndex === -1) {
|
if (char === "_") {
|
||||||
elements.push(
|
return (
|
||||||
<text fg={fg} attributes={attrs} selectable={false}>
|
<text
|
||||||
{rest}
|
fg={shade(ink, theme, s * 0.08)}
|
||||||
</text>,
|
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
|
||||||
)
|
attributes={attrs}
|
||||||
break
|
selectable={false}
|
||||||
}
|
>
|
||||||
|
{" "}
|
||||||
if (markerIndex > 0) {
|
</text>
|
||||||
elements.push(
|
|
||||||
<text fg={fg} attributes={attrs} selectable={false}>
|
|
||||||
{rest.slice(0, markerIndex)}
|
|
||||||
</text>,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const marker = rest[markerIndex]
|
if (char === "^") {
|
||||||
switch (marker) {
|
return (
|
||||||
case "_":
|
<text
|
||||||
elements.push(
|
fg={shade(ink, theme, n + p + e + b)}
|
||||||
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
|
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
||||||
{" "}
|
attributes={attrs}
|
||||||
</text>,
|
selectable={false}
|
||||||
)
|
>
|
||||||
break
|
▀
|
||||||
case "^":
|
</text>
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
i += markerIndex + 1
|
if (char === "~") {
|
||||||
|
return (
|
||||||
|
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||||
|
▀
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
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}>
|
<For each={logo.left}>
|
||||||
{(line, index) => (
|
{(line, index) => (
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
|
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
|
||||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
|
<box flexDirection="row">
|
||||||
|
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
|
||||||
156
packages/opencode/src/cli/cmd/tui/util/sound.ts
Normal file
156
packages/opencode/src/cli/cmd/tui/util/sound.ts
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -330,6 +330,7 @@ export namespace Ripgrep {
|
||||||
glob?: string[]
|
glob?: string[]
|
||||||
limit?: number
|
limit?: number
|
||||||
follow?: boolean
|
follow?: boolean
|
||||||
|
file?: string[]
|
||||||
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
|
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -351,6 +352,7 @@ export namespace Ripgrep {
|
||||||
maxDepth?: number
|
maxDepth?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
pattern?: string
|
pattern?: string
|
||||||
|
file?: string[]
|
||||||
}) {
|
}) {
|
||||||
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
|
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
|
||||||
if (input.follow) out.push("--follow")
|
if (input.follow) out.push("--follow")
|
||||||
|
|
@ -363,7 +365,7 @@ export namespace Ripgrep {
|
||||||
}
|
}
|
||||||
if (input.limit) out.push(`--max-count=${input.limit}`)
|
if (input.limit) out.push(`--max-count=${input.limit}`)
|
||||||
if (input.mode === "search") out.push("--no-messages")
|
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
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -405,6 +407,7 @@ export namespace Ripgrep {
|
||||||
glob?: string[]
|
glob?: string[]
|
||||||
limit?: number
|
limit?: number
|
||||||
follow?: boolean
|
follow?: boolean
|
||||||
|
file?: string[]
|
||||||
}) {
|
}) {
|
||||||
return yield* Effect.scoped(
|
return yield* Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -414,6 +417,7 @@ export namespace Ripgrep {
|
||||||
follow: input.follow,
|
follow: input.follow,
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
pattern: input.pattern,
|
pattern: input.pattern,
|
||||||
|
file: input.file,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handle = yield* spawner.spawn(
|
const handle = yield* spawner.spawn(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { ProjectID } from "@/project/schema"
|
import { ProjectID } from "@/project/schema"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
import { MessageID, SessionID } from "@/session/schema"
|
||||||
|
|
@ -308,18 +307,4 @@ export namespace Permission {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
|
|
@ -36,11 +37,15 @@ export const PermissionRoutes = lazy(() =>
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
const json = c.req.valid("json")
|
const json = c.req.valid("json")
|
||||||
await Permission.reply({
|
await AppRuntime.runPromise(
|
||||||
requestID: params.requestID,
|
Permission.Service.use((svc) =>
|
||||||
reply: json.reply,
|
svc.reply({
|
||||||
message: json.message,
|
requestID: params.requestID,
|
||||||
})
|
reply: json.reply,
|
||||||
|
message: json.message,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -62,7 +67,7 @@ export const PermissionRoutes = lazy(() =>
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const permissions = await Permission.list()
|
const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
|
||||||
return c.json(permissions)
|
return c.json(permissions)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1070,10 +1070,14 @@ export const SessionRoutes = lazy(() =>
|
||||||
validator("json", z.object({ response: Permission.Reply })),
|
validator("json", z.object({ response: Permission.Reply })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
Permission.reply({
|
await AppRuntime.runPromise(
|
||||||
requestID: params.permissionID,
|
Permission.Service.use((svc) =>
|
||||||
reply: c.req.valid("json").response,
|
svc.reply({
|
||||||
})
|
requestID: params.permissionID,
|
||||||
|
reply: c.req.valid("json").response,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { Cause, Effect, Layer, Record, Context } from "effect"
|
import { Context, Effect, Layer, Record } from "effect"
|
||||||
import * as Queue from "effect/Queue"
|
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
||||||
import { mergeDeep, pipe } from "remeda"
|
import { mergeDeep, pipe } from "remeda"
|
||||||
|
|
@ -21,10 +20,13 @@ import { Wildcard } from "@/util/wildcard"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
export namespace LLM {
|
export namespace LLM {
|
||||||
const log = Log.create({ service: "llm" })
|
const log = Log.create({ service: "llm" })
|
||||||
|
const perms = makeRuntime(Permission.Service, Permission.defaultLayer)
|
||||||
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
||||||
|
type Result = Awaited<ReturnType<typeof streamText>>
|
||||||
|
|
||||||
export type StreamInput = {
|
export type StreamInput = {
|
||||||
user: MessageV2.User
|
user: MessageV2.User
|
||||||
|
|
@ -45,7 +47,7 @@ export namespace LLM {
|
||||||
abort: AbortSignal
|
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 {
|
export interface Interface {
|
||||||
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
|
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
|
||||||
|
|
@ -53,12 +55,340 @@ export namespace LLM {
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer: Layer.Layer<Service, never, Auth.Service | Config.Service | Provider.Service | Plugin.Service> =
|
||||||
Service,
|
Layer.effect(
|
||||||
Effect.gen(function* () {
|
Service,
|
||||||
return Service.of({
|
Effect.gen(function* () {
|
||||||
stream(input) {
|
const auth = yield* Auth.Service
|
||||||
return Stream.scoped(
|
const config = yield* Config.Service
|
||||||
|
const provider = yield* Provider.Service
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
|
||||||
|
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 [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" },
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: move this to a proper hook
|
||||||
|
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
|
||||||
|
|
||||||
|
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 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 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 isWorkflow = language instanceof GitLabWorkflowLanguageModel
|
||||||
|
const messages = isOpenaiOauth
|
||||||
|
? input.messages
|
||||||
|
: isWorkflow
|
||||||
|
? input.messages
|
||||||
|
: [
|
||||||
|
...system.map(
|
||||||
|
(x): ModelMessage => ({
|
||||||
|
role: "system",
|
||||||
|
content: x,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...input.messages,
|
||||||
|
]
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { headers } = yield* plugin.trigger(
|
||||||
|
"chat.headers",
|
||||||
|
{
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: input.agent.name,
|
||||||
|
model: input.model,
|
||||||
|
provider: item,
|
||||||
|
message: input.user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const tools = resolveTools(input)
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
// 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: {} }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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 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",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ctrl = yield* Effect.acquireRelease(
|
const ctrl = yield* Effect.acquireRelease(
|
||||||
|
|
@ -66,7 +396,7 @@ export namespace LLM {
|
||||||
(ctrl) => Effect.sync(() => ctrl.abort()),
|
(ctrl) => Effect.sync(() => ctrl.abort()),
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal }))
|
const result = yield* run({ ...input, abort: ctrl.signal })
|
||||||
|
|
||||||
return Stream.fromAsyncIterable(result.fullStream, (e) =>
|
return Stream.fromAsyncIterable(result.fullStream, (e) =>
|
||||||
e instanceof Error ? e : new Error(String(e)),
|
e instanceof Error ? e : new Error(String(e)),
|
||||||
|
|
@ -74,335 +404,19 @@ export namespace LLM {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const defaultLayer = layer
|
return Service.of({ stream })
|
||||||
|
|
||||||
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(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const auth = yield* Auth.Service
|
|
||||||
const cfg = 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 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 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 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
|
|
||||||
const messages = isOpenaiOauth
|
|
||||||
? input.messages
|
|
||||||
: isWorkflow
|
|
||||||
? input.messages
|
|
||||||
: [
|
|
||||||
...system.map(
|
|
||||||
(x): ModelMessage => ({
|
|
||||||
role: "system",
|
|
||||||
content: x,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
...input.messages,
|
|
||||||
]
|
|
||||||
|
|
||||||
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 { headers } = await Plugin.trigger(
|
|
||||||
"chat.headers",
|
|
||||||
{
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
agent: input.agent.name,
|
|
||||||
model: input.model,
|
|
||||||
provider,
|
|
||||||
message: input.user,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const tools = resolveTools(input)
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
|
|
||||||
// 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: {} }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 args.params
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
experimental_telemetry: {
|
)
|
||||||
isEnabled: cfg.experimental?.openTelemetry,
|
|
||||||
metadata: {
|
export const defaultLayer = Layer.suspend(() =>
|
||||||
userId: cfg.username ?? "unknown",
|
layer.pipe(
|
||||||
sessionId: input.sessionID,
|
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">) {
|
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
||||||
const disabled = Permission.disabled(
|
const disabled = Permission.disabled(
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ export const GlobTool = Tool.define(
|
||||||
|
|
||||||
let search = params.path ?? Instance.directory
|
let search = params.path ?? Instance.directory
|
||||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
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" })
|
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
||||||
|
|
||||||
const limit = 100
|
const limit = 100
|
||||||
|
|
|
||||||
|
|
@ -51,19 +51,25 @@ export const GrepTool = Tool.define(
|
||||||
? (params.path ?? Instance.directory)
|
? (params.path ?? Instance.directory)
|
||||||
: path.join(Instance.directory, params.path ?? "."),
|
: 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({
|
const result = yield* rg.search({
|
||||||
cwd: searchPath,
|
cwd,
|
||||||
pattern: params.pattern,
|
pattern: params.pattern,
|
||||||
glob: params.include ? [params.include] : undefined,
|
glob: params.include ? [params.include] : undefined,
|
||||||
|
file,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.items.length === 0) return empty
|
if (result.items.length === 0) return empty
|
||||||
|
|
||||||
const rows = result.items.map((item) => ({
|
const rows = result.items.map((item) => ({
|
||||||
path: AppFileSystem.resolve(
|
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,
|
line: item.line_number,
|
||||||
text: item.lines.text,
|
text: item.lines.text,
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,25 @@ describe("Ripgrep.Service", () => {
|
||||||
expect(result.items[0]?.lines.text).toContain("needle")
|
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 () => {
|
test("files returns stream of filenames", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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", () => {
|
describe("session.llm.hasToolCalls", () => {
|
||||||
test("returns false for empty messages array", () => {
|
test("returns false for empty messages array", () => {
|
||||||
expect(LLM.hasToolCalls([])).toBe(false)
|
expect(LLM.hasToolCalls([])).toBe(false)
|
||||||
|
|
@ -355,20 +361,16 @@ describe("session.llm.stream", () => {
|
||||||
model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" },
|
model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" },
|
||||||
} satisfies MessageV2.User
|
} satisfies MessageV2.User
|
||||||
|
|
||||||
const stream = await LLM.stream({
|
await drain({
|
||||||
user,
|
user,
|
||||||
sessionID,
|
sessionID,
|
||||||
model: resolved,
|
model: resolved,
|
||||||
agent,
|
agent,
|
||||||
system: ["You are a helpful assistant."],
|
system: ["You are a helpful assistant."],
|
||||||
abort: new AbortController().signal,
|
|
||||||
messages: [{ role: "user", content: "Hello" }],
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
tools: {},
|
tools: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
for await (const _ of stream.fullStream) {
|
|
||||||
}
|
|
||||||
|
|
||||||
const capture = await request
|
const capture = await request
|
||||||
const body = capture.body
|
const body = capture.body
|
||||||
const headers = capture.headers
|
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 () => {
|
test("service stream cancellation cancels provider response body promptly", async () => {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
if (!server) throw new Error("Server not initialized")
|
if (!server) throw new Error("Server not initialized")
|
||||||
|
|
@ -518,8 +446,7 @@ describe("session.llm.stream", () => {
|
||||||
} satisfies MessageV2.User
|
} satisfies MessageV2.User
|
||||||
|
|
||||||
const ctrl = new AbortController()
|
const ctrl = new AbortController()
|
||||||
const { runPromiseExit } = makeRuntime(LLM.Service, LLM.defaultLayer)
|
const run = llm.runPromiseExit(
|
||||||
const run = runPromiseExit(
|
|
||||||
(svc) =>
|
(svc) =>
|
||||||
svc
|
svc
|
||||||
.stream({
|
.stream({
|
||||||
|
|
@ -610,14 +537,13 @@ describe("session.llm.stream", () => {
|
||||||
tools: { question: true },
|
tools: { question: true },
|
||||||
} satisfies MessageV2.User
|
} satisfies MessageV2.User
|
||||||
|
|
||||||
const stream = await LLM.stream({
|
await drain({
|
||||||
user,
|
user,
|
||||||
sessionID,
|
sessionID,
|
||||||
model: resolved,
|
model: resolved,
|
||||||
agent,
|
agent,
|
||||||
permission: [{ permission: "question", pattern: "*", action: "allow" }],
|
permission: [{ permission: "question", pattern: "*", action: "allow" }],
|
||||||
system: ["You are a helpful assistant."],
|
system: ["You are a helpful assistant."],
|
||||||
abort: new AbortController().signal,
|
|
||||||
messages: [{ role: "user", content: "Hello" }],
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
tools: {
|
tools: {
|
||||||
question: tool({
|
question: tool({
|
||||||
|
|
@ -628,9 +554,6 @@ describe("session.llm.stream", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
for await (const _ of stream.fullStream) {
|
|
||||||
}
|
|
||||||
|
|
||||||
const capture = await request
|
const capture = await request
|
||||||
const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
|
const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
|
||||||
expect(tools?.some((item) => item.function?.name === "question")).toBe(true)
|
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" },
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
|
||||||
} satisfies MessageV2.User
|
} satisfies MessageV2.User
|
||||||
|
|
||||||
const stream = await LLM.stream({
|
await drain({
|
||||||
user,
|
user,
|
||||||
sessionID,
|
sessionID,
|
||||||
model: resolved,
|
model: resolved,
|
||||||
agent,
|
agent,
|
||||||
system: ["You are a helpful assistant."],
|
system: ["You are a helpful assistant."],
|
||||||
abort: new AbortController().signal,
|
|
||||||
messages: [{ role: "user", content: "Hello" }],
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
tools: {},
|
tools: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
for await (const _ of stream.fullStream) {
|
|
||||||
}
|
|
||||||
|
|
||||||
const capture = await request
|
const capture = await request
|
||||||
const body = capture.body
|
const body = capture.body
|
||||||
|
|
||||||
|
|
@ -847,13 +766,12 @@ describe("session.llm.stream", () => {
|
||||||
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
|
||||||
} satisfies MessageV2.User
|
} satisfies MessageV2.User
|
||||||
|
|
||||||
const stream = await LLM.stream({
|
await drain({
|
||||||
user,
|
user,
|
||||||
sessionID,
|
sessionID,
|
||||||
model: resolved,
|
model: resolved,
|
||||||
agent,
|
agent,
|
||||||
system: ["You are a helpful assistant."],
|
system: ["You are a helpful assistant."],
|
||||||
abort: new AbortController().signal,
|
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -871,9 +789,6 @@ describe("session.llm.stream", () => {
|
||||||
tools: {},
|
tools: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
for await (const _ of stream.fullStream) {
|
|
||||||
}
|
|
||||||
|
|
||||||
const capture = await request
|
const capture = await request
|
||||||
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
|
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") },
|
model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
|
||||||
} satisfies MessageV2.User
|
} satisfies MessageV2.User
|
||||||
|
|
||||||
const stream = await LLM.stream({
|
await drain({
|
||||||
user,
|
user,
|
||||||
sessionID,
|
sessionID,
|
||||||
model: resolved,
|
model: resolved,
|
||||||
agent,
|
agent,
|
||||||
system: ["You are a helpful assistant."],
|
system: ["You are a helpful assistant."],
|
||||||
abort: new AbortController().signal,
|
|
||||||
messages: [{ role: "user", content: "Hello" }],
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
tools: {},
|
tools: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
for await (const _ of stream.fullStream) {
|
|
||||||
}
|
|
||||||
|
|
||||||
const capture = await request
|
const capture = await request
|
||||||
const body = capture.body
|
const body = capture.body
|
||||||
|
|
||||||
|
|
@ -1073,20 +984,16 @@ describe("session.llm.stream", () => {
|
||||||
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
|
model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
|
||||||
} satisfies MessageV2.User
|
} satisfies MessageV2.User
|
||||||
|
|
||||||
const stream = await LLM.stream({
|
await drain({
|
||||||
user,
|
user,
|
||||||
sessionID,
|
sessionID,
|
||||||
model: resolved,
|
model: resolved,
|
||||||
agent,
|
agent,
|
||||||
system: ["You are a helpful assistant."],
|
system: ["You are a helpful assistant."],
|
||||||
abort: new AbortController().signal,
|
|
||||||
messages: [{ role: "user", content: "Hello" }],
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
tools: {},
|
tools: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
for await (const _ of stream.fullStream) {
|
|
||||||
}
|
|
||||||
|
|
||||||
const capture = await request
|
const capture = await request
|
||||||
const body = capture.body
|
const body = capture.body
|
||||||
const config = body.generationConfig as
|
const config = body.generationConfig as
|
||||||
|
|
|
||||||
81
packages/opencode/test/tool/glob.test.ts
Normal file
81
packages/opencode/test/tool/glob.test.ts
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -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")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue