Vendor opencode source for docker build

This commit is contained in:
southseact-3d
2026-02-07 20:54:46 +00:00
parent b30ff1cfa4
commit efda260214
3195 changed files with 387717 additions and 1 deletions

View File

@@ -0,0 +1,11 @@
const defaults: Record<string, string> = {
ask: "var(--icon-agent-ask-base)",
build: "var(--icon-agent-build-base)",
docs: "var(--icon-agent-docs-base)",
plan: "var(--icon-agent-plan-base)",
}
export function agentColor(name: string, custom?: string) {
if (custom) return custom
return defaults[name] ?? defaults[name.toLowerCase()]
}

View File

@@ -0,0 +1,138 @@
type Point = { x: number; y: number }
export function createAim(props: {
enabled: () => boolean
active: () => string | undefined
el: () => HTMLElement | undefined
onActivate: (id: string) => void
delay?: number
max?: number
tolerance?: number
edge?: number
}) {
const state = {
locs: [] as Point[],
timer: undefined as number | undefined,
pending: undefined as string | undefined,
over: undefined as string | undefined,
last: undefined as Point | undefined,
}
const delay = props.delay ?? 250
const max = props.max ?? 4
const tolerance = props.tolerance ?? 80
const edge = props.edge ?? 18
const cancel = () => {
if (state.timer !== undefined) clearTimeout(state.timer)
state.timer = undefined
state.pending = undefined
}
const reset = () => {
cancel()
state.over = undefined
state.last = undefined
state.locs.length = 0
}
const move = (event: MouseEvent) => {
if (!props.enabled()) return
const el = props.el()
if (!el) return
const rect = el.getBoundingClientRect()
const x = event.clientX
const y = event.clientY
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return
state.locs.push({ x, y })
if (state.locs.length > max) state.locs.shift()
}
const wait = () => {
if (!props.enabled()) return 0
if (!props.active()) return 0
const el = props.el()
if (!el) return 0
if (state.locs.length < 2) return 0
const rect = el.getBoundingClientRect()
const loc = state.locs[state.locs.length - 1]
if (!loc) return 0
const prev = state.locs[0] ?? loc
if (prev.x < rect.left || prev.x > rect.right || prev.y < rect.top || prev.y > rect.bottom) return 0
if (state.last && loc.x === state.last.x && loc.y === state.last.y) return 0
if (rect.right - loc.x <= edge) {
state.last = loc
return delay
}
const upper = { x: rect.right, y: rect.top - tolerance }
const lower = { x: rect.right, y: rect.bottom + tolerance }
const slope = (a: Point, b: Point) => (b.y - a.y) / (b.x - a.x)
const decreasing = slope(loc, upper)
const increasing = slope(loc, lower)
const prevDecreasing = slope(prev, upper)
const prevIncreasing = slope(prev, lower)
if (decreasing < prevDecreasing && increasing > prevIncreasing) {
state.last = loc
return delay
}
state.last = undefined
return 0
}
const activate = (id: string) => {
cancel()
props.onActivate(id)
}
const request = (id: string) => {
if (!id) return
if (props.active() === id) return
if (!props.active()) {
activate(id)
return
}
const ms = wait()
if (ms === 0) {
activate(id)
return
}
cancel()
state.pending = id
state.timer = window.setTimeout(() => {
state.timer = undefined
if (state.pending !== id) return
state.pending = undefined
if (!props.enabled()) return
if (!props.active()) return
if (state.over !== id) return
props.onActivate(id)
}, ms)
}
const enter = (id: string, event: MouseEvent) => {
if (!props.enabled()) return
state.over = id
move(event)
request(id)
}
const leave = (id: string) => {
if (state.over === id) state.over = undefined
if (state.pending === id) cancel()
}
return { move, enter, leave, activate, request, cancel, reset }
}

View File

@@ -0,0 +1,10 @@
import { base64Decode } from "@opencode-ai/util/encode"
export function decode64(value: string | undefined) {
if (value === undefined) return
try {
return base64Decode(value)
} catch {
return
}
}

View File

@@ -0,0 +1,51 @@
export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
const r = document.createRange()
r.selectNodeContents(lineElement)
r.setEnd(targetNode, offset)
return r.toString().length
}
export function getNodeOffsetInLine(lineElement: Element, charIndex: number): { node: Node; offset: number } | null {
const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT, null)
let remaining = Math.max(0, charIndex)
let lastText: Node | null = null
let lastLen = 0
let node: Node | null
while ((node = walker.nextNode())) {
const len = node.textContent?.length || 0
lastText = node
lastLen = len
if (remaining <= len) return { node, offset: remaining }
remaining -= len
}
if (lastText) return { node: lastText, offset: lastLen }
if (lineElement.firstChild) return { node: lineElement.firstChild, offset: 0 }
return null
}
export function getSelectionInContainer(
container: HTMLElement,
): { sl: number; sch: number; el: number; ech: number } | null {
const s = window.getSelection()
if (!s || s.rangeCount === 0) return null
const r = s.getRangeAt(0)
const sc = r.startContainer
const ec = r.endContainer
const getLineElement = (n: Node) =>
(n.nodeType === Node.TEXT_NODE ? (n.parentElement as Element) : (n as Element))?.closest(".line")
const sle = getLineElement(sc)
const ele = getLineElement(ec)
if (!sle || !ele) return null
if (!container.contains(sle as Node) || !container.contains(ele as Node)) return null
const cc = container.querySelector("code") as HTMLElement | null
if (!cc) return null
const lines = Array.from(cc.querySelectorAll(".line"))
const sli = lines.indexOf(sle as Element)
const eli = lines.indexOf(ele as Element)
if (sli === -1 || eli === -1) return null
const sl = sli + 1
const el = eli + 1
const sch = getCharacterOffsetInLine(sle as Element, sc, r.startOffset)
const ech = getCharacterOffsetInLine(ele as Element, ec, r.endOffset)
return { sl, sch, el, ech }
}

View File

@@ -0,0 +1,99 @@
import z from "zod"
const prefixes = {
session: "ses",
message: "msg",
permission: "per",
user: "usr",
part: "prt",
pty: "pty",
} as const
const LENGTH = 26
let lastTimestamp = 0
let counter = 0
type Prefix = keyof typeof prefixes
export namespace Identifier {
export function schema(prefix: Prefix) {
return z.string().startsWith(prefixes[prefix])
}
export function ascending(prefix: Prefix, given?: string) {
return generateID(prefix, false, given)
}
export function descending(prefix: Prefix, given?: string) {
return generateID(prefix, true, given)
}
}
function generateID(prefix: Prefix, descending: boolean, given?: string): string {
if (!given) {
return create(prefix, descending)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter += 1
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
if (descending) {
now = ~now
}
const timeBytes = new Uint8Array(6)
for (let i = 0; i < 6; i += 1) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
}
function bytesToHex(bytes: Uint8Array): string {
let hex = ""
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, "0")
}
return hex
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const bytes = getRandomBytes(length)
let result = ""
for (let i = 0; i < length; i += 1) {
result += chars[bytes[i] % 62]
}
return result
}
function getRandomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length)
const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
cryptoObj.getRandomValues(bytes)
return bytes
}
for (let i = 0; i < length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256)
}
return bytes
}

View File

@@ -0,0 +1 @@
export * from "./dom"

View File

@@ -0,0 +1,135 @@
type Nav = {
id: string
dir?: string
from?: string
to: string
trigger?: string
start: number
marks: Record<string, number>
logged: boolean
timer?: ReturnType<typeof setTimeout>
}
const dev = import.meta.env.DEV
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
const now = () => performance.now()
const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)
const navs = new Map<string, Nav>()
const pending = new Map<string, string>()
const active = new Map<string, string>()
const required = [
"session:params",
"session:data-ready",
"session:first-turn-mounted",
"storage:prompt-ready",
"storage:terminal-ready",
"storage:file-view-ready",
]
function flush(id: string, reason: "complete" | "timeout") {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
nav.logged = true
if (nav.timer) clearTimeout(nav.timer)
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
const base = nav.marks[baseName] ?? nav.start
const ms = Object.fromEntries(
Object.entries(nav.marks)
.slice()
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
)
console.log(
"perf.session-nav " +
JSON.stringify({
type: "perf.session-nav.v0",
id: nav.id,
dir: nav.dir,
from: nav.from,
to: nav.to,
trigger: nav.trigger,
base: baseName,
reason,
ms,
}),
)
navs.delete(id)
}
function maybeFlush(id: string) {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
if (!required.every((name) => nav.marks[name] !== undefined)) return
flush(id, "complete")
}
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
const existing = navs.get(id)
if (existing) return existing
const nav: Nav = {
...data,
marks: {},
logged: false,
}
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
navs.set(id, nav)
return nav
}
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
if (!dev) return
const id = uid()
const start = now()
const nav = ensure(id, { ...input, id, start })
nav.marks["navigate:start"] = start
pending.set(key(input.dir, input.to), id)
return id
}
export function navParams(input: { dir?: string; from?: string; to: string }) {
if (!dev) return
const k = key(input.dir, input.to)
const pendingId = pending.get(k)
if (pendingId) pending.delete(k)
const id = pendingId ?? uid()
const start = now()
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
nav.marks["session:params"] = start
active.set(k, id)
maybeFlush(id)
return id
}
export function navMark(input: { dir?: string; to: string; name: string }) {
if (!dev) return
const id = active.get(key(input.dir, input.to))
if (!id) return
const nav = navs.get(id)
if (!nav) return
if (nav.marks[input.name] !== undefined) return
nav.marks[input.name] = now()
maybeFlush(id)
}

View File

@@ -0,0 +1,451 @@
import { usePlatform } from "@/context/platform"
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
type PersistTarget = {
storage?: string
key: string
legacy?: string[]
migrate?: (value: unknown) => unknown
}
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
const LOCAL_PREFIX = "opencode."
const fallback = { disabled: false }
const CACHE_MAX_ENTRIES = 500
const CACHE_MAX_BYTES = 8 * 1024 * 1024
type CacheEntry = { value: string; bytes: number }
const cache = new Map<string, CacheEntry>()
const cacheTotal = { bytes: 0 }
function cacheDelete(key: string) {
const entry = cache.get(key)
if (!entry) return
cacheTotal.bytes -= entry.bytes
cache.delete(key)
}
function cachePrune() {
for (;;) {
if (cache.size <= CACHE_MAX_ENTRIES && cacheTotal.bytes <= CACHE_MAX_BYTES) return
const oldest = cache.keys().next().value as string | undefined
if (!oldest) return
cacheDelete(oldest)
}
}
function cacheSet(key: string, value: string) {
const bytes = value.length * 2
if (bytes > CACHE_MAX_BYTES) {
cacheDelete(key)
return
}
const entry = cache.get(key)
if (entry) cacheTotal.bytes -= entry.bytes
cache.delete(key)
cache.set(key, { value, bytes })
cacheTotal.bytes += bytes
cachePrune()
}
function cacheGet(key: string) {
const entry = cache.get(key)
if (!entry) return
cache.delete(key)
cache.set(key, entry)
return entry.value
}
function quota(error: unknown) {
if (error instanceof DOMException) {
if (error.name === "QuotaExceededError") return true
if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true
if (error.name === "QUOTA_EXCEEDED_ERR") return true
if (error.code === 22 || error.code === 1014) return true
return false
}
if (!error || typeof error !== "object") return false
const name = (error as { name?: string }).name
if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true
if (name && /quota/i.test(name)) return true
const code = (error as { code?: number }).code
if (code === 22 || code === 1014) return true
const message = (error as { message?: string }).message
if (typeof message !== "string") return false
if (/quota/i.test(message)) return true
return false
}
type Evict = { key: string; size: number }
function evict(storage: Storage, keep: string, value: string) {
const total = storage.length
const indexes = Array.from({ length: total }, (_, index) => index)
const items: Evict[] = []
for (const index of indexes) {
const name = storage.key(index)
if (!name) continue
if (!name.startsWith(LOCAL_PREFIX)) continue
if (name === keep) continue
const stored = storage.getItem(name)
items.push({ key: name, size: stored?.length ?? 0 })
}
items.sort((a, b) => b.size - a.size)
for (const item of items) {
storage.removeItem(item.key)
cacheDelete(item.key)
try {
storage.setItem(keep, value)
cacheSet(keep, value)
return true
} catch (error) {
if (!quota(error)) throw error
}
}
return false
}
function write(storage: Storage, key: string, value: string) {
try {
storage.setItem(key, value)
cacheSet(key, value)
return true
} catch (error) {
if (!quota(error)) throw error
}
try {
storage.removeItem(key)
cacheDelete(key)
storage.setItem(key, value)
cacheSet(key, value)
return true
} catch (error) {
if (!quota(error)) throw error
}
const ok = evict(storage, key, value)
if (!ok) cacheSet(key, value)
return ok
}
function snapshot(value: unknown) {
return JSON.parse(JSON.stringify(value)) as unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function merge(defaults: unknown, value: unknown): unknown {
if (value === undefined) return defaults
if (value === null) return value
if (Array.isArray(defaults)) {
if (Array.isArray(value)) return value
return defaults
}
if (isRecord(defaults)) {
if (!isRecord(value)) return defaults
const result: Record<string, unknown> = { ...defaults }
for (const key of Object.keys(value)) {
if (key in defaults) {
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
} else {
result[key] = (value as Record<string, unknown>)[key]
}
}
return result
}
return value
}
function parse(value: string) {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
function workspaceStorage(dir: string) {
const head = dir.slice(0, 12) || "workspace"
const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat`
}
function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:`
const item = (key: string) => base + key
return {
getItem: (key) => {
const name = item(key)
const cached = cacheGet(name)
if (fallback.disabled && cached !== undefined) return cached
const stored = (() => {
try {
return localStorage.getItem(name)
} catch {
fallback.disabled = true
return null
}
})()
if (stored === null) return cached ?? null
cacheSet(name, stored)
return stored
},
setItem: (key, value) => {
const name = item(key)
cacheSet(name, value)
if (fallback.disabled) return
try {
if (write(localStorage, name, value)) return
} catch {
fallback.disabled = true
return
}
fallback.disabled = true
},
removeItem: (key) => {
const name = item(key)
cacheDelete(name)
if (fallback.disabled) return
try {
localStorage.removeItem(name)
} catch {
fallback.disabled = true
}
},
}
}
function localStorageDirect(): SyncStorage {
return {
getItem: (key) => {
const cached = cacheGet(key)
if (fallback.disabled && cached !== undefined) return cached
const stored = (() => {
try {
return localStorage.getItem(key)
} catch {
fallback.disabled = true
return null
}
})()
if (stored === null) return cached ?? null
cacheSet(key, stored)
return stored
},
setItem: (key, value) => {
cacheSet(key, value)
if (fallback.disabled) return
try {
if (write(localStorage, key, value)) return
} catch {
fallback.disabled = true
return
}
fallback.disabled = true
},
removeItem: (key) => {
cacheDelete(key)
if (fallback.disabled) return
try {
localStorage.removeItem(key)
} catch {
fallback.disabled = true
}
},
}
}
export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
},
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
},
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
},
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
if (session) return Persist.session(dir, session, key, legacy)
return Persist.workspace(dir, key, legacy)
},
}
export function removePersisted(target: { storage?: string; key: string }) {
const platform = usePlatform()
const isDesktop = platform.platform === "desktop" && !!platform.storage
if (isDesktop) {
return platform.storage?.(target.storage)?.removeItem(target.key)
}
if (!target.storage) {
localStorageDirect().removeItem(target.key)
return
}
localStorageWithPrefix(target.storage).removeItem(target.key)
}
export function persisted<T>(
target: string | PersistTarget,
store: [Store<T>, SetStoreFunction<T>],
): PersistedWithReady<T> {
const platform = usePlatform()
const config: PersistTarget = typeof target === "string" ? { key: target } : target
const defaults = snapshot(store[0])
const legacy = config.legacy ?? []
const isDesktop = platform.platform === "desktop" && !!platform.storage
const currentStorage = (() => {
if (isDesktop) return platform.storage?.(config.storage)
if (!config.storage) return localStorageDirect()
return localStorageWithPrefix(config.storage)
})()
const legacyStorage = (() => {
if (!isDesktop) return localStorageDirect()
if (!config.storage) return platform.storage?.()
return platform.storage?.(LEGACY_STORAGE)
})()
const storage = (() => {
if (!isDesktop) {
const current = currentStorage as SyncStorage
const legacyStore = legacyStorage as SyncStorage
const api: SyncStorage = {
getItem: (key) => {
const raw = current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) current.setItem(key, next)
return next
}
for (const legacyKey of legacy) {
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
current.setItem(key, legacyRaw)
legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) current.setItem(key, next)
return next
}
return null
},
setItem: (key, value) => {
current.setItem(key, value)
},
removeItem: (key) => {
current.removeItem(key)
},
}
return api
}
const current = currentStorage as AsyncStorage
const legacyStore = legacyStorage as AsyncStorage | undefined
const api: AsyncStorage = {
getItem: async (key) => {
const raw = await current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) await current.setItem(key, next)
return next
}
if (!legacyStore) return null
for (const legacyKey of legacy) {
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
await current.setItem(key, legacyRaw)
await legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) await current.setItem(key, next)
return next
}
return null
},
setItem: async (key, value) => {
await current.setItem(key, value)
},
removeItem: async (key) => {
await current.removeItem(key)
},
}
return api
})()
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
const isAsync = init instanceof Promise
const [ready] = createResource(
() => init,
async (initValue) => {
if (initValue instanceof Promise) await initValue
return true
},
{ initialValue: !isAsync },
)
return [state, setState, init, () => ready() === true]
}

View File

@@ -0,0 +1,203 @@
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
type Inline =
| {
type: "file"
start: number
end: number
value: string
path: string
selection?: {
startLine: number
endLine: number
startChar: number
endChar: number
}
}
| {
type: "agent"
start: number
end: number
value: string
name: string
}
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
const queryIndex = url.indexOf("?")
if (queryIndex === -1) return undefined
const params = new URLSearchParams(url.slice(queryIndex + 1))
const startLine = Number(params.get("start"))
const endLine = Number(params.get("end"))
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
return {
startLine,
endLine,
startChar: 0,
endChar: 0,
}
}
function textPartValue(parts: Part[]) {
const candidates = parts
.filter((part): part is TextPart => part.type === "text")
.filter((part) => !part.synthetic && !part.ignored)
return candidates.reduce((best: TextPart | undefined, part) => {
if (!best) return part
if (part.text.length > best.text.length) return part
return best
}, undefined)
}
/**
* Extract prompt content from message parts for restoring into the prompt input.
* This is used by undo to restore the original user prompt.
*/
export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt {
const textPart = textPartValue(parts)
const text = textPart?.text ?? ""
const directory = opts?.directory
const attachmentName = opts?.attachmentName ?? "attachment"
const toRelative = (path: string) => {
if (!directory) return path
const prefix = directory.endsWith("/") ? directory : directory + "/"
if (path.startsWith(prefix)) return path.slice(prefix.length)
if (path.startsWith(directory)) {
const next = path.slice(directory.length)
if (next.startsWith("/")) return next.slice(1)
return next
}
return path
}
const inline: Inline[] = []
const images: ImageAttachmentPart[] = []
for (const part of parts) {
if (part.type === "file") {
const filePart = part as FilePart
const sourceText = filePart.source?.text
if (sourceText) {
const value = sourceText.value
const start = sourceText.start
const end = sourceText.end
let path = value
if (value.startsWith("@")) path = value.slice(1)
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
path = filePart.source.path
}
inline.push({
type: "file",
start,
end,
value,
path: toRelative(path),
selection: selectionFromFileUrl(filePart.url),
})
continue
}
if (filePart.url.startsWith("data:")) {
images.push({
type: "image",
id: filePart.id,
filename: filePart.filename ?? attachmentName,
mime: filePart.mime,
dataUrl: filePart.url,
})
}
}
if (part.type === "agent") {
const agentPart = part as MessageAgentPart
const source = agentPart.source
if (!source) continue
inline.push({
type: "agent",
start: source.start,
end: source.end,
value: source.value,
name: agentPart.name,
})
}
}
inline.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start
return a.end - b.end
})
const result: Prompt = []
let position = 0
let cursor = 0
const pushText = (content: string) => {
if (!content) return
result.push({
type: "text",
content,
start: position,
end: position + content.length,
})
position += content.length
}
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
const content = item.value
const attachment: FileAttachmentPart = {
type: "file",
path: item.path,
content,
start: position,
end: position + content.length,
selection: item.selection,
}
result.push(attachment)
position += content.length
}
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
const content = item.value
const mention: AgentPart = {
type: "agent",
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(mention)
position += content.length
}
for (const item of inline) {
if (item.start < 0 || item.end < item.start) continue
const expected = item.value
if (!expected) continue
const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
const start = mismatch ? text.indexOf(expected, cursor) : item.start
if (start === -1) continue
const end = mismatch ? start + expected.length : item.end
pushText(text.slice(cursor, start))
if (item.type === "file") pushFile(item)
if (item.type === "agent") pushAgent(item)
cursor = end
}
pushText(text.slice(cursor))
if (result.length === 0) {
result.push({ type: "text", content: "", start: 0, end: 0 })
}
if (images.length === 0) return result
return [...result, ...images]
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test"
import {
disposeIfDisposable,
getHoveredLinkText,
getSpeechRecognitionCtor,
hasSetOption,
isDisposable,
setOptionIfSupported,
} from "./runtime-adapters"
describe("runtime adapters", () => {
test("detects and disposes disposable values", () => {
let count = 0
const value = {
dispose: () => {
count += 1
},
}
expect(isDisposable(value)).toBe(true)
disposeIfDisposable(value)
expect(count).toBe(1)
})
test("ignores non-disposable values", () => {
expect(isDisposable({ dispose: "nope" })).toBe(false)
expect(() => disposeIfDisposable({ dispose: "nope" })).not.toThrow()
})
test("sets options only when setter exists", () => {
const calls: Array<[string, unknown]> = []
const value = {
setOption: (key: string, next: unknown) => {
calls.push([key, next])
},
}
expect(hasSetOption(value)).toBe(true)
setOptionIfSupported(value, "fontFamily", "Berkeley Mono")
expect(calls).toEqual([["fontFamily", "Berkeley Mono"]])
expect(() => setOptionIfSupported({}, "fontFamily", "Berkeley Mono")).not.toThrow()
})
test("reads hovered link text safely", () => {
expect(getHoveredLinkText({ currentHoveredLink: { text: "https://example.com" } })).toBe("https://example.com")
expect(getHoveredLinkText({ currentHoveredLink: { text: 1 } })).toBeUndefined()
expect(getHoveredLinkText(null)).toBeUndefined()
})
test("resolves speech recognition constructor with webkit precedence", () => {
class SpeechCtor {}
class WebkitCtor {}
const ctor = getSpeechRecognitionCtor({
SpeechRecognition: SpeechCtor,
webkitSpeechRecognition: WebkitCtor,
})
expect(ctor).toBe(WebkitCtor)
})
test("returns undefined when no valid speech constructor exists", () => {
expect(getSpeechRecognitionCtor({ SpeechRecognition: "nope" })).toBeUndefined()
expect(getSpeechRecognitionCtor(undefined)).toBeUndefined()
})
})

View File

@@ -0,0 +1,39 @@
type RecordValue = Record<string, unknown>
const isRecord = (value: unknown): value is RecordValue => {
return typeof value === "object" && value !== null
}
export const isDisposable = (value: unknown): value is { dispose: () => void } => {
return isRecord(value) && typeof value.dispose === "function"
}
export const disposeIfDisposable = (value: unknown) => {
if (!isDisposable(value)) return
value.dispose()
}
export const hasSetOption = (value: unknown): value is { setOption: (key: string, next: unknown) => void } => {
return isRecord(value) && typeof value.setOption === "function"
}
export const setOptionIfSupported = (value: unknown, key: string, next: unknown) => {
if (!hasSetOption(value)) return
value.setOption(key, next)
}
export const getHoveredLinkText = (value: unknown) => {
if (!isRecord(value)) return
const link = value.currentHoveredLink
if (!isRecord(link)) return
if (typeof link.text !== "string") return
return link.text
}
export const getSpeechRecognitionCtor = <T>(value: unknown): (new () => T) | undefined => {
if (!isRecord(value)) return
const ctor =
typeof value.webkitSpeechRecognition === "function" ? value.webkitSpeechRecognition : value.SpeechRecognition
if (typeof ctor !== "function") return
return ctor as new () => T
}

View File

@@ -0,0 +1,6 @@
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test"
import { createScopedCache } from "./scoped-cache"
describe("createScopedCache", () => {
test("evicts least-recently-used entry when max is reached", () => {
const disposed: string[] = []
const cache = createScopedCache((key) => ({ key }), {
maxEntries: 2,
dispose: (value) => disposed.push(value.key),
})
const a = cache.get("a")
const b = cache.get("b")
expect(a.key).toBe("a")
expect(b.key).toBe("b")
cache.get("a")
const c = cache.get("c")
expect(c.key).toBe("c")
expect(cache.peek("a")?.key).toBe("a")
expect(cache.peek("b")).toBeUndefined()
expect(cache.peek("c")?.key).toBe("c")
expect(disposed).toEqual(["b"])
})
test("disposes entries on delete and clear", () => {
const disposed: string[] = []
const cache = createScopedCache((key) => ({ key }), {
dispose: (value) => disposed.push(value.key),
})
cache.get("a")
cache.get("b")
const removed = cache.delete("a")
expect(removed?.key).toBe("a")
expect(cache.peek("a")).toBeUndefined()
cache.clear()
expect(cache.peek("b")).toBeUndefined()
expect(disposed).toEqual(["a", "b"])
})
test("expires stale entries with ttl and recreates on get", () => {
let clock = 0
let count = 0
const disposed: string[] = []
const cache = createScopedCache((key) => ({ key, count: ++count }), {
ttlMs: 10,
now: () => clock,
dispose: (value) => disposed.push(`${value.key}:${value.count}`),
})
const first = cache.get("a")
expect(first.count).toBe(1)
clock = 9
expect(cache.peek("a")?.count).toBe(1)
clock = 11
expect(cache.peek("a")).toBeUndefined()
expect(disposed).toEqual(["a:1"])
const second = cache.get("a")
expect(second.count).toBe(2)
expect(disposed).toEqual(["a:1"])
})
})

View File

@@ -0,0 +1,104 @@
type ScopedCacheOptions<T> = {
maxEntries?: number
ttlMs?: number
dispose?: (value: T, key: string) => void
now?: () => number
}
type Entry<T> = {
value: T
touchedAt: number
}
export function createScopedCache<T>(createValue: (key: string) => T, options: ScopedCacheOptions<T> = {}) {
const store = new Map<string, Entry<T>>()
const now = options.now ?? Date.now
const dispose = (key: string, entry: Entry<T>) => {
options.dispose?.(entry.value, key)
}
const expired = (entry: Entry<T>) => {
if (options.ttlMs === undefined) return false
return now() - entry.touchedAt >= options.ttlMs
}
const sweep = () => {
if (options.ttlMs === undefined) return
for (const [key, entry] of store) {
if (!expired(entry)) continue
store.delete(key)
dispose(key, entry)
}
}
const touch = (key: string, entry: Entry<T>) => {
entry.touchedAt = now()
store.delete(key)
store.set(key, entry)
}
const prune = () => {
if (options.maxEntries === undefined) return
while (store.size > options.maxEntries) {
const key = store.keys().next().value
if (!key) return
const entry = store.get(key)
store.delete(key)
if (!entry) continue
dispose(key, entry)
}
}
const remove = (key: string) => {
const entry = store.get(key)
if (!entry) return
store.delete(key)
dispose(key, entry)
return entry.value
}
const peek = (key: string) => {
sweep()
const entry = store.get(key)
if (!entry) return
if (!expired(entry)) return entry.value
store.delete(key)
dispose(key, entry)
}
const get = (key: string) => {
sweep()
const entry = store.get(key)
if (entry && !expired(entry)) {
touch(key, entry)
return entry.value
}
if (entry) {
store.delete(key)
dispose(key, entry)
}
const created = {
value: createValue(key),
touchedAt: now(),
}
store.set(key, created)
prune()
return created.value
}
const clear = () => {
for (const [key, entry] of store) {
dispose(key, entry)
}
store.clear()
}
return {
get,
peek,
delete: remove,
clear,
}
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test"
import { checkServerHealth } from "./server-health"
describe("checkServerHealth", () => {
test("returns healthy response with version", async () => {
const fetch = (async () =>
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
})
test("returns unhealthy when request fails", async () => {
const fetch = (async () => {
throw new Error("network")
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch)
expect(result).toEqual({ healthy: false })
})
test("uses provided abort signal", async () => {
let signal: AbortSignal | undefined
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
signal = init?.signal ?? (input instanceof Request ? input.signal : undefined)
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
status: 200,
headers: { "content-type": "application/json" },
})
}) as unknown as typeof globalThis.fetch
const abort = new AbortController()
await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal })
expect(signal).toBe(abort.signal)
})
})

View File

@@ -0,0 +1,29 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
export type ServerHealth = { healthy: boolean; version?: string }
interface CheckServerHealthOptions {
timeoutMs?: number
signal?: AbortSignal
}
function timeoutSignal(timeoutMs: number) {
return (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(timeoutMs)
}
export async function checkServerHealth(
url: string,
fetch: typeof globalThis.fetch,
opts?: CheckServerHealthOptions,
): Promise<ServerHealth> {
const signal = opts?.signal ?? timeoutSignal(opts?.timeoutMs ?? 3000)
const sdk = createOpencodeClient({
baseUrl: url,
fetch,
signal,
})
return sdk.global
.health()
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
.catch(() => ({ healthy: false }))
}

View File

@@ -0,0 +1,55 @@
import { useDragDropContext } from "@thisbeyond/solid-dnd"
import { JSXElement } from "solid-js"
import type { Transformer } from "@thisbeyond/solid-dnd"
export const getDraggableId = (event: unknown): string | undefined => {
if (typeof event !== "object" || event === null) return undefined
if (!("draggable" in event)) return undefined
const draggable = (event as { draggable?: { id?: unknown } }).draggable
if (!draggable) return undefined
return typeof draggable.id === "string" ? draggable.id : undefined
}
export const ConstrainDragXAxis = (): JSXElement => {
const context = useDragDropContext()
if (!context) return <></>
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = {
id: "constrain-x-axis",
order: 100,
callback: (transform) => ({ ...transform, x: 0 }),
}
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return <></>
}
export const ConstrainDragYAxis = (): JSXElement => {
const context = useDragDropContext()
if (!context) return <></>
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = {
id: "constrain-y-axis",
order: 100,
callback: (transform) => ({ ...transform, y: 0 }),
}
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return <></>
}

View File

@@ -0,0 +1,117 @@
import alert01 from "@opencode-ai/ui/audio/alert-01.aac"
import alert02 from "@opencode-ai/ui/audio/alert-02.aac"
import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
import alert04 from "@opencode-ai/ui/audio/alert-04.aac"
import alert05 from "@opencode-ai/ui/audio/alert-05.aac"
import alert06 from "@opencode-ai/ui/audio/alert-06.aac"
import alert07 from "@opencode-ai/ui/audio/alert-07.aac"
import alert08 from "@opencode-ai/ui/audio/alert-08.aac"
import alert09 from "@opencode-ai/ui/audio/alert-09.aac"
import alert10 from "@opencode-ai/ui/audio/alert-10.aac"
import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac"
import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
export const SOUND_OPTIONS = [
{ id: "alert-01", label: "sound.option.alert01", src: alert01 },
{ id: "alert-02", label: "sound.option.alert02", src: alert02 },
{ id: "alert-03", label: "sound.option.alert03", src: alert03 },
{ id: "alert-04", label: "sound.option.alert04", src: alert04 },
{ id: "alert-05", label: "sound.option.alert05", src: alert05 },
{ id: "alert-06", label: "sound.option.alert06", src: alert06 },
{ id: "alert-07", label: "sound.option.alert07", src: alert07 },
{ id: "alert-08", label: "sound.option.alert08", src: alert08 },
{ id: "alert-09", label: "sound.option.alert09", src: alert09 },
{ id: "alert-10", label: "sound.option.alert10", src: alert10 },
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
{ id: "nope-01", label: "sound.option.nope01", src: nope01 },
{ id: "nope-02", label: "sound.option.nope02", src: nope02 },
{ id: "nope-03", label: "sound.option.nope03", src: nope03 },
{ id: "nope-04", label: "sound.option.nope04", src: nope04 },
{ id: "nope-05", label: "sound.option.nope05", src: nope05 },
{ id: "nope-06", label: "sound.option.nope06", src: nope06 },
{ id: "nope-07", label: "sound.option.nope07", src: nope07 },
{ id: "nope-08", label: "sound.option.nope08", src: nope08 },
{ id: "nope-09", label: "sound.option.nope09", src: nope09 },
{ id: "nope-10", label: "sound.option.nope10", src: nope10 },
{ id: "nope-11", label: "sound.option.nope11", src: nope11 },
{ id: "nope-12", label: "sound.option.nope12", src: nope12 },
{ id: "yup-01", label: "sound.option.yup01", src: yup01 },
{ id: "yup-02", label: "sound.option.yup02", src: yup02 },
{ id: "yup-03", label: "sound.option.yup03", src: yup03 },
{ id: "yup-04", label: "sound.option.yup04", src: yup04 },
{ id: "yup-05", label: "sound.option.yup05", src: yup05 },
{ id: "yup-06", label: "sound.option.yup06", src: yup06 },
] as const
export type SoundOption = (typeof SOUND_OPTIONS)[number]
export type SoundID = SoundOption["id"]
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
export function soundSrc(id: string | undefined) {
if (!id) return
if (!(id in soundById)) return
return soundById[id as SoundID]
}
export function playSound(src: string | undefined) {
if (typeof Audio === "undefined") return
if (!src) return
const audio = new Audio(src)
audio.play().catch(() => undefined)
// Return a cleanup function to pause the sound.
return () => {
audio.pause()
audio.currentTime = 0
}
}

View File

@@ -0,0 +1,326 @@
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { getSpeechRecognitionCtor } from "@/utils/runtime-adapters"
// Minimal types to avoid relying on non-standard DOM typings
type RecognitionResult = {
0: { transcript: string }
isFinal: boolean
}
type RecognitionEvent = {
results: RecognitionResult[]
resultIndex: number
}
interface Recognition {
continuous: boolean
interimResults: boolean
lang: string
start: () => void
stop: () => void
onresult: ((e: RecognitionEvent) => void) | null
onerror: ((e: { error: string }) => void) | null
onend: (() => void) | null
onstart: (() => void) | null
}
const COMMIT_DELAY = 250
const appendSegment = (base: string, addition: string) => {
const trimmed = addition.trim()
if (!trimmed) return base
if (!base) return trimmed
const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
return `${base}${needsSpace ? " " : ""}${trimmed}`
}
const extractSuffix = (committed: string, hypothesis: string) => {
const cleanHypothesis = hypothesis.trim()
if (!cleanHypothesis) return ""
const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
const hypothesisTokens = cleanHypothesis.split(/\s+/)
let index = 0
while (
index < baseTokens.length &&
index < hypothesisTokens.length &&
baseTokens[index] === hypothesisTokens[index]
) {
index += 1
}
if (index < baseTokens.length) return ""
return hypothesisTokens.slice(index).join(" ")
}
export function createSpeechRecognition(opts?: {
lang?: string
onFinal?: (text: string) => void
onInterim?: (text: string) => void
}) {
const ctor = getSpeechRecognitionCtor<Recognition>(typeof window === "undefined" ? undefined : window)
const hasSupport = Boolean(ctor)
const [store, setStore] = createStore({
isRecording: false,
committed: "",
interim: "",
})
const isRecording = () => store.isRecording
const committed = () => store.committed
const interim = () => store.interim
let recognition: Recognition | undefined
let shouldContinue = false
let committedText = ""
let sessionCommitted = ""
let pendingHypothesis = ""
let lastInterimSuffix = ""
let shrinkCandidate: string | undefined
let commitTimer: number | undefined
let restartTimer: number | undefined
const cancelPendingCommit = () => {
if (commitTimer === undefined) return
clearTimeout(commitTimer)
commitTimer = undefined
}
const clearRestart = () => {
if (restartTimer === undefined) return
window.clearTimeout(restartTimer)
restartTimer = undefined
}
const scheduleRestart = () => {
clearRestart()
if (!shouldContinue) return
if (!recognition) return
restartTimer = window.setTimeout(() => {
restartTimer = undefined
if (!shouldContinue) return
if (!recognition) return
try {
recognition.start()
} catch {}
}, 150)
}
const commitSegment = (segment: string) => {
const nextCommitted = appendSegment(committedText, segment)
if (nextCommitted === committedText) return
committedText = nextCommitted
setStore("committed", committedText)
if (opts?.onFinal) opts.onFinal(segment.trim())
}
const promotePending = () => {
if (!pendingHypothesis) return
const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
if (!suffix) {
pendingHypothesis = ""
return
}
sessionCommitted = appendSegment(sessionCommitted, suffix)
commitSegment(suffix)
pendingHypothesis = ""
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
}
const applyInterim = (suffix: string, hypothesis: string) => {
cancelPendingCommit()
pendingHypothesis = hypothesis
lastInterimSuffix = suffix
shrinkCandidate = undefined
setStore("interim", suffix)
if (opts?.onInterim) {
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
}
if (!suffix) return
const snapshot = hypothesis
commitTimer = window.setTimeout(() => {
if (pendingHypothesis !== snapshot) return
const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
if (!currentSuffix) return
sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
commitSegment(currentSuffix)
pendingHypothesis = ""
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
}, COMMIT_DELAY)
}
if (ctor) {
recognition = new ctor()
recognition.continuous = false
recognition.interimResults = true
recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
recognition.onresult = (event: RecognitionEvent) => {
if (!event.results.length) return
let aggregatedFinal = ""
let latestHypothesis = ""
for (let i = 0; i < event.results.length; i += 1) {
const result = event.results[i]
const transcript = (result[0]?.transcript || "").trim()
if (!transcript) continue
if (result.isFinal) {
aggregatedFinal = appendSegment(aggregatedFinal, transcript)
} else {
latestHypothesis = transcript
}
}
if (aggregatedFinal) {
cancelPendingCommit()
const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
if (finalSuffix) {
sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
commitSegment(finalSuffix)
}
pendingHypothesis = ""
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
return
}
cancelPendingCommit()
if (!latestHypothesis) {
shrinkCandidate = undefined
applyInterim("", "")
return
}
const suffix = extractSuffix(sessionCommitted, latestHypothesis)
if (!suffix) {
if (!lastInterimSuffix) {
shrinkCandidate = undefined
applyInterim("", latestHypothesis)
return
}
if (shrinkCandidate === "") {
applyInterim("", latestHypothesis)
return
}
shrinkCandidate = ""
pendingHypothesis = latestHypothesis
return
}
if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
if (shrinkCandidate === suffix) {
applyInterim(suffix, latestHypothesis)
return
}
shrinkCandidate = suffix
pendingHypothesis = latestHypothesis
return
}
shrinkCandidate = undefined
applyInterim(suffix, latestHypothesis)
}
recognition.onerror = (e: { error: string }) => {
clearRestart()
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
if (e.error === "no-speech" && shouldContinue) {
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
scheduleRestart()
return
}
shouldContinue = false
setStore("isRecording", false)
}
recognition.onstart = () => {
clearRestart()
sessionCommitted = ""
pendingHypothesis = ""
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
setStore("isRecording", true)
}
recognition.onend = () => {
clearRestart()
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("isRecording", false)
if (shouldContinue) {
scheduleRestart()
}
}
}
const start = () => {
if (!recognition) return
clearRestart()
shouldContinue = true
sessionCommitted = ""
pendingHypothesis = ""
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
try {
recognition.start()
} catch {}
}
const stop = () => {
if (!recognition) return
shouldContinue = false
clearRestart()
promotePending()
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
try {
recognition.stop()
} catch {}
}
onCleanup(() => {
shouldContinue = false
clearRestart()
promotePending()
cancelPendingCommit()
lastInterimSuffix = ""
shrinkCandidate = undefined
setStore("interim", "")
if (opts?.onInterim) opts.onInterim("")
try {
recognition?.stop()
} catch {}
})
return {
isSupported: () => hasSupport,
isRecording,
committed,
interim,
start,
stop,
}
}

View File

@@ -0,0 +1,14 @@
export function getRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSeconds = Math.floor(diffMs / 1000)
const diffMinutes = Math.floor(diffSeconds / 60)
const diffHours = Math.floor(diffMinutes / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffSeconds < 60) return "Just now"
if (diffMinutes < 60) return `${diffMinutes}m ago`
if (diffHours < 24) return `${diffHours}h ago`
return `${diffDays}d ago`
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import { Worktree } from "./worktree"
const dir = (name: string) => `/tmp/opencode-worktree-${name}-${crypto.randomUUID()}`
describe("Worktree", () => {
test("normalizes trailing slashes", () => {
const key = dir("normalize")
Worktree.ready(`${key}/`)
expect(Worktree.get(key)).toEqual({ status: "ready" })
})
test("pending does not overwrite a terminal state", () => {
const key = dir("pending")
Worktree.failed(key, "boom")
Worktree.pending(key)
expect(Worktree.get(key)).toEqual({ status: "failed", message: "boom" })
})
test("wait resolves shared pending waiter when ready", async () => {
const key = dir("wait-ready")
Worktree.pending(key)
const a = Worktree.wait(key)
const b = Worktree.wait(`${key}/`)
expect(a).toBe(b)
Worktree.ready(key)
expect(await a).toEqual({ status: "ready" })
expect(await b).toEqual({ status: "ready" })
})
test("wait resolves with failure message", async () => {
const key = dir("wait-failed")
const waiting = Worktree.wait(key)
Worktree.failed(key, "permission denied")
expect(await waiting).toEqual({ status: "failed", message: "permission denied" })
expect(await Worktree.wait(key)).toEqual({ status: "failed", message: "permission denied" })
})
})

View File

@@ -0,0 +1,73 @@
const normalize = (directory: string) => directory.replace(/[\\/]+$/, "")
type State =
| {
status: "pending"
}
| {
status: "ready"
}
| {
status: "failed"
message: string
}
const state = new Map<string, State>()
const waiters = new Map<
string,
{
promise: Promise<State>
resolve: (state: State) => void
}
>()
function deferred() {
const box = { resolve: (_: State) => {} }
const promise = new Promise<State>((resolve) => {
box.resolve = resolve
})
return { promise, resolve: box.resolve }
}
export const Worktree = {
get(directory: string) {
return state.get(normalize(directory))
},
pending(directory: string) {
const key = normalize(directory)
const current = state.get(key)
if (current && current.status !== "pending") return
state.set(key, { status: "pending" })
},
ready(directory: string) {
const key = normalize(directory)
const next = { status: "ready" } as const
state.set(key, next)
const waiter = waiters.get(key)
if (!waiter) return
waiters.delete(key)
waiter.resolve(next)
},
failed(directory: string, message: string) {
const key = normalize(directory)
const next = { status: "failed", message } as const
state.set(key, next)
const waiter = waiters.get(key)
if (!waiter) return
waiters.delete(key)
waiter.resolve(next)
},
wait(directory: string) {
const key = normalize(directory)
const current = state.get(key)
if (current && current.status !== "pending") return Promise.resolve(current)
const existing = waiters.get(key)
if (existing) return existing.promise
const waiter = deferred()
waiters.set(key, waiter)
return waiter.promise
},
}