Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1803aaf857 |
@@ -1003,6 +1003,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
||||
@@ -2,13 +2,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { run } from "@opencode-ai/tui"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { TuiConfig } from "../../tui-config"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Server } from "../../services/server"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -16,25 +15,17 @@ export default Runtime.handler(Commands, (input) =>
|
||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
const preflight = UpdatePreflight.make()
|
||||
const server = yield* Server.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
onStart: (reason, existing) => {
|
||||
if (reason === "version-mismatch" && preflight.begin(existing?.version)) return
|
||||
onStart: (reason) =>
|
||||
process.stderr.write(
|
||||
reason === "version-mismatch"
|
||||
? "Restarting background server (version mismatch)...\n"
|
||||
: "Starting background server...\n",
|
||||
)
|
||||
},
|
||||
}).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() => preflight.finish())
|
||||
const config = yield* TuiConfig.load()
|
||||
),
|
||||
})
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
let disposeSlots: (() => void) | undefined
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
yield* run({
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Cause, Effect } from "effect"
|
||||
|
||||
const RED_BOLD = "\x1b[91m\x1b[1m"
|
||||
const BOLD = "\x1b[1m"
|
||||
const RESET = "\x1b[0m"
|
||||
|
||||
export function handle(cause: Cause.Cause<unknown>, command: string) {
|
||||
const error = Cause.squash(cause)
|
||||
if (!(error instanceof Service.StartError)) return Effect.failCause(cause)
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.logError("background service startup failed", { cause: Cause.pretty(cause) })
|
||||
yield* Effect.sync(() => {
|
||||
process.stderr.write(render(error, command))
|
||||
process.exitCode = 1
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function render(error: Service.StartError, command: string) {
|
||||
const detail =
|
||||
error.stage === "spawn"
|
||||
? "The service process could not be started."
|
||||
: error.stage === "registration"
|
||||
? "The service exited or never became ready.\nThe expected registration file was not created."
|
||||
: "The service started but did not become ready."
|
||||
return `\n${RED_BOLD}OpenCode could not start its background service${RESET}\n\n${detail}\n\n${BOLD}Try:${RESET}\n ${command} service restart\n OPENCODE_LOG_LEVEL=DEBUG ${command}\n`
|
||||
}
|
||||
|
||||
export * as StartupError from "./startup-error"
|
||||
@@ -11,7 +11,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { StartupError } from "./framework/startup-error"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
@@ -52,7 +51,6 @@ Effect.logInfo("cli starting", {
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.catchCause((cause) => StartupError.handle(cause, Commands.name)),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||
Effect.provide(Observability.layer),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// history ring. All are async because they read config or hit the SDK, but
|
||||
// none block each other.
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// → OpenTUI split-footer renderer writes to terminal
|
||||
import type { OpenCodeClient, ReferenceListOutput } from "@opencode-ai/client/promise"
|
||||
import type { FilePart, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
import type { TuiConfig } from "@opencode-ai/tui/config"
|
||||
|
||||
export type RunFilePart = {
|
||||
type: "file"
|
||||
|
||||
@@ -1,440 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
// Split-footer status shown while a freshly launched CLI replaces a
|
||||
// version-mismatched background service before the TUI attaches.
|
||||
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer } from "@opentui/core"
|
||||
import { render, useTerminalDimensions } from "@opentui/solid"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
||||
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
||||
import { go } from "@opencode-ai/tui/logo"
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Index,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
|
||||
const stages = ["Keeping your session safe", "Starting the new background service", "Connecting to OpenCode"] as const
|
||||
const stageFloor = 480
|
||||
const transitionDuration = 420
|
||||
const completionHold = 650
|
||||
|
||||
export type Handle = {
|
||||
readonly begin: (from?: string) => boolean
|
||||
readonly finish: () => Promise<void>
|
||||
readonly fail: (message: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const make = (): Handle => {
|
||||
let session: Promise<Session | undefined> | undefined
|
||||
return {
|
||||
begin: (from) => {
|
||||
if (!process.stdout.isTTY || !process.stdin.isTTY) return false
|
||||
session ??= open(from).catch(() => {
|
||||
process.stderr.write("Restarting background server (version mismatch)...\n")
|
||||
return undefined
|
||||
})
|
||||
return true
|
||||
},
|
||||
finish: async () => {
|
||||
const active = await session
|
||||
await active?.finish()
|
||||
},
|
||||
fail: async (message) => {
|
||||
const active = await session
|
||||
await active?.fail(message)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Session = {
|
||||
readonly finish: () => Promise<void>
|
||||
readonly fail: (message: string) => Promise<void>
|
||||
}
|
||||
|
||||
async function open(from?: string): Promise<Session> {
|
||||
registerOpencodeSpinner()
|
||||
const [active, setActive] = createSignal(0)
|
||||
const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
|
||||
const [failure, setFailure] = createSignal("")
|
||||
const [animating, setAnimating] = createSignal(true)
|
||||
let resolveOutcome: (() => void) | undefined
|
||||
const renderer = await createCliRenderer({
|
||||
stdin: process.stdin,
|
||||
useMouse: false,
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
exitOnCtrlC: false,
|
||||
exitSignals: [],
|
||||
screenMode: "split-footer",
|
||||
footerHeight: 4,
|
||||
targetFps: 60,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
await render(
|
||||
() => (
|
||||
<UpdateFooter
|
||||
from={from}
|
||||
active={active}
|
||||
outcome={outcome}
|
||||
failure={failure}
|
||||
animating={animating}
|
||||
renderer={renderer}
|
||||
onOutcomeSettled={() => resolveOutcome?.()}
|
||||
/>
|
||||
),
|
||||
renderer,
|
||||
).catch((error) => {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
throw error
|
||||
})
|
||||
let shownAt = performance.now()
|
||||
const advance = async (stage: number) => {
|
||||
const remaining = stageFloor - (performance.now() - shownAt)
|
||||
if (remaining > 0) await Bun.sleep(remaining)
|
||||
if (outcome() !== "running") return
|
||||
setActive(stage)
|
||||
shownAt = performance.now()
|
||||
}
|
||||
// Service.start currently exposes only its start boundary, so this first
|
||||
// transition is time-based. Finer lifecycle callbacks remain follow-up work.
|
||||
const auto = advance(1)
|
||||
const transitionTo = async (next: "success" | "failure", hold: number) => {
|
||||
const settled = Promise.withResolvers<void>()
|
||||
resolveOutcome = settled.resolve
|
||||
setOutcome(next)
|
||||
const completed = await Promise.race([
|
||||
settled.promise.then(() => true),
|
||||
Bun.sleep(transitionDuration + 500).then(() => false),
|
||||
])
|
||||
resolveOutcome = undefined
|
||||
setAnimating(false)
|
||||
if (completed) await Bun.sleep(hold)
|
||||
}
|
||||
const close = async () => {
|
||||
setAnimating(false)
|
||||
if (renderer.isDestroyed) return
|
||||
renderer.pause()
|
||||
await Promise.race([renderer.idle(), Bun.sleep(500)])
|
||||
renderer.destroy()
|
||||
}
|
||||
let settled: Promise<void> | undefined
|
||||
const settle = (task: () => Promise<void>) => (settled ??= task())
|
||||
return {
|
||||
finish: () =>
|
||||
settle(async () => {
|
||||
await auto
|
||||
await advance(2)
|
||||
await Bun.sleep(stageFloor)
|
||||
await transitionTo("success", completionHold)
|
||||
await close()
|
||||
}),
|
||||
fail: (message) =>
|
||||
settle(async () => {
|
||||
setFailure(message)
|
||||
await transitionTo("failure", 250)
|
||||
await close()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const colors = {
|
||||
accent: RGBA.fromHex("#a6b8ff"),
|
||||
accentBright: RGBA.fromHex("#eef1ff"),
|
||||
accentDim: RGBA.fromHex("#596998"),
|
||||
error: RGBA.fromHex("#ff8192"),
|
||||
muted: RGBA.fromHex("#808080"),
|
||||
success: RGBA.fromHex("#8bd5a5"),
|
||||
text: RGBA.fromHex("#eeeeee"),
|
||||
}
|
||||
|
||||
const monogram = go.right.slice(1)
|
||||
const sweepBlend = 8
|
||||
const textDim = RGBA.fromHex("#4c4c4c")
|
||||
const rampSteps = 32
|
||||
|
||||
const ramp = (from: RGBA, to: RGBA) =>
|
||||
Array.from({ length: rampSteps + 1 }, (_, step) => {
|
||||
const amount = step / rampSteps
|
||||
return RGBA.fromValues(
|
||||
from.r + (to.r - from.r) * amount,
|
||||
from.g + (to.g - from.g) * amount,
|
||||
from.b + (to.b - from.b) * amount,
|
||||
)
|
||||
})
|
||||
const railRamp = ramp(colors.accentDim, colors.accentBright)
|
||||
const monogramRamp = ramp(colors.muted, colors.accent)
|
||||
const rampCache = new Map<RGBA, ReadonlyArray<RGBA>>()
|
||||
const rampFor = (color: RGBA) => {
|
||||
const cached = rampCache.get(color)
|
||||
if (cached) return cached
|
||||
const result = ramp(textDim, color)
|
||||
rampCache.set(color, result)
|
||||
return result
|
||||
}
|
||||
const shade = (palette: ReadonlyArray<RGBA>, brightness: number) =>
|
||||
palette[Math.round(Math.max(0, Math.min(1, brightness)) * rampSteps)]
|
||||
|
||||
type Cell = { readonly char: string; readonly color: RGBA; readonly bold?: boolean }
|
||||
const styled = (text: string, color: RGBA, bold?: boolean): Cell[] =>
|
||||
Array.from(text).map((char) => ({ char, color, bold }))
|
||||
const phrase = (...segments: ReadonlyArray<readonly [string, RGBA, boolean?]>): Cell[] =>
|
||||
segments.flatMap((segment, index) => [
|
||||
...(index > 0 ? styled(" ", colors.muted) : []),
|
||||
...styled(segment[0], segment[1], segment[2]),
|
||||
])
|
||||
|
||||
function Monogram(props: { ink: () => RGBA }) {
|
||||
const shadow = createMemo(() => {
|
||||
const ink = props.ink()
|
||||
return RGBA.fromValues(ink.r * 0.25, ink.g * 0.25, ink.b * 0.25)
|
||||
})
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<For each={monogram}>
|
||||
{(line) => (
|
||||
<box flexDirection="row">
|
||||
<For each={Array.from(line)}>
|
||||
{(char) =>
|
||||
char === "_" ? (
|
||||
<text bg={shadow()} selectable={false}>
|
||||
{" "}
|
||||
</text>
|
||||
) : (
|
||||
<text fg={props.ink()} selectable={false}>
|
||||
{char}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
type CellTransition = { from: Cell[]; to: Cell[]; done?: () => void }
|
||||
|
||||
function createTransition(render: (transition: CellTransition, progress: number) => Cell[]) {
|
||||
const [state, setState] = createSignal<{ from: Cell[]; to: Cell[]; done?: () => void } | undefined>()
|
||||
const [progress, setProgress] = createSignal(0)
|
||||
let elapsed = 0
|
||||
const cells = createMemo(() => {
|
||||
const transition = state()
|
||||
if (!transition) return undefined
|
||||
return render(transition, progress())
|
||||
})
|
||||
return {
|
||||
start(from: Cell[], to: Cell[], done?: () => void) {
|
||||
elapsed = 0
|
||||
setProgress(0)
|
||||
setState({ from, to, done })
|
||||
},
|
||||
tick(deltaTime: number) {
|
||||
const transition = state()
|
||||
if (!transition) return
|
||||
elapsed = Math.min(transitionDuration, elapsed + deltaTime)
|
||||
setProgress(elapsed / transitionDuration)
|
||||
if (elapsed < transitionDuration) return
|
||||
setState(undefined)
|
||||
transition.done?.()
|
||||
},
|
||||
cells,
|
||||
progress,
|
||||
}
|
||||
}
|
||||
|
||||
const createSweep = () =>
|
||||
createTransition((transition, progress) => {
|
||||
const length = Math.max(transition.from.length, transition.to.length)
|
||||
const front = smoothstep(progress) * (length + 2 * sweepBlend) - sweepBlend
|
||||
return Array.from({ length }, (_, index) => {
|
||||
const passed = Math.max(0, Math.min(1, (front - index) / sweepBlend))
|
||||
const brightness = smoothstep(Math.abs(passed * 2 - 1))
|
||||
const cell = (passed >= 0.5 ? transition.to[index] : transition.from[index]) ?? {
|
||||
char: " ",
|
||||
color: colors.text,
|
||||
}
|
||||
return { ...cell, color: shade(rampFor(cell.color), brightness) }
|
||||
})
|
||||
})
|
||||
|
||||
const createFade = () =>
|
||||
createTransition((transition, progress) => {
|
||||
const entering = progress >= 0.5
|
||||
const brightness = smoothstep(entering ? progress * 2 - 1 : 1 - progress * 2)
|
||||
return (entering ? transition.to : transition.from).map((cell) => ({
|
||||
...cell,
|
||||
color: shade(rampFor(cell.color), brightness),
|
||||
}))
|
||||
})
|
||||
|
||||
const smoothstep = (value: number) => value * value * (3 - 2 * value)
|
||||
const frameDone = Promise.resolve()
|
||||
|
||||
function UpdateFooter(props: {
|
||||
from?: string
|
||||
active: () => number
|
||||
outcome: () => "running" | "success" | "failure"
|
||||
failure: () => string
|
||||
animating: () => boolean
|
||||
renderer: CliRenderer
|
||||
onOutcomeSettled: () => void
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const [position, setPosition] = createSignal(0)
|
||||
const [pulse, setPulse] = createSignal(0)
|
||||
const headerFade = createFade()
|
||||
const statusSweep = createSweep()
|
||||
const runningHeader = () =>
|
||||
phrase(
|
||||
["OpenCode", colors.muted, true],
|
||||
["is updating", colors.muted],
|
||||
...(props.from
|
||||
? ([
|
||||
["from", colors.muted],
|
||||
[props.from, colors.accentDim],
|
||||
] as const)
|
||||
: []),
|
||||
["to", colors.muted],
|
||||
[InstallationVersion, colors.accent],
|
||||
)
|
||||
const completedHeader = phrase(
|
||||
["OpenCode", colors.muted, true],
|
||||
["updated to", colors.muted],
|
||||
[InstallationVersion, colors.accent],
|
||||
)
|
||||
const pausedHeader = phrase(["OpenCode", colors.muted, true], ["update paused", colors.muted])
|
||||
const outcomeStatus = () =>
|
||||
props.outcome() === "success"
|
||||
? [...styled("✓", colors.success), ...styled(" Ready", colors.text)]
|
||||
: [...styled("!", colors.error), ...styled(" " + props.failure(), colors.text)]
|
||||
let previousStage: string = stages[0]
|
||||
createEffect(
|
||||
on(props.active, (index) => {
|
||||
if (props.outcome() !== "running") return
|
||||
const next = stages[index]
|
||||
if (next === previousStage) return
|
||||
statusSweep.start(styled(previousStage, colors.text), styled(next, colors.text))
|
||||
previousStage = next
|
||||
}),
|
||||
)
|
||||
createEffect(
|
||||
on(
|
||||
props.outcome,
|
||||
(outcome) => {
|
||||
if (outcome === "running") return
|
||||
const visibleStatus = untrack(statusSweep.cells) ?? styled(previousStage, colors.text)
|
||||
headerFade.start(runningHeader(), outcome === "success" ? completedHeader : pausedHeader)
|
||||
statusSweep.start([...styled(" ", colors.text), ...visibleStatus], outcomeStatus(), props.onOutcomeSettled)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const header = createMemo(
|
||||
() =>
|
||||
headerFade.cells() ??
|
||||
(props.outcome() === "success"
|
||||
? completedHeader
|
||||
: props.outcome() === "failure"
|
||||
? pausedHeader
|
||||
: runningHeader()),
|
||||
)
|
||||
const monogramInk = createMemo(() =>
|
||||
props.outcome() === "success" ? shade(monogramRamp, smoothstep(headerFade.progress())) : colors.muted,
|
||||
)
|
||||
const rail = createMemo(() => {
|
||||
const width = Math.max(0, Math.min(30, term().width - 39))
|
||||
if (width === 0) return []
|
||||
if (props.outcome() === "success") return Array.from({ length: width }, () => ({ char: "━", color: colors.accent }))
|
||||
const filled = Math.round(position() * width)
|
||||
const glowRadius = 6
|
||||
const span = Math.max(1, filled + glowRadius * 2)
|
||||
const center = pulse() * span - glowRadius
|
||||
return Array.from({ length: width }, (_, index) => {
|
||||
if (index >= filled) return { char: "·", color: colors.muted }
|
||||
const glow = Math.max(0, 1 - Math.abs(index - center) / glowRadius) ** 2
|
||||
return { char: "━", color: shade(railRamp, glow) }
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
let value = 0
|
||||
let velocity = 0
|
||||
let phase = 0
|
||||
const frame = (deltaTime: number) => {
|
||||
if (!props.animating()) return frameDone
|
||||
const elapsed = Math.min(0.032, deltaTime / 1_000)
|
||||
const stiffness = 110
|
||||
const damping = 2 * Math.sqrt(stiffness)
|
||||
const target = props.outcome() === "success" ? 1 : (props.active() + 1) / stages.length
|
||||
velocity += (stiffness * (target - value) - damping * velocity) * elapsed
|
||||
value += velocity * elapsed
|
||||
phase = (phase + deltaTime / 900) % 1
|
||||
batch(() => {
|
||||
setPosition(Math.max(0, Math.min(1, value)))
|
||||
setPulse(phase)
|
||||
})
|
||||
headerFade.tick(deltaTime)
|
||||
statusSweep.tick(deltaTime)
|
||||
return frameDone
|
||||
}
|
||||
props.renderer.setFrameCallback(frame)
|
||||
onCleanup(() => props.renderer.removeFrameCallback(frame))
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
|
||||
<Monogram ink={monogramInk} />
|
||||
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<CellLine cells={header()} />
|
||||
<Show
|
||||
when={props.outcome() === "running"}
|
||||
fallback={<CellLine cells={statusSweep.cells() ?? outcomeStatus()} />}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={colors.accent} />
|
||||
<CellLine cells={statusSweep.cells() ?? styled(stages[props.active()], colors.text)} />
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<CellLine cells={rail()} />
|
||||
<text fg={colors.muted}>
|
||||
{props.outcome() === "success" ? stages.length : props.active() + 1}/{stages.length}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function CellLine(props: { cells: ReadonlyArray<Cell> }) {
|
||||
return (
|
||||
<text truncate>
|
||||
<Index each={props.cells}>
|
||||
{(cell) => (
|
||||
<span
|
||||
style={{
|
||||
fg: cell().color,
|
||||
attributes: cell().bold ? TextAttributes.BOLD : TextAttributes.NONE,
|
||||
}}
|
||||
>
|
||||
{cell().char}
|
||||
</span>
|
||||
)}
|
||||
</Index>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export * as UpdatePreflight from "./update-preflight"
|
||||
@@ -1,24 +0,0 @@
|
||||
export * as TuiConfig from "./tui-config"
|
||||
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
|
||||
export const load = Effect.fn("TuiConfig.load")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const filepath = path.join(global.config, "tui.json")
|
||||
const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
||||
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
|
||||
|
||||
return TuiConfig.resolve(
|
||||
Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})),
|
||||
{ terminalSuspend: process.platform !== "win32" },
|
||||
)
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createComponent, createSignal } from "solid-js"
|
||||
import { RunFooterView } from "../src/mini/footer.view"
|
||||
|
||||
@@ -11,72 +11,11 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Cause, Effect, Exit, Schedule, Schema } from "effect"
|
||||
import { Effect, Schedule, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
import { StartupError } from "../src/framework/startup-error"
|
||||
|
||||
const RED_BOLD = "\x1b[91m\x1b[1m"
|
||||
const BOLD = "\x1b[1m"
|
||||
const RESET = "\x1b[0m"
|
||||
|
||||
test("renders a missing registration as an actionable startup failure", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-start-"))
|
||||
const registration = path.join(root, "server.json")
|
||||
const script = path.join(root, "exit.ts")
|
||||
await Bun.write(script, "process.exit(1)\n")
|
||||
|
||||
try {
|
||||
const exit = await Service.start({ file: registration, command: [process.execPath, script] }).pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
Effect.exit,
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
expect(error).toBeInstanceOf(Service.StartError)
|
||||
if (error instanceof Service.StartError) {
|
||||
expect(StartupError.render(error, "opencode2")).toBe(
|
||||
`\n${RED_BOLD}OpenCode could not start its background service${RESET}\n\nThe service exited or never became ready.\nThe expected registration file was not created.\n\n${BOLD}Try:${RESET}\n opencode2 service restart\n OPENCODE_LOG_LEVEL=DEBUG opencode2\n`,
|
||||
)
|
||||
}
|
||||
expect(Cause.pretty(exit.cause)).toContain(
|
||||
`[cause]: PlatformError: NotFound: FileSystem.readFile (${registration})`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("reports a service spawn failure without losing its cause", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-spawn-"))
|
||||
const command = path.join(os.tmpdir(), "opencode-command-that-does-not-exist")
|
||||
try {
|
||||
const exit = await Service.start({ file: path.join(root, "server.json"), command: [command] }).pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
Effect.exit,
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
expect(error).toBeInstanceOf(Service.StartError)
|
||||
if (error instanceof Service.StartError) {
|
||||
expect(error.stage).toBe("spawn")
|
||||
expect(StartupError.render(error, "opencode2")).toContain("The service process could not be started.")
|
||||
}
|
||||
expect(Cause.pretty(exit.cause)).toContain(command)
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("local channel stores service config with the local service filename", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { TuiConfig } from "../src/tui-config"
|
||||
|
||||
test("loads the global tui config", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "tui.json"), JSON.stringify({ keybinds: { leader: "ctrl+o" } }))
|
||||
|
||||
try {
|
||||
const config = await Effect.runPromise(
|
||||
TuiConfig.load().pipe(
|
||||
Effect.provide(Global.layerWith({ config: directory })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
|
||||
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
|
||||
expect(config.keybinds.get("session.new")?.[0]?.key).toBe("<leader>n")
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -476,16 +476,18 @@ export interface IntegrationApi<E = never> {
|
||||
type Endpoint11_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||
export type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
|
||||
export type Endpoint11_0Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.list"]>>
|
||||
export type McpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
||||
export type ServerMcpListOperation<E = never> = (input?: Endpoint11_0Input) => Effect.Effect<Endpoint11_0Output, E>
|
||||
|
||||
type Endpoint11_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
|
||||
export type Endpoint11_1Input = { readonly location?: Endpoint11_1Request["query"]["location"] }
|
||||
export type Endpoint11_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
|
||||
export type McpResourceCatalogOperation<E = never> = (input?: Endpoint11_1Input) => Effect.Effect<Endpoint11_1Output, E>
|
||||
export type ServerMcpResourceCatalogOperation<E = never> = (
|
||||
input?: Endpoint11_1Input,
|
||||
) => Effect.Effect<Endpoint11_1Output, E>
|
||||
|
||||
export interface McpApi<E = never> {
|
||||
readonly list: McpListOperation<E>
|
||||
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
|
||||
export interface ServerMcpApi<E = never> {
|
||||
readonly list: ServerMcpListOperation<E>
|
||||
readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation<E> }
|
||||
}
|
||||
|
||||
type Endpoint12_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||
@@ -953,7 +955,7 @@ export interface AppApi<E = never> {
|
||||
readonly generate: GenerateApi<E>
|
||||
readonly provider: ProviderApi<E>
|
||||
readonly integration: IntegrationApi<E>
|
||||
readonly mcp: McpApi<E>
|
||||
readonly "server.mcp": ServerMcpApi<E>
|
||||
readonly credential: CredentialApi<E>
|
||||
readonly project: ProjectApi<E>
|
||||
readonly form: FormApi<E>
|
||||
|
||||
@@ -1134,7 +1134,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
generate: adaptGroup8(raw["server.generate"]),
|
||||
provider: adaptGroup9(raw["server.provider"]),
|
||||
integration: adaptGroup10(raw["server.integration"]),
|
||||
mcp: adaptGroup11(raw["server.mcp"]),
|
||||
"server.mcp": adaptGroup11(raw["server.mcp"]),
|
||||
credential: adaptGroup12(raw["server.credential"]),
|
||||
project: adaptGroup13(raw["server.project"]),
|
||||
form: adaptGroup14(raw["server.form"]),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import { spawn } from "node:child_process"
|
||||
import { once } from "node:events"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
@@ -35,16 +34,8 @@ export type Options = {
|
||||
|
||||
export type StartReason = "missing" | "version-mismatch"
|
||||
|
||||
export class StartError extends Schema.TaggedErrorClass<StartError>()("ServiceStartError", {
|
||||
stage: Schema.Literals(["spawn", "registration", "readiness"]),
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type StartOptions = Options & {
|
||||
// Called once when start() decides it must spawn: either no service was
|
||||
// found, or a healthy service with a different version is being replaced.
|
||||
// `existing` carries the registration of the service being replaced.
|
||||
readonly onStart?: (reason: StartReason, existing?: Info) => void
|
||||
readonly onStart?: (reason: StartReason) => void
|
||||
}
|
||||
|
||||
// Read-only lookup: registration file plus health check and version gate.
|
||||
@@ -66,29 +57,23 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
|
||||
const compatible = yield* discover(options)
|
||||
if (compatible !== undefined) return compatible
|
||||
const mismatched = yield* find(options)
|
||||
yield* Effect.sync(() =>
|
||||
options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch", mismatched?.info),
|
||||
)
|
||||
yield* Effect.sync(() => options.onStart?.(mismatched === undefined ? "missing" : "version-mismatch"))
|
||||
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
||||
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined)
|
||||
return yield* Effect.fail(new StartError({ stage: "spawn", cause: new Error("Missing service command") }))
|
||||
const child = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
const child = yield* Effect.try({
|
||||
try: () => {
|
||||
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
||||
await once(child, "spawn")
|
||||
child.unref()
|
||||
return child
|
||||
},
|
||||
catch: (cause) => new StartError({ stage: "spawn", cause }),
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
|
||||
return yield* awaitReady(options).pipe(
|
||||
return yield* discoverLocal(options).pipe(
|
||||
Effect.flatMap((found) =>
|
||||
found === undefined
|
||||
? Effect.fail(new StartError({ stage: "readiness", cause: new Error("Server is not ready") }))
|
||||
: Effect.succeed(found),
|
||||
found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found),
|
||||
),
|
||||
Effect.retry(poll),
|
||||
Effect.tap((found) =>
|
||||
@@ -100,6 +85,7 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
|
||||
),
|
||||
Effect.map((found) => found.endpoint),
|
||||
Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)),
|
||||
Effect.mapError(() => new Error("Failed to start server")),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -144,18 +130,6 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
||||
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
})
|
||||
|
||||
const awaitReady = Effect.fnUntraced(function* (options: Options) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const info = yield* fs.readFileString(options.file ?? fallback()).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new StartError({ stage: cause.reason._tag === "NotFound" ? "registration" : "readiness", cause }),
|
||||
),
|
||||
Effect.flatMap(decode),
|
||||
Effect.mapError((cause) => (cause instanceof StartError ? cause : new StartError({ stage: "readiness", cause }))),
|
||||
)
|
||||
return yield* probe(info, options.version)
|
||||
})
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export type ClientErrorReason =
|
||||
| "Transport"
|
||||
| "UnexpectedStatus"
|
||||
| "UnsupportedContentType"
|
||||
| "MalformedResponse"
|
||||
| "SseEventTooLarge"
|
||||
export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"
|
||||
|
||||
export class ClientError extends Error {
|
||||
override readonly name = "ClientError"
|
||||
|
||||
@@ -90,10 +90,10 @@ import type {
|
||||
IntegrationAttemptCompleteOutput,
|
||||
IntegrationAttemptCancelInput,
|
||||
IntegrationAttemptCancelOutput,
|
||||
McpListInput,
|
||||
McpListOutput,
|
||||
McpResourceCatalogInput,
|
||||
McpResourceCatalogOutput,
|
||||
ServerMcpListInput,
|
||||
ServerMcpListOutput,
|
||||
ServerMcpResourceCatalogInput,
|
||||
ServerMcpResourceCatalogOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialRemoveInput,
|
||||
@@ -213,8 +213,6 @@ interface RequestDescriptor {
|
||||
readonly binary?: true
|
||||
}
|
||||
|
||||
const maxSseEventBytes = 16 * 1024 * 1024
|
||||
|
||||
export function make(options: ClientOptions) {
|
||||
const fetch = options.fetch ?? globalThis.fetch
|
||||
|
||||
@@ -291,7 +289,7 @@ export function make(options: ClientOptions) {
|
||||
throw new ClientError("Transport", { cause })
|
||||
}
|
||||
buffer += decoder.decode(next.value, { stream: !next.done })
|
||||
if (buffer.length > maxSseEventBytes) throw new ClientError("SseEventTooLarge")
|
||||
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
|
||||
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
|
||||
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
|
||||
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
|
||||
@@ -941,9 +939,9 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
list: (input?: McpListInput, requestOptions?: RequestOptions) =>
|
||||
request<McpListOutput>(
|
||||
"server.mcp": {
|
||||
list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerMcpListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/mcp`,
|
||||
@@ -955,8 +953,8 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
resource: {
|
||||
catalog: (input?: McpResourceCatalogInput, requestOptions?: RequestOptions) =>
|
||||
request<McpResourceCatalogOutput>(
|
||||
catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerMcpResourceCatalogOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/mcp/resource`,
|
||||
|
||||
@@ -130,9 +130,9 @@ export type SessionMessageCompactionCompleted = {
|
||||
|
||||
export type InstructionEntryKey = string
|
||||
|
||||
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } }
|
||||
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: unknown } }
|
||||
|
||||
export type ShellInfo = {
|
||||
export type Shell = {
|
||||
id: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
command: string
|
||||
@@ -141,19 +141,19 @@ export type ShellInfo = {
|
||||
file: string
|
||||
pid?: number
|
||||
exit?: number
|
||||
metadata: { [x: string]: any }
|
||||
metadata: { [x: string]: unknown }
|
||||
time: { started: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageProviderState3 = { [x: string]: any }
|
||||
export type SessionMessageProviderState3 = { [x: string]: unknown }
|
||||
|
||||
export type SessionMessageProviderState4 = { [x: string]: any }
|
||||
export type SessionMessageProviderState4 = { [x: string]: unknown }
|
||||
|
||||
export type SessionMessageProviderState5 = { [x: string]: any }
|
||||
export type SessionMessageProviderState5 = { [x: string]: unknown }
|
||||
|
||||
export type SessionMessageProviderState6 = { [x: string]: any }
|
||||
export type SessionMessageProviderState6 = { [x: string]: unknown }
|
||||
|
||||
export type SessionMessageProviderState7 = { [x: string]: any }
|
||||
export type SessionMessageProviderState7 = { [x: string]: unknown }
|
||||
|
||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||
|
||||
@@ -445,7 +445,7 @@ export type QuestionV2Tool = { messageID: string; callID: string }
|
||||
|
||||
export type QuestionV2Answer = Array<string>
|
||||
|
||||
export type FormMetadata1 = { [x: string]: any }
|
||||
export type FormMetadata1 = { [x: string]: unknown }
|
||||
|
||||
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
|
||||
|
||||
@@ -466,7 +466,7 @@ export type QuestionTool = { messageID: string; callID: string }
|
||||
|
||||
export type QuestionAnswer = Array<string>
|
||||
|
||||
export type ShellInfo1 = {
|
||||
export type Shell1 = {
|
||||
id: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
command: string
|
||||
@@ -474,9 +474,9 @@ export type ShellInfo1 = {
|
||||
shell: string
|
||||
file: string
|
||||
pid?: number
|
||||
exit?: number
|
||||
exit?: number | "Infinity" | "-Infinity" | "NaN"
|
||||
metadata: { [x: string]: JsonValue }
|
||||
time: { started: number; completed?: number }
|
||||
time: { started: number | "Infinity" | "-Infinity" | "NaN"; completed?: number | "Infinity" | "-Infinity" | "NaN" }
|
||||
}
|
||||
|
||||
export type ReferenceLocalSource = { type: "local"; path: string; description?: string; hidden?: boolean }
|
||||
@@ -527,7 +527,7 @@ export type PermissionV2Rule = { action: string; resource: string; effect: Permi
|
||||
export type SessionAgentSelected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.agent.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -537,7 +537,7 @@ export type SessionAgentSelected = {
|
||||
export type SessionModelSelected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.model.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -547,7 +547,7 @@ export type SessionModelSelected = {
|
||||
export type SessionMoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.moved"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -557,7 +557,7 @@ export type SessionMoved = {
|
||||
export type SessionRenamed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.renamed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -567,7 +567,7 @@ export type SessionRenamed = {
|
||||
export type SessionDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.deleted"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
@@ -577,7 +577,7 @@ export type SessionDeleted = {
|
||||
export type SessionForked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.forked"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
@@ -587,7 +587,7 @@ export type SessionForked = {
|
||||
export type SessionInputPromoted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.input.promoted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -597,7 +597,7 @@ export type SessionInputPromoted = {
|
||||
export type SessionExecutionStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.execution.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -607,7 +607,7 @@ export type SessionExecutionStarted = {
|
||||
export type SessionExecutionSucceeded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.execution.succeeded"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -617,7 +617,7 @@ export type SessionExecutionSucceeded = {
|
||||
export type SessionExecutionInterrupted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.execution.interrupted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -627,7 +627,7 @@ export type SessionExecutionInterrupted = {
|
||||
export type SessionInstructionsUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.instructions.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
@@ -637,17 +637,17 @@ export type SessionInstructionsUpdated = {
|
||||
export type SessionSynthetic = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.synthetic"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; text: string; description?: string; metadata?: { [x: string]: any } }
|
||||
data: { sessionID: string; text: string; description?: string; metadata?: { [x: string]: unknown } }
|
||||
}
|
||||
|
||||
export type SessionSkillActivated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.skill.activated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -657,7 +657,7 @@ export type SessionSkillActivated = {
|
||||
export type SessionStepStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.step.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -667,7 +667,7 @@ export type SessionStepStarted = {
|
||||
export type SessionStepEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.step.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -685,7 +685,7 @@ export type SessionStepEnded = {
|
||||
export type SessionTextStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.text.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -695,7 +695,7 @@ export type SessionTextStarted = {
|
||||
export type SessionTextEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.text.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -705,7 +705,7 @@ export type SessionTextEnded = {
|
||||
export type SessionToolInputStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.input.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -715,7 +715,7 @@ export type SessionToolInputStarted = {
|
||||
export type SessionToolInputEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.input.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -725,7 +725,7 @@ export type SessionToolInputEnded = {
|
||||
export type SessionCompactionAdmitted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.compaction.admitted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -735,7 +735,7 @@ export type SessionCompactionAdmitted = {
|
||||
export type SessionCompactionStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.compaction.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -745,7 +745,7 @@ export type SessionCompactionStarted = {
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -755,7 +755,7 @@ export type SessionCompactionEnded = {
|
||||
export type SessionRevertCleared = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.revert.cleared"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -765,7 +765,7 @@ export type SessionRevertCleared = {
|
||||
export type SessionRevertCommitted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.revert.committed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -775,7 +775,7 @@ export type SessionRevertCommitted = {
|
||||
export type ModelsDevRefreshed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "models-dev.refreshed"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -784,7 +784,7 @@ export type ModelsDevRefreshed = {
|
||||
export type IntegrationUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "integration.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -793,7 +793,7 @@ export type IntegrationUpdated = {
|
||||
export type IntegrationConnectionUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "integration.connection.updated"
|
||||
location?: LocationRef
|
||||
data: { integrationID: string }
|
||||
@@ -802,7 +802,7 @@ export type IntegrationConnectionUpdated = {
|
||||
export type CatalogUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "catalog.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -811,7 +811,7 @@ export type CatalogUpdated = {
|
||||
export type AgentUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "agent.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -820,7 +820,7 @@ export type AgentUpdated = {
|
||||
export type MessageRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "message.removed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -830,7 +830,7 @@ export type MessageRemoved = {
|
||||
export type MessagePartRemoved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "message.part.removed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -840,7 +840,7 @@ export type MessagePartRemoved = {
|
||||
export type SessionUsageUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.usage.updated"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; cost: MoneyUSD; tokens: TokenUsageInfo }
|
||||
@@ -849,7 +849,7 @@ export type SessionUsageUpdated = {
|
||||
export type SessionTextDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.text.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
@@ -858,7 +858,7 @@ export type SessionTextDelta = {
|
||||
export type SessionReasoningDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.reasoning.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; delta: string }
|
||||
@@ -867,7 +867,7 @@ export type SessionReasoningDelta = {
|
||||
export type SessionToolInputDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.input.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; callID: string; delta: string }
|
||||
@@ -876,7 +876,7 @@ export type SessionToolInputDelta = {
|
||||
export type SessionCompactionDelta = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.compaction.delta"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; text: string }
|
||||
@@ -885,7 +885,7 @@ export type SessionCompactionDelta = {
|
||||
export type FilesystemChanged = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "filesystem.changed"
|
||||
location?: LocationRef
|
||||
data: { file: string; event: "add" | "change" | "unlink" }
|
||||
@@ -894,7 +894,7 @@ export type FilesystemChanged = {
|
||||
export type ReferenceUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "reference.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -903,7 +903,7 @@ export type ReferenceUpdated = {
|
||||
export type PluginAdded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "plugin.added"
|
||||
location?: LocationRef
|
||||
data: { id: string }
|
||||
@@ -912,7 +912,7 @@ export type PluginAdded = {
|
||||
export type PluginUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "plugin.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -921,7 +921,7 @@ export type PluginUpdated = {
|
||||
export type ProjectDirectoriesUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "project.directories.updated"
|
||||
location?: LocationRef
|
||||
data: { projectID: string }
|
||||
@@ -930,7 +930,7 @@ export type ProjectDirectoriesUpdated = {
|
||||
export type CommandUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "command.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -939,7 +939,7 @@ export type CommandUpdated = {
|
||||
export type ConfigUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "config.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -948,7 +948,7 @@ export type ConfigUpdated = {
|
||||
export type SkillUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "skill.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
@@ -957,7 +957,7 @@ export type SkillUpdated = {
|
||||
export type PtyExited = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "pty.exited"
|
||||
location?: LocationRef
|
||||
data: { id: string; exitCode: number }
|
||||
@@ -966,7 +966,7 @@ export type PtyExited = {
|
||||
export type PtyDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "pty.deleted"
|
||||
location?: LocationRef
|
||||
data: { id: string }
|
||||
@@ -975,7 +975,7 @@ export type PtyDeleted = {
|
||||
export type ShellExited = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "shell.exited"
|
||||
location?: LocationRef
|
||||
data: { id: string; exit?: number; status: "running" | "exited" | "timeout" | "killed" }
|
||||
@@ -984,7 +984,7 @@ export type ShellExited = {
|
||||
export type ShellDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "shell.deleted"
|
||||
location?: LocationRef
|
||||
data: { id: string }
|
||||
@@ -993,7 +993,7 @@ export type ShellDeleted = {
|
||||
export type QuestionV2Rejected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.v2.rejected"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string }
|
||||
@@ -1002,7 +1002,7 @@ export type QuestionV2Rejected = {
|
||||
export type FormCancelled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "form.cancelled"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string }
|
||||
@@ -1011,7 +1011,7 @@ export type FormCancelled = {
|
||||
export type SessionIdle = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.idle"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string }
|
||||
@@ -1020,7 +1020,7 @@ export type SessionIdle = {
|
||||
export type TuiPromptAppend = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "tui.prompt.append"
|
||||
location?: LocationRef
|
||||
data: { text: string }
|
||||
@@ -1029,7 +1029,7 @@ export type TuiPromptAppend = {
|
||||
export type TuiCommandExecute = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "tui.command.execute"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
@@ -1058,7 +1058,7 @@ export type TuiCommandExecute = {
|
||||
export type TuiToastShow = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "tui.toast.show"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
@@ -1072,7 +1072,7 @@ export type TuiToastShow = {
|
||||
export type TuiSessionSelect = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "tui.session.select"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string }
|
||||
@@ -1081,7 +1081,7 @@ export type TuiSessionSelect = {
|
||||
export type InstallationUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "installation.updated"
|
||||
location?: LocationRef
|
||||
data: { version: string }
|
||||
@@ -1090,7 +1090,7 @@ export type InstallationUpdated = {
|
||||
export type InstallationUpdateAvailable = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "installation.update-available"
|
||||
location?: LocationRef
|
||||
data: { version: string }
|
||||
@@ -1099,7 +1099,7 @@ export type InstallationUpdateAvailable = {
|
||||
export type VcsBranchUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "vcs.branch.updated"
|
||||
location?: LocationRef
|
||||
data: { branch?: string }
|
||||
@@ -1108,7 +1108,7 @@ export type VcsBranchUpdated = {
|
||||
export type McpStatusChanged = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "mcp.status.changed"
|
||||
location?: LocationRef
|
||||
data: { server: string }
|
||||
@@ -1117,7 +1117,7 @@ export type McpStatusChanged = {
|
||||
export type McpResourcesChanged = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "mcp.resources.changed"
|
||||
location?: LocationRef
|
||||
data: { server: string }
|
||||
@@ -1126,7 +1126,7 @@ export type McpResourcesChanged = {
|
||||
export type PermissionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "permission.asked"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
@@ -1134,7 +1134,7 @@ export type PermissionAsked = {
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: Array<string>
|
||||
metadata: { [x: string]: any }
|
||||
metadata: { [x: string]: unknown }
|
||||
always: Array<string>
|
||||
tool?: { messageID: string; callID: string } | undefined
|
||||
}
|
||||
@@ -1143,7 +1143,7 @@ export type PermissionAsked = {
|
||||
export type PermissionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "permission.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" }
|
||||
@@ -1152,7 +1152,7 @@ export type PermissionReplied = {
|
||||
export type QuestionRejected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.rejected"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string }
|
||||
@@ -1160,7 +1160,7 @@ export type QuestionRejected = {
|
||||
|
||||
export type V2EventServerConnected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
metadata?: { [x: string]: unknown } | undefined
|
||||
location?: LocationRef | undefined
|
||||
type: "server.connected"
|
||||
data: {}
|
||||
@@ -1213,7 +1213,7 @@ export type SessionMessageCompactionFailed = {
|
||||
export type SessionExecutionFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.execution.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1223,7 +1223,7 @@ export type SessionExecutionFailed = {
|
||||
export type SessionStepFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.step.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1239,7 +1239,7 @@ export type SessionStepFailed = {
|
||||
export type SessionRetryScheduled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.retry.scheduled"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1249,7 +1249,7 @@ export type SessionRetryScheduled = {
|
||||
export type SessionCompactionFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.compaction.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1267,23 +1267,23 @@ export type SessionPendingSyntheticMessage = {
|
||||
export type SessionShellStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.shell.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; shell: ShellInfo }
|
||||
data: { sessionID: string; shell: Shell }
|
||||
}
|
||||
|
||||
export type SessionShellEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.shell.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
shell: ShellInfo
|
||||
shell: Shell
|
||||
output: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
}
|
||||
@@ -1291,16 +1291,16 @@ export type SessionShellEnded = {
|
||||
export type ShellCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "shell.created"
|
||||
location?: LocationRef
|
||||
data: { info: ShellInfo }
|
||||
data: { info: Shell }
|
||||
}
|
||||
|
||||
export type SessionReasoningStarted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.reasoning.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1310,7 +1310,7 @@ export type SessionReasoningStarted = {
|
||||
export type SessionReasoningEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.reasoning.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1326,7 +1326,7 @@ export type SessionReasoningEnded = {
|
||||
export type SessionToolCalled = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.called"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1334,7 +1334,7 @@ export type SessionToolCalled = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
input: { [x: string]: any }
|
||||
input: { [x: string]: unknown }
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState5
|
||||
}
|
||||
@@ -1343,7 +1343,7 @@ export type SessionToolCalled = {
|
||||
export type SessionToolFailed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1352,7 +1352,7 @@ export type SessionToolFailed = {
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
error: SessionStructuredError
|
||||
result?: any
|
||||
result?: unknown
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState7
|
||||
}
|
||||
@@ -1490,7 +1490,7 @@ export type PermissionV2Request = {
|
||||
export type PermissionV2Asked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "permission.v2.asked"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
@@ -1499,7 +1499,7 @@ export type PermissionV2Asked = {
|
||||
action: string
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
source?: PermissionV2Source
|
||||
}
|
||||
}
|
||||
@@ -1558,7 +1558,7 @@ export type RetryPart = {
|
||||
export type SessionError = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.error"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
@@ -1592,7 +1592,7 @@ export type SymbolSource = {
|
||||
export type PermissionV2Replied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "permission.v2.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; reply: PermissionV2Reply }
|
||||
@@ -1601,7 +1601,7 @@ export type PermissionV2Replied = {
|
||||
export type PtyCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "pty.created"
|
||||
location?: LocationRef
|
||||
data: { info: Pty }
|
||||
@@ -1610,7 +1610,7 @@ export type PtyCreated = {
|
||||
export type PtyUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "pty.updated"
|
||||
location?: LocationRef
|
||||
data: { info: Pty }
|
||||
@@ -1627,7 +1627,7 @@ export type QuestionV2Info = {
|
||||
export type QuestionV2Replied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.v2.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionV2Answer> }
|
||||
@@ -1701,7 +1701,7 @@ export type FormMultiselectField1 = {
|
||||
export type SessionStatus2 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.status"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; status: SessionStatus }
|
||||
@@ -1718,7 +1718,7 @@ export type QuestionInfo = {
|
||||
export type QuestionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
|
||||
@@ -1747,7 +1747,7 @@ export type SessionInfo = {
|
||||
export type SessionRevertStaged = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.revert.staged"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1775,7 +1775,7 @@ export type SessionPendingUserData1 = {
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
}
|
||||
|
||||
export type SessionMessageToolStateRunning = {
|
||||
@@ -1805,7 +1805,7 @@ export type SessionMessageToolStateError = {
|
||||
export type SessionToolProgress = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.progress"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1813,7 +1813,7 @@ export type SessionToolProgress = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
structured: { [x: string]: any }
|
||||
structured: { [x: string]: unknown }
|
||||
content: Array<LLMToolContent>
|
||||
}
|
||||
}
|
||||
@@ -1821,7 +1821,7 @@ export type SessionToolProgress = {
|
||||
export type SessionToolSuccess = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.tool.success"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -1829,9 +1829,9 @@ export type SessionToolSuccess = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
callID: string
|
||||
structured: { [x: string]: any }
|
||||
structured: { [x: string]: unknown }
|
||||
content: Array<LLMToolContent>
|
||||
result?: any
|
||||
result?: unknown
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState6
|
||||
}
|
||||
@@ -1881,7 +1881,7 @@ export type FormState = { status: "pending" } | { status: "answered"; answer: Fo
|
||||
export type FormReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "form.replied"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; answer: FormAnswer }
|
||||
@@ -1907,7 +1907,7 @@ export type FilePartSource = FileSource | SymbolSource | ResourceSource
|
||||
export type QuestionV2Asked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.v2.asked"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionV2Info>; tool?: QuestionV2Tool }
|
||||
@@ -1931,20 +1931,12 @@ export type FormField1 =
|
||||
export type QuestionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "question.asked"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool | undefined }
|
||||
}
|
||||
|
||||
export type ReferenceInfo = {
|
||||
name: string
|
||||
path: string
|
||||
description?: string
|
||||
hidden?: boolean
|
||||
source: ReferenceSource
|
||||
}
|
||||
|
||||
export type AgentInfo = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -2049,19 +2041,12 @@ export type SessionMessageAssistant = {
|
||||
retry?: SessionMessageAssistantRetry
|
||||
}
|
||||
|
||||
export type IntegrationInfo = {
|
||||
id: string
|
||||
name: string
|
||||
methods: Array<IntegrationMethod>
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
|
||||
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -2071,7 +2056,7 @@ export type SessionCreated = {
|
||||
export type SessionUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -2081,7 +2066,7 @@ export type SessionUpdated = {
|
||||
export type SessionDeleted1 = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.deleted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -2091,7 +2076,7 @@ export type SessionDeleted1 = {
|
||||
export type MessageUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "message.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -2113,7 +2098,7 @@ export type FormInfo1 = { id: string; sessionID: string; title: string; metadata
|
||||
export type SessionInputAdmitted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "session.input.admitted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -2136,7 +2121,7 @@ export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "form.created"
|
||||
location?: LocationRef
|
||||
data: { form: FormInfo1 }
|
||||
@@ -2217,7 +2202,7 @@ export type Part =
|
||||
export type MessagePartUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
metadata?: { [x: string]: unknown }
|
||||
type: "message.part.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
@@ -3256,7 +3241,7 @@ export type IntegrationListInput = {
|
||||
|
||||
export type IntegrationListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<IntegrationInfo>
|
||||
data: Array<{ id: string; name: string; methods: Array<IntegrationMethod>; connections: Array<ConnectionInfo> }>
|
||||
}
|
||||
|
||||
export type IntegrationGetInput = {
|
||||
@@ -3268,7 +3253,7 @@ export type IntegrationGetInput = {
|
||||
|
||||
export type IntegrationGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: IntegrationInfo | null
|
||||
data: { id: string; name: string; methods: Array<IntegrationMethod>; connections: Array<ConnectionInfo> } | null
|
||||
}
|
||||
|
||||
export type IntegrationConnectKeyInput = {
|
||||
@@ -3346,24 +3331,24 @@ export type IntegrationAttemptCancelInput = {
|
||||
|
||||
export type IntegrationAttemptCancelOutput = void
|
||||
|
||||
export type McpListInput = {
|
||||
export type ServerMcpListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type McpListOutput = {
|
||||
export type ServerMcpListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<McpServer>
|
||||
}
|
||||
|
||||
export type McpResourceCatalogInput = {
|
||||
export type ServerMcpResourceCatalogInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type McpResourceCatalogOutput = {
|
||||
export type ServerMcpResourceCatalogOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: McpResourceCatalog
|
||||
}
|
||||
@@ -4554,7 +4539,7 @@ export type ShellListInput = {
|
||||
|
||||
export type ShellListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ShellInfo1>
|
||||
data: Array<Shell1>
|
||||
}
|
||||
|
||||
export type ShellCreateInput = {
|
||||
@@ -4589,7 +4574,7 @@ export type ShellCreateInput = {
|
||||
|
||||
export type ShellCreateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
data: Shell1
|
||||
}
|
||||
|
||||
export type ShellGetInput = {
|
||||
@@ -4601,7 +4586,7 @@ export type ShellGetInput = {
|
||||
|
||||
export type ShellGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
data: Shell1
|
||||
}
|
||||
|
||||
export type ShellTimeoutInput = {
|
||||
@@ -4614,7 +4599,7 @@ export type ShellTimeoutInput = {
|
||||
|
||||
export type ShellTimeoutOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
data: Shell1
|
||||
}
|
||||
|
||||
export type ShellOutputInput = {
|
||||
@@ -4688,7 +4673,7 @@ export type ReferenceListInput = {
|
||||
|
||||
export type ReferenceListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ReferenceInfo>
|
||||
data: Array<{ name: string; path: string; description?: string; hidden?: boolean; source: ReferenceSource }>
|
||||
}
|
||||
|
||||
export type ProjectCopyCreateInput = {
|
||||
|
||||
@@ -284,43 +284,6 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("event.subscribe accepts a fragmented SSE event below the size limit", async () => {
|
||||
const event = { id: "evt_large", type: "test.large", data: { output: "x".repeat(12 * 1024 * 1024) } }
|
||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (let offset = 0; offset < encoded.length; offset += 64 * 1024) {
|
||||
controller.enqueue(encoded.slice(offset, offset + 64 * 1024))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
})
|
||||
|
||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
|
||||
})
|
||||
|
||||
test("event.subscribe rejects an SSE event above the size limit", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(`data: ${JSON.stringify({ output: "x".repeat(16 * 1024 * 1024) })}`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
||||
name: "ClientError",
|
||||
reason: "SseEventTooLarge",
|
||||
})
|
||||
})
|
||||
|
||||
test("session methods use the public HTTP contract", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
const client = OpenCode.make({
|
||||
|
||||
+261
-92
@@ -1,40 +1,37 @@
|
||||
# @opencode-ai/codemode
|
||||
|
||||
This is our take on code mode. Programs are written in a lightweight, JavaScript-like DSL and run in the package's
|
||||
own interpreter. They never execute as actual JavaScript, so there is no runtime to escape into. The interpreter
|
||||
itself can reach nothing; every effect a program has goes through a tool you explicitly supplied. The tradeoff is a
|
||||
bounded language rather than full JavaScript: the [interpreter support checklist](./interpreter-support.md) documents
|
||||
exactly what is supported.
|
||||
Effect-native confined code execution over explicit, schema-described tools.
|
||||
|
||||
[Cloudflare's post](https://blog.cloudflare.com/code-mode/) introduced the idea. Their implementation executes
|
||||
generated code in isolate sandboxes. We took a lighter route: a pure interpreter that runs wherever your application
|
||||
runs, no sandbox required.
|
||||
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
|
||||
|
||||
## How it differs from JavaScript
|
||||
The package is currently private to this workspace. Its API is designed around one-shot and reusable execution:
|
||||
|
||||
The deliberate differences:
|
||||
```ts
|
||||
// One execution
|
||||
yield * CodeMode.execute({ tools, code })
|
||||
|
||||
- **No ambient authority.** No `fetch`, `process`, filesystem, timers, or host globals - only the allowlisted standard
|
||||
library and the `tools` tree.
|
||||
- **No dynamic code.** No `eval`, `Function`, or module loading.
|
||||
- **Plain-data boundaries.** Tool arguments and program results are JSON-like data. Dates become ISO strings, RegExp,
|
||||
Map, and Set serialize as `{}`, and promises, functions, and runtime references cannot cross the boundary.
|
||||
- **Eager, supervised promises.** Tool calls and async functions start immediately when called. Whatever is still
|
||||
running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must
|
||||
await every call whose completion matters. Rejections that settle un-awaited become `warnings` on the result instead
|
||||
of crashing the run.
|
||||
- **REPL-style results.** An omitted `return` yields the final top-level expression; `undefined` normalizes to `null`.
|
||||
// A reusable runtime
|
||||
const runtime = CodeMode.make({ tools, limits })
|
||||
yield * runtime.execute(code)
|
||||
```
|
||||
|
||||
Beyond these, the language is a growing subset rather than a divergent one: unsupported syntax returns an
|
||||
`UnsupportedSyntax` diagnostic with a source location, and current gaps (for example thenable assimilation, classes,
|
||||
generators, and full sparse-array parity) are tracked as unchecked items in the
|
||||
[interpreter support checklist](./interpreter-support.md).
|
||||
## Install
|
||||
|
||||
Within this workspace:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@opencode-ai/codemode": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Hosts interact with CodeMode through `effect` (tool `run` implementations, `Effect`-typed results), so they should depend on `effect` themselves.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The package is workspace-private (`"@opencode-ai/codemode": "workspace:*"`). Hosts interact with it through `effect`
|
||||
and should depend on `effect` themselves. Define tools with Effect Schema, then place them in the object tree exposed
|
||||
to programs as `tools`:
|
||||
Define tools with Effect Schema, then place them in the object tree exposed to programs as `tools`:
|
||||
|
||||
```ts
|
||||
import { CodeMode, Tool } from "@opencode-ai/codemode"
|
||||
@@ -63,53 +60,69 @@ const result =
|
||||
`)
|
||||
```
|
||||
|
||||
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics
|
||||
rather than failing the Effect; host interruption remains interruption.
|
||||
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
|
||||
|
||||
Successful result values are JSON-safe data. An explicit `return` produces the program result; when it is omitted, the final executable top-level expression is returned as a model-friendly REPL convenience. Otherwise reaching the end produces `null`. Returned `undefined` and nested `undefined` values are normalized to `null` as well.
|
||||
|
||||
## API
|
||||
|
||||
### `Tool.make`
|
||||
|
||||
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document. Effect Schema input
|
||||
is decoded before `run` is invoked; an Effect Schema `output` is decoded and copied before the program sees it. JSON
|
||||
Schemas only shape the model-visible signature. Without `output` the signature advertises `Promise<unknown>`.
|
||||
Descriptions and schemas are model-visible contract; keep authorization in `run`.
|
||||
```ts
|
||||
const tool = Tool.make({
|
||||
description,
|
||||
input, // Effect Schema (validating) or JSON Schema (render-only)
|
||||
output, // optional; same choice
|
||||
run,
|
||||
})
|
||||
```
|
||||
|
||||
### `CodeMode.execute` and `CodeMode.make`
|
||||
`input` and `output` each accept a validating Effect Schema or a render-only JSON Schema document (the natural shape for adapter-provided tools whose schemas arrive as JSON Schema, e.g. MCP definitions). Effect Schema input is decoded before `run` is invoked, and `run` returns the encoded representation of an Effect Schema `output`, which CodeMode decodes and copies before exposing it to the program. JSON Schemas only shape the model-visible signature; values pass through unvalidated (they still cross the plain-data boundary).
|
||||
|
||||
`CodeMode.execute({ ...options, code })` runs once and is equivalent to `CodeMode.make(options).execute(code)`. A
|
||||
runtime from `make` reuses the tool set and policy:
|
||||
`output` is optional. Without it the tool's signature advertises `Promise<unknown>` and the host result is exposed as-is.
|
||||
|
||||
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
|
||||
|
||||
Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`.
|
||||
|
||||
### `CodeMode.execute`
|
||||
|
||||
Use `CodeMode.execute` for a single execution:
|
||||
|
||||
```ts
|
||||
const runtime = CodeMode.make({ tools, limits: { timeoutMs: 30_000 } })
|
||||
const result =
|
||||
yield *
|
||||
CodeMode.execute({
|
||||
tools: { orders: { lookup: lookupOrder } },
|
||||
code: `return await tools.orders.lookup({ id: "order_42" })`,
|
||||
limits: { maxToolCalls: 10 },
|
||||
onToolCallStart: (call) => Effect.logDebug("CodeMode tool started", call),
|
||||
onToolCallEnd: (call) => Effect.logDebug("CodeMode tool settled", call),
|
||||
})
|
||||
```
|
||||
|
||||
The Effect environment is inferred from the supplied tools. CodeMode does not erase service requirements introduced by tool implementations.
|
||||
|
||||
### `CodeMode.make`
|
||||
|
||||
Use `CodeMode.make` when the tool set and execution policy are reused:
|
||||
|
||||
```ts
|
||||
const runtime = CodeMode.make({
|
||||
tools: { orders: { lookup: lookupOrder } },
|
||||
limits: { timeoutMs: 30_000 },
|
||||
})
|
||||
|
||||
runtime.catalog() // structured tool descriptions
|
||||
runtime.instructions() // model-facing syntax and tool guide
|
||||
runtime.execute(source) // CodeMode.Result
|
||||
```
|
||||
|
||||
The Effect environment is inferred from the supplied tools; service requirements are not erased. Optional
|
||||
`onToolCallStart` / `onToolCallEnd` hooks observe admitted calls with decoded input, outcome, and duration; both are
|
||||
Effect-returning and must not fail.
|
||||
`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool.
|
||||
|
||||
### OpenAPI tools
|
||||
All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types.
|
||||
|
||||
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation, namespaced by dotted
|
||||
`operationId`:
|
||||
|
||||
```ts
|
||||
const api = OpenAPI.fromSpec({ spec, auth: { resolve } })
|
||||
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
|
||||
```
|
||||
|
||||
It is synchronous and returns `{ tools, skipped }`: operations with unsupported encodings, non-JSON bodies, binary
|
||||
responses, or streaming land in `skipped` instead of producing broken tools. Auth is resolved host-side and never
|
||||
model-visible; generated tools require `HttpClient.HttpClient` in the environment. See the option docstrings in
|
||||
`src/openapi/types.ts` for full semantics.
|
||||
|
||||
## Outputs
|
||||
|
||||
Every execution returns a `CodeMode.Result`:
|
||||
### Results
|
||||
|
||||
```ts
|
||||
type Result = Success | Failure
|
||||
@@ -132,11 +145,152 @@ interface Failure {
|
||||
}
|
||||
```
|
||||
|
||||
`value` is JSON-safe data. `warnings` are non-fatal diagnostics alongside a valid value (un-awaited rejections,
|
||||
timeout cleanup after the return). `logs` holds program console output, `truncated` marks any output-budget cut, and
|
||||
`toolCalls` lists admitted calls in order - retained on failure for auditing.
|
||||
`toolCalls` contains the names of calls admitted by the runtime in call order. It is retained on failure so hosts can audit partial execution without exposing inputs or host failures. A successful execution may also contain `warnings`: runtime-authored, non-fatal diagnostics alongside a valid value - unhandled rejections from promises that failed, un-awaited, before the program returned, or background work interrupted by the timeout after the program returned. Anything still running when the program returns is interrupted - race losers and fire-and-forget calls alike - so a program must await every call whose completion matters. Failure has an `error`; success may have `warnings`; program-authored console output stays in `logs`. Keeping the value on an unhandled rejection is a deliberate divergence from Node's crash-on-unhandled-rejection default: the computed value and the background failure are independently useful to the model, so the result carries both. When warnings are cut by `maxOutputBytes`, a final `Truncated` diagnostic marks the omission in-band, and `truncated` marks any result, warning, or log truncation (see Execution Limits).
|
||||
|
||||
Failure `error` and success `warnings` share one diagnostic vocabulary:
|
||||
### Tool-call hooks
|
||||
|
||||
`onToolCallStart` receives `{ index, name, input }` after input decoding and before tool execution. The input is decoded host-side data and may include values produced by schema transformations; applications should avoid logging sensitive tool arguments indiscriminately.
|
||||
|
||||
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace.
|
||||
|
||||
```ts
|
||||
import { CodeMode, OpenAPI } from "@opencode-ai/codemode"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const api = OpenAPI.fromSpec({
|
||||
spec: await Bun.file("openapi.json").json(), // parsed document (no YAML)
|
||||
auth: {
|
||||
resolve: ({ name, scopes, operation }) =>
|
||||
name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined),
|
||||
},
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
|
||||
const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer)))
|
||||
```
|
||||
|
||||
`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`.
|
||||
|
||||
Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes.
|
||||
|
||||
## Discovery
|
||||
|
||||
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete, JSDoc-annotated tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Schema field descriptions and tags are part of each signature's measured cost. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature against the shared budget, and a namespace whose next signature does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
|
||||
|
||||
The catalog-entry budget defaults to 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). It applies only to full tool entries shown in the catalog; fixed instructions and namespace summaries are not counted. Override it when constructing a runtime:
|
||||
|
||||
```ts
|
||||
const runtime = CodeMode.make({
|
||||
tools,
|
||||
discovery: { catalogBudget: 6_000 },
|
||||
})
|
||||
```
|
||||
|
||||
The budget must be a non-negative safe integer.
|
||||
|
||||
The runtime search tool is always registered - including when the catalog is fully inlined - so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial:
|
||||
|
||||
```ts
|
||||
const matches = await tools.$codemode.search({
|
||||
query: "order status",
|
||||
namespace: "orders", // optional: scope to one top-level namespace
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
```
|
||||
|
||||
`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path), then sliced from the zero-based `offset` (default 0) to the configured `limit` (default 10). `remaining` counts matches after the current page. `next` is `{ offset }` when another page exists and `null` on the final page; spread it into the original request to preserve its query, namespace, and limit.
|
||||
|
||||
```ts
|
||||
const request = { query: "order status", namespace: "orders", limit: 10 }
|
||||
const page = await tools.$codemode.search(request)
|
||||
const nextPage = page.next ? await tools.$codemode.search({ ...request, ...page.next }) : undefined
|
||||
```
|
||||
|
||||
Each result contains the path, description, and the same generated TypeScript signature used by the inline catalog, so no second lookup is needed. Signatures use the JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
|
||||
|
||||
```ts
|
||||
tools.github.list_issues(input: {
|
||||
/** Repository owner */
|
||||
owner: string,
|
||||
/** Cursor from the previous response's pageInfo */
|
||||
after?: string,
|
||||
/**
|
||||
* Results per page
|
||||
* @default 30
|
||||
*/
|
||||
perPage?: number,
|
||||
}): Promise<unknown>
|
||||
```
|
||||
|
||||
Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone.
|
||||
|
||||
The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result Code Mode tools and internal runtime tools exist inside `tools`; filter and aggregate collections in code; narrow `Promise<unknown>` results at runtime; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace and paginate search results when search is advertised), a short `## Language` section that identifies the runtime as a restricted JavaScript orchestration language and names its major unavailable capabilities, and the budgeted `## Available tools` catalog. Example call forms use explicit `<namespace>.<tool>`/`<field>` placeholders - never a real or fabricated tool name.
|
||||
|
||||
A host cannot define its own `$codemode` top-level namespace.
|
||||
|
||||
## Supported Programs
|
||||
|
||||
CodeMode executes a deliberately bounded JavaScript subset. See the
|
||||
[interpreter support checklist](./interpreter-support.md) for the complete, checkable language and standard-library
|
||||
matrix, known semantic gaps, and intentional exclusions.
|
||||
|
||||
At a high level, it supports:
|
||||
|
||||
- Plain data, property access and assignment, destructuring, functions, conditionals, loops, spread, optional chaining,
|
||||
and structured error handling.
|
||||
- Allowlisted Array, String, Number, Object, Math, JSON, console, Date, RegExp, Map, Set, URL, and URLSearchParams APIs.
|
||||
- Eager supervised tool promises, direct `await`, and the supported `Promise` combinators for concurrent work.
|
||||
- Live standard-library values inside the sandbox and predictable JSON-like serialization at tool/result boundaries.
|
||||
- Actionable diagnostics for unsupported syntax, invalid data, tool failures, limits, and execution failures.
|
||||
|
||||
It does not expose ambient host authority or arbitrary JavaScript execution. Unsupported syntax returns an
|
||||
`UnsupportedSyntax` diagnostic with a source location when available.
|
||||
|
||||
CodeMode is an orchestration language, not a general JavaScript runtime.
|
||||
|
||||
## Execution Limits
|
||||
|
||||
The limits are exactly three knobs:
|
||||
|
||||
| Limit | Default | Bounds |
|
||||
| ---------------- | -------------------: | ---------------------------------------------------- |
|
||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
|
||||
|
||||
No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context.
|
||||
|
||||
Pass only the overrides you need:
|
||||
|
||||
```ts
|
||||
const runtime = CodeMode.make({
|
||||
tools,
|
||||
limits: {
|
||||
maxToolCalls: 20,
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0`. Invalid configuration throws a `RangeError` when `CodeMode.make` or `CodeMode.execute` is called. An explicitly `undefined` value is the same as leaving the limit unset.
|
||||
|
||||
`maxOutputBytes` is a payload budget, not a strict byte cap on the final rendered tool message. It counts the serialized result value and retained log lines; warning diagnostics are bounded by a separate budget of the same size, so a large value never silences runtime diagnostics. Fixed truncation notices and framing added by a host when it renders the structured result are additional and may make the final message exceed the configured number.
|
||||
|
||||
Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept within the remaining budget, warnings are kept within their own budget, omitted entries receive a summary marker, and the result carries `truncated: true`.
|
||||
|
||||
When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded: the timeout bounds when interruption begins, not when the result is delivered, which waits for tool interruption cleanup to finish. If the timeout fires after the program has already returned a valid value - while the runtime is interrupting leftover work and waiting for its cleanup - the result stays successful: the computed value is returned with a `TimeoutExceeded` warning instead of being discarded.
|
||||
|
||||
Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Failures are data:
|
||||
|
||||
| Kind | Meaning |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
@@ -152,45 +306,58 @@ Failure `error` and success `warnings` share one diagnostic vocabulary:
|
||||
| `ExecutionFailure` | The program threw or another execution error occurred. |
|
||||
| `Truncated` | Warning-only marker: additional warnings were omitted by `maxOutputBytes`. |
|
||||
|
||||
Unknown host failures, defects, and invalid outputs are sanitized. `toolError("safe message")` is the explicit channel
|
||||
for a model-visible refusal; its optional cause never crosses the boundary.
|
||||
Unknown host failures, defects, invalid outputs, and copying failures are sanitized. To return a safe operational refusal, fail with `toolError`:
|
||||
|
||||
## Discovery
|
||||
```ts
|
||||
import { toolError } from "@opencode-ai/codemode"
|
||||
|
||||
The generated instructions inline a budgeted catalog (default 2,000 estimated tokens, override with
|
||||
`discovery: { catalogBudget }`): every namespace is always listed with its tool count, signatures are selected
|
||||
round-robin so every namespace gets representation, and the instructions state whether the list is complete or
|
||||
partial. Programs also get a global `search(...)` built-in - always available, advertised when the list is partial:
|
||||
synchronous, deterministic field-weighted substring matching that returns directly callable paths with full
|
||||
signatures, supports namespace scoping and pagination, and treats an empty query as browsing and an exact path as
|
||||
lookup. Search counts as an admitted tool call.
|
||||
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
|
||||
```
|
||||
|
||||
## Execution Limits
|
||||
Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary.
|
||||
|
||||
| Limit | Default | Bounds |
|
||||
| ---------------- | -------------------: | ---------------------------------------------------- |
|
||||
| `timeoutMs` | none - no timeout | Wall-clock execution time. |
|
||||
| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. |
|
||||
| `maxOutputBytes` | none - no truncation | Retained result value and logs; warnings separately. |
|
||||
## Authority Boundary
|
||||
|
||||
No limit has a default, on purpose: execution budgets are host policy. A host without its own truncation or
|
||||
interruption should set `maxOutputBytes` and `timeoutMs`. Limits are safe integers; invalid configuration throws a
|
||||
`RangeError` at construction. Exceeding `maxOutputBytes` never fails the execution - oversized output is truncated
|
||||
with an in-band marker. The timeout interrupts in-flight tool fibers and pure busy loops alike; a value the program
|
||||
already returned survives a cleanup timeout as a success with a `TimeoutExceeded` warning. Two internals are fixed
|
||||
constants, not knobs: at most 8 concurrent tool calls, and 32 levels of data nesting at boundaries.
|
||||
CodeMode confines programs to the supplied tool tree, but it does not decide what those tools may do.
|
||||
|
||||
## Boundaries and Non-Goals
|
||||
The host owns:
|
||||
|
||||
The host owns authentication, authorization, tool selection, credentials, persistence, approval, and logging policy.
|
||||
CodeMode owns interpretation, schema and plain-data boundaries, resource limits, diagnostics, and discovery. A program
|
||||
can only exercise authority already present in the supplied tools - do not expose a broad tool and expect the prompt
|
||||
to restrict it.
|
||||
- Authentication and authorization.
|
||||
- Tool selection and immutable scope.
|
||||
- Credentials and network clients.
|
||||
- Persistence, idempotency, approval, and durable side effects.
|
||||
- Logging and redaction policy.
|
||||
|
||||
Non-goals: permission prompts and approval workflows, durable pause/resume or replay, exactly-once side effects,
|
||||
application authorization policy, sandboxing arbitrary JavaScript, and compatibility with the full language or npm
|
||||
ecosystem. Applications that need approval or durable consequences should model those above CodeMode and expose only
|
||||
the currently authorized tools.
|
||||
CodeMode owns:
|
||||
|
||||
- Parsing and interpreting the supported subset without `eval`.
|
||||
- Schema boundaries around tool calls.
|
||||
- Plain-data copying and blocked prototype members.
|
||||
- Resource limits, call accounting, and normalized diagnostics.
|
||||
- Model-facing tool discovery and instructions.
|
||||
|
||||
A program cannot gain authority through prose or generated code. It can only exercise authority already present in the supplied tools. Do not expose a broad tool and expect the prompt to restrict it.
|
||||
|
||||
## Laws
|
||||
|
||||
The public contract is guided by these equivalences:
|
||||
|
||||
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
|
||||
- A tool implementation is not invoked unless its input has decoded successfully.
|
||||
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
|
||||
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
|
||||
- Host interruption remains interruption rather than a `CodeMode.Failure`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Generic permission prompts or approval workflows.
|
||||
- Durable pause/resume, replay, or storage adapters.
|
||||
- Exactly-once external side effects.
|
||||
- Application authorization or product policy.
|
||||
- A filesystem or process sandbox for arbitrary JavaScript.
|
||||
- Compatibility with the full JavaScript language or npm ecosystem.
|
||||
|
||||
Applications that need approval or durable consequences should model those above CodeMode and expose only the currently authorized tools.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -200,3 +367,5 @@ From the package directory:
|
||||
bun test
|
||||
bun run typecheck
|
||||
```
|
||||
|
||||
The direct suite covers public projections, discovery, schema boundaries, diagnostic sanitization, resource limits, tool-call observation, and interruption.
|
||||
|
||||
@@ -32,7 +32,7 @@ CodeMode is an orchestration language, not a general JavaScript runtime or an ap
|
||||
The generic runtime lives in `packages/codemode` and is host-neutral:
|
||||
|
||||
1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
|
||||
2. CodeMode generates model instructions, a budgeted inline catalog, and the global `search(...)` built-in.
|
||||
2. CodeMode generates model instructions, a budgeted inline catalog, and the internal `$codemode.search` tool.
|
||||
3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
|
||||
executes it without `eval`.
|
||||
4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
|
||||
@@ -46,14 +46,13 @@ advertised as `Promise<unknown>`.
|
||||
### Discovery and model workflow
|
||||
|
||||
The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
|
||||
round-robin across namespaces so one large namespace cannot starve the others. The global `search(...)` built-in is
|
||||
always callable - synchronously, counted as an admitted tool call - and is advertised when the inline catalog is
|
||||
partial.
|
||||
round-robin across namespaces so one large namespace cannot starve the others. `$codemode.search` is always callable
|
||||
and is advertised when the inline catalog is partial.
|
||||
|
||||
The intended workflow is:
|
||||
|
||||
1. Pick an exact signature from the inline catalog, or return `search(...)` results and use a selected path in the
|
||||
next execution.
|
||||
1. Pick an exact signature from the inline catalog, or return `$codemode.search(...)` results and use a selected path
|
||||
in the next execution.
|
||||
2. Call the exact returned path without guessing or normalizing segments.
|
||||
3. Narrow `Promise<unknown>` results before reading fields.
|
||||
4. Start independent calls together and await them with `Promise.all`.
|
||||
|
||||
@@ -22,8 +22,6 @@ ultimate source of truth.
|
||||
- [x] JSON-like host boundaries with `undefined` and non-finite numbers normalized to `null`.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside the sandbox.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
shadowable by program declarations like other globals.
|
||||
- [x] Cooperative timeout, tool-call accounting, output bounding, and a maximum of eight concurrent tool calls.
|
||||
- [ ] Full JavaScript or TypeScript compatibility. CodeMode is a bounded orchestration language.
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ export const execute = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
ToolRuntime.assertValidTools(tools)
|
||||
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
|
||||
}
|
||||
|
||||
@@ -151,6 +152,7 @@ export const make = <const Tools extends Record<string, unknown> = {}>(
|
||||
options: Options<Tools> = {} as Options<Tools>,
|
||||
): Runtime<Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
ToolRuntime.assertValidTools(tools)
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
|
||||
|
||||
|
||||
@@ -114,10 +114,6 @@ export class UriFunction {
|
||||
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
|
||||
}
|
||||
|
||||
// The global `search` built-in: synchronous tool discovery that shares the tool admission
|
||||
// pipeline (budget, audit, hooks) without living in the `tools` tree.
|
||||
export class SearchFunction {}
|
||||
|
||||
export class ProgramThrow {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
PromiseNamespace,
|
||||
ProgramThrow,
|
||||
type ProgramNode,
|
||||
SearchFunction,
|
||||
type StatementResult,
|
||||
sourceLocation,
|
||||
supportedSyntaxMessage,
|
||||
@@ -291,7 +290,6 @@ const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof SandboxPromise ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof SearchFunction ||
|
||||
value instanceof PromiseCapabilityFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
isSandboxValue(value)
|
||||
@@ -340,7 +338,7 @@ const typeofValue = (value: unknown): string => {
|
||||
value instanceof ErrorConstructorReference
|
||||
)
|
||||
return "function"
|
||||
if (value instanceof UriFunction || value instanceof SearchFunction) return "function"
|
||||
if (value instanceof UriFunction) return "function"
|
||||
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
|
||||
if (value instanceof GlobalNamespace) {
|
||||
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
|
||||
@@ -740,8 +738,6 @@ class PromiseRuntime<R> {
|
||||
class Interpreter<R> {
|
||||
private scopes: Array<Map<string, Binding>>
|
||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
// The built-in `search` global, threaded from ToolRuntime.make like invokeTool.
|
||||
private readonly invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
// Enumerable namespace/tool names at a node of the host tool tree, threaded from
|
||||
// ToolRuntime.make like invokeTool: the interpreter never holds the tree itself.
|
||||
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
@@ -752,7 +748,6 @@ class Interpreter<R> {
|
||||
|
||||
constructor(
|
||||
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
invokeSearch: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||
promises: PromiseRuntime<R>,
|
||||
logs: Array<string> = [],
|
||||
@@ -761,13 +756,11 @@ class Interpreter<R> {
|
||||
const globalScope = new Map<string, Binding>()
|
||||
this.scopes = [globalScope]
|
||||
this.invokeTool = invokeTool
|
||||
this.invokeSearch = invokeSearch
|
||||
this.toolKeys = toolKeys
|
||||
this.logs = logs
|
||||
this.callPermits = callPermits
|
||||
this.promises = promises
|
||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
||||
globalScope.set("search", { mutable: false, value: new SearchFunction() })
|
||||
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
||||
globalScope.set("undefined", { mutable: false, value: undefined })
|
||||
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
|
||||
@@ -2092,11 +2085,6 @@ class Interpreter<R> {
|
||||
if (callable instanceof UriFunction) {
|
||||
return invokeUriFunction(callable, args, node)
|
||||
}
|
||||
if (callable instanceof SearchFunction) {
|
||||
// The built-in search is synchronous in-memory matching: the call returns its
|
||||
// result directly (await still works, as with any plain value).
|
||||
return yield* self.invokeSearch(args)
|
||||
}
|
||||
// `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS.
|
||||
if (callable instanceof ErrorConstructorReference) {
|
||||
return constructErrorValue(callable.name, args, node)
|
||||
@@ -2118,7 +2106,7 @@ class Interpreter<R> {
|
||||
return boundedData(this.enumerableKeys(ref)!, "Object.keys result")
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
|
||||
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
@@ -2505,14 +2493,7 @@ class Interpreter<R> {
|
||||
}
|
||||
|
||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||
const invocation = new Interpreter(
|
||||
this.invokeTool,
|
||||
this.invokeSearch,
|
||||
this.toolKeys,
|
||||
this.promises,
|
||||
this.logs,
|
||||
this.callPermits,
|
||||
)
|
||||
const invocation = new Interpreter(this.invokeTool, this.toolKeys, this.promises, this.logs, this.callPermits)
|
||||
invocation.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
||||
const run = Effect.gen(function* () {
|
||||
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
||||
@@ -3685,7 +3666,7 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
Effect.gen(function* () {
|
||||
const program = parseProgram(options.code)
|
||||
const promises = new PromiseRuntime<Services<Tools>>(scope)
|
||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.search, tools.keys, promises, logs)
|
||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, promises, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
// Validate the result first so an invalid value is a fatal completion that closes
|
||||
// the promise scope directly instead of taking the normal-completion path.
|
||||
|
||||
@@ -82,6 +82,7 @@ export type ToolDescription = {
|
||||
|
||||
export type SafeObject = Record<string, unknown>
|
||||
|
||||
const reservedNamespace = "$codemode"
|
||||
const defaultCatalogBudget = 2_000
|
||||
const defaultSearchLimit = 10
|
||||
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0))
|
||||
@@ -451,12 +452,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition =>
|
||||
}),
|
||||
})
|
||||
|
||||
// The built-in `search` is a synchronous global function, not a tool-tree entry, so its
|
||||
// advertised signature is rendered by hand instead of through `describeDefinition`.
|
||||
const searchSignature = (() => {
|
||||
const definition = makeSearchTool([])
|
||||
return `search(input: ${inputTypeScript(definition, true)}): ${outputTypeScript(definition, true)}`
|
||||
})()
|
||||
const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([]))
|
||||
|
||||
const catalogLine = (tool: ToolDescription) => {
|
||||
// Keep the tool description concise; the full schema documentation remains in the signature.
|
||||
@@ -483,6 +479,12 @@ const toSearchEntry = <R>(path: string, definition: Definition<R>, description:
|
||||
export const searchIndex = <R>(tools: HostTools<R>): ReadonlyArray<SearchEntry> =>
|
||||
visibleDefinitions(tools).map(({ path, definition, description }) => toSearchEntry(path, definition, description))
|
||||
|
||||
export const assertValidTools = <R>(tools: HostTools<R>): void => {
|
||||
if (Object.hasOwn(tools, reservedNamespace)) {
|
||||
throw new Error(`Tool namespace '${reservedNamespace}' is reserved for CodeMode discovery tools.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Budgeted catalog: every namespace is always listed with its tool count; full call
|
||||
* signatures are inlined against the `catalogBudget` (estimated tokens,
|
||||
@@ -555,8 +557,8 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
empty
|
||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime."
|
||||
: complete
|
||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below; surrounding agent tools are not available."
|
||||
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below; surrounding agent tools are not available.",
|
||||
? "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed below and internal runtime tools; surrounding agent tools are not available."
|
||||
: "This is a restricted JavaScript language for calling tools, not a general-purpose runtime. Inside the confined interpreter, `tools` contains the Code Mode tools listed or searchable below and internal runtime tools; surrounding agent tools are not available.",
|
||||
...(empty
|
||||
? []
|
||||
: ["Do not infer or normalize tool names; use only exact signatures shown below or returned by search."]),
|
||||
@@ -577,7 +579,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
"3. Return only the fields you need from structured results; narrow unknown results before reading fields, and avoid returning large raw payloads.",
|
||||
]
|
||||
: [
|
||||
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
|
||||
'1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
|
||||
"2. In the next execution, copy a returned path exactly, call it, and return only the needed fields.",
|
||||
]),
|
||||
]
|
||||
@@ -589,8 +591,8 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
"## Rules",
|
||||
"",
|
||||
complete
|
||||
? "- Only Code Mode tools listed here are available; surrounding agent tools are not implicitly exposed."
|
||||
: "- Only Code Mode tools listed here or returned by the built-in `search` function are available; surrounding agent tools are not implicitly exposed.",
|
||||
? "- Only Code Mode tools listed here and internal runtime tools are available; surrounding agent tools are not implicitly exposed."
|
||||
: "- Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools are available; surrounding agent tools are not implicitly exposed.",
|
||||
"- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.",
|
||||
"- A result typed `Promise<unknown>` may be structured data or text. Before reading fields, check that it is a non-null object and not an array; otherwise handle the returned text or primitive directly.",
|
||||
'- Run independent calls in parallel: `await Promise.all(items.map((item) => tools.<namespace>.<tool>(item)))`, or use `tools.<namespace>["tool-name"](item)` when the listed signature uses bracket notation.',
|
||||
@@ -599,7 +601,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
...(complete
|
||||
? []
|
||||
: [
|
||||
'- Browse one namespace: `search({ query: "", namespace: "<name>" })`.',
|
||||
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
|
||||
"- If search returns `next`, repeat the same search with `offset: next.offset`.",
|
||||
]),
|
||||
]
|
||||
@@ -621,7 +623,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
toolSection.push(
|
||||
complete
|
||||
? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)"
|
||||
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with search(...))`,
|
||||
: `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`,
|
||||
"",
|
||||
)
|
||||
for (const [namespace, group] of ordered) {
|
||||
@@ -639,7 +641,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
|
||||
for (const tool of group) if (picked.has(tool)) toolSection.push(catalogLine(tool))
|
||||
}
|
||||
if (!complete) {
|
||||
toolSection.push("", "Search returns complete callable signatures:", `- ${searchSignature}`)
|
||||
toolSection.push("", "Search returns complete callable signatures:", `- ${searchDescription.signature}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,7 +670,7 @@ const namespaceKeys = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): Rea
|
||||
!Object.hasOwn(value, segment)
|
||||
) {
|
||||
throw new ToolRuntimeError("UnknownTool", `Unknown tool namespace '${path.join(".")}'.`, [
|
||||
"Object.keys(tools) lists the available namespaces; search({ query }) finds described tools.",
|
||||
"Object.keys(tools) lists the available namespaces; tools.$codemode.search({ query }) finds described tools.",
|
||||
])
|
||||
}
|
||||
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
|
||||
@@ -688,7 +690,7 @@ const resolve = <R>(tools: HostTools<R>, path: ReadonlyArray<string>): HostTool<
|
||||
!Object.hasOwn(value, segment)
|
||||
) {
|
||||
throw new ToolRuntimeError("UnknownTool", `Unknown tool '${path.join(".")}'.`, [
|
||||
"Use search({ query }) to find available described tools.",
|
||||
"Use tools.$codemode.search({ query }) to find available described tools.",
|
||||
])
|
||||
}
|
||||
value = value[segment] as HostTool<R> | Definition<R> | HostTools<R>
|
||||
@@ -705,11 +707,6 @@ export type ToolRuntime<R = never> = {
|
||||
readonly root: ToolReference
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly invoke: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
/**
|
||||
* The built-in `search` global: a synchronous discovery call that shares the tool
|
||||
* admission pipeline (budget, audit, hooks) without living in the `tools` tree.
|
||||
*/
|
||||
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
/** Enumerable namespace/tool names at one node of the callable tool tree; see `namespaceKeys`. */
|
||||
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
}
|
||||
@@ -722,7 +719,10 @@ export const make = <R>(
|
||||
hooks?: ToolCallHooks<R>,
|
||||
): ToolRuntime<R> => {
|
||||
const calls: Array<ToolCall> = []
|
||||
const searchTool = makeSearchTool(searchIndex)
|
||||
const callableTools = {
|
||||
...tools,
|
||||
[reservedNamespace]: { search: makeSearchTool(searchIndex) },
|
||||
}
|
||||
|
||||
// Wraps the settling portion of a tool call so onToolCallEnd observes success and failure
|
||||
// symmetrically. Interruption (e.g. the execution timeout) fires neither outcome.
|
||||
@@ -758,59 +758,52 @@ export const make = <R>(
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
const recordAndObserve = (name: string, input: unknown) =>
|
||||
Effect.sync(() => {
|
||||
recordCall({ name })
|
||||
return calls.length - 1
|
||||
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
|
||||
|
||||
const invokeDefinition = (name: string, tool: Definition<R>, externalArgs: Array<unknown>) =>
|
||||
Effect.gen(function* () {
|
||||
if (externalArgs.length !== 1)
|
||||
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
const input = yield* Effect.try({
|
||||
try: () => decodeToolInput(tool, externalArgs[0]),
|
||||
catch: (cause) =>
|
||||
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||
})
|
||||
const index = yield* recordAndObserve(name, input)
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function* () {
|
||||
const raw = yield* runHost(Effect.suspend(() => tool.run(input)))
|
||||
const result = yield* Effect.try({
|
||||
try: () => decodeToolOutput(tool, raw),
|
||||
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
|
||||
})
|
||||
return yield* decodeOutput(result, name)
|
||||
}),
|
||||
{ index, name, input },
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
root: new ToolReference([]),
|
||||
calls,
|
||||
keys: (path) => namespaceKeys(tools, path),
|
||||
search: (args) =>
|
||||
Effect.suspend(() =>
|
||||
invokeDefinition(
|
||||
"search",
|
||||
searchTool,
|
||||
args.map((arg) => copyOut(copyIn(arg, "Arguments for tool 'search'"))),
|
||||
),
|
||||
),
|
||||
keys: (path) => namespaceKeys(callableTools, path),
|
||||
invoke: (path, args) =>
|
||||
Effect.gen(function* () {
|
||||
const name = path.join(".")
|
||||
const externalArgs = args.map((arg) => copyOut(copyIn(arg, `Arguments for tool '${name}'`)))
|
||||
const tool = resolve(tools, path)
|
||||
if (isDefinition(tool)) return yield* invokeDefinition(name, tool, externalArgs)
|
||||
const index = yield* recordAndObserve(name, externalArgs)
|
||||
const call = { name }
|
||||
const recordAndObserve = (input: unknown) =>
|
||||
Effect.sync(() => {
|
||||
recordCall(call)
|
||||
return calls.length - 1
|
||||
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
|
||||
const tool = resolve(callableTools, path)
|
||||
let describedInput: unknown
|
||||
if (isDefinition(tool)) {
|
||||
if (externalArgs.length !== 1)
|
||||
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
describedInput = yield* Effect.try({
|
||||
try: () => decodeToolInput(tool, externalArgs[0]),
|
||||
catch: (cause) =>
|
||||
new ToolRuntimeError("InvalidToolInput", `Invalid input for tool '${name}': ${String(cause)}`),
|
||||
})
|
||||
}
|
||||
const input = isDefinition(tool) ? describedInput : externalArgs
|
||||
const index = yield* recordAndObserve(input)
|
||||
const currentCall = { index, name, input }
|
||||
if (isDefinition(tool)) {
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function* () {
|
||||
const raw = yield* runHost(Effect.suspend(() => tool.run(describedInput)))
|
||||
const result = yield* Effect.try({
|
||||
try: () => decodeToolOutput(tool, raw),
|
||||
catch: () => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}'.`),
|
||||
})
|
||||
return yield* decodeOutput(result, name)
|
||||
}),
|
||||
currentCall,
|
||||
)
|
||||
}
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function* () {
|
||||
return yield* decodeOutput(yield* runHost(Effect.suspend(() => tool(...externalArgs))), name)
|
||||
}),
|
||||
{ index, name, input: externalArgs },
|
||||
currentCall,
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -541,11 +541,11 @@ describe("CodeMode public contract", () => {
|
||||
" - tools.orders.lookup(input: {\n id: string,\n}): Promise<{\n id: string,\n status: string,\n}> // Look up an order by ID",
|
||||
)
|
||||
// A fully inlined catalog does not advertise search in the instructions...
|
||||
expect(runtime.instructions()).not.toContain("search(")
|
||||
expect(runtime.instructions()).not.toMatch(/\$codemode/)
|
||||
|
||||
// ...but the search built-in stays available, so a speculative call still works with the
|
||||
// ...but the search tool stays registered, so a speculative call still works with the
|
||||
// same signature as the inline catalog.
|
||||
const result = await Effect.runPromise(runtime.execute(`return search({ query: "order" })`))
|
||||
const result = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "order" })`))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value).toStrictEqual({
|
||||
@@ -583,7 +583,9 @@ describe("CodeMode public contract", () => {
|
||||
'tools.context7["resolve-library-id"](input: {\n libraryName: string,\n}): Promise<string>',
|
||||
)
|
||||
|
||||
const search = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library id" })`))
|
||||
const search = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "resolve library id" })`),
|
||||
)
|
||||
expect(search.ok).toBe(true)
|
||||
if (search.ok) {
|
||||
expect(search.value).toStrictEqual({
|
||||
@@ -606,7 +608,7 @@ describe("CodeMode public contract", () => {
|
||||
if (call.ok) expect(call.value).toBe("/resolved/TypeScript")
|
||||
|
||||
const exact = await Effect.runPromise(
|
||||
runtime.execute(`return search({ query: 'tools.context7["resolve-library-id"]' })`),
|
||||
runtime.execute(`return await tools.$codemode.search({ query: 'tools.context7["resolve-library-id"]' })`),
|
||||
)
|
||||
expect(exact.ok).toBe(true)
|
||||
if (exact.ok) expect(exact.value).toMatchObject({ remaining: 0, next: null })
|
||||
@@ -630,7 +632,7 @@ describe("CodeMode public contract", () => {
|
||||
expect(instructions).toContain("Do not infer or normalize tool names")
|
||||
expect(instructions).toContain("bracket notation and quotes are part of the path")
|
||||
expect(instructions).toContain("surrounding agent tools are not available")
|
||||
expect(instructions).toContain("Only Code Mode tools listed here are available")
|
||||
expect(instructions).toContain("Only Code Mode tools listed here and internal runtime tools")
|
||||
// Placeholders use generic namespace/tool/field names only - no fabricated real tools
|
||||
// and no real catalog tools cherry-picked into example lines.
|
||||
expect(instructions).toContain("`const result = await tools.<namespace>.<tool>(input)`")
|
||||
@@ -649,11 +651,15 @@ describe("CodeMode public contract", () => {
|
||||
// PARTIAL: the workflow starts with search (with query-style guidance that is clearly
|
||||
// a query string, never a tool name) and the browse-namespace rule appears.
|
||||
expect(partial).toContain(
|
||||
'1. If needed, discover tools with the built-in search function: `return search({ query: "<intent + key nouns>" })`.',
|
||||
'1. If needed, discover tools: `return await tools.$codemode.search({ query: "<intent + key nouns>" })`.',
|
||||
)
|
||||
expect(partial).toContain("In the next execution, copy a returned path exactly")
|
||||
expect(partial).toContain("Only Code Mode tools listed here or returned by the built-in `search` function")
|
||||
expect(partial).toContain('- Browse one namespace: `search({ query: "", namespace: "<name>" })`.')
|
||||
expect(partial).toContain(
|
||||
"Only Code Mode tools listed here or returned by `tools.$codemode.search` and internal runtime tools",
|
||||
)
|
||||
expect(partial).toContain(
|
||||
'- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "<name>" })`.',
|
||||
)
|
||||
expect(partial).toContain("repeat the same search with `offset: next.offset`")
|
||||
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
||||
expect(partial).not.toContain("total_count")
|
||||
@@ -690,7 +696,7 @@ describe("CodeMode public contract", () => {
|
||||
expect(instructions).toContain("## Available tools")
|
||||
expect(instructions).not.toContain("## Workflow")
|
||||
expect(instructions).not.toContain("## Rules")
|
||||
expect(instructions).not.toContain("search(")
|
||||
expect(instructions).not.toMatch(/\$codemode/)
|
||||
})
|
||||
|
||||
test("uses one ranked search returning complete definitions for large catalogs", async () => {
|
||||
@@ -710,15 +716,17 @@ describe("CodeMode public contract", () => {
|
||||
tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } },
|
||||
discovery: { catalogBudget: 0 },
|
||||
})
|
||||
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with search(...))")
|
||||
expect(runtime.instructions()).toContain(
|
||||
"Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)",
|
||||
)
|
||||
expect(runtime.instructions()).toContain("- thread (2 tools, none shown)")
|
||||
expect(runtime.instructions()).toContain("- orders (1 tool, none shown)")
|
||||
expect(runtime.instructions()).toContain("Search returns complete callable signatures:\n- search(input: {")
|
||||
expect(runtime.instructions()).toMatch(/\$codemode\.search/)
|
||||
expect(runtime.instructions()).not.toMatch(/tools\.thread\.uploadFile\(input/)
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
return search({
|
||||
return await tools.$codemode.search({
|
||||
query: "send message attachment upload file to current Discord thread",
|
||||
limit: 2
|
||||
})
|
||||
@@ -742,14 +750,14 @@ describe("CodeMode public contract", () => {
|
||||
remaining: 0,
|
||||
next: null,
|
||||
})
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "search" }])
|
||||
expect(result.toolCalls).toStrictEqual([{ name: "$codemode.search" }])
|
||||
|
||||
const variants = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
return [
|
||||
search({ query: "file" }),
|
||||
search({ query: "image" })
|
||||
]
|
||||
return await Promise.all([
|
||||
tools.$codemode.search({ query: "file" }),
|
||||
tools.$codemode.search({ query: "image" })
|
||||
])
|
||||
`),
|
||||
)
|
||||
expect(variants.ok).toBe(true)
|
||||
@@ -761,35 +769,12 @@ describe("CodeMode public contract", () => {
|
||||
"tools.thread.generateImage",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
|
||||
const started: Array<string> = []
|
||||
const ended: Array<string> = []
|
||||
const limited = CodeMode.make({
|
||||
tools,
|
||||
limits: { maxToolCalls: 1 },
|
||||
onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
|
||||
onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
|
||||
})
|
||||
const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
|
||||
expect(started).toEqual(["search"])
|
||||
expect(ended).toEqual(["search:success"])
|
||||
})
|
||||
|
||||
test("search is an opaque, shadowable global like other built-ins", async () => {
|
||||
const runtime = CodeMode.make({ tools })
|
||||
expect(await Effect.runPromise(runtime.execute(`return typeof search`))).toMatchObject({ value: "function" })
|
||||
// A program-level declaration shadows the global, as JS module scope does.
|
||||
const shadowed = await Effect.runPromise(runtime.execute(`const search = () => "local"; return search()`))
|
||||
expect(shadowed.ok).toBe(true)
|
||||
if (shadowed.ok) expect(shadowed.value).toBe("local")
|
||||
// The reference itself cannot cross the data boundary.
|
||||
const escaped = await Effect.runPromise(runtime.execute(`return { search }`))
|
||||
expect(escaped.ok).toBe(false)
|
||||
if (!escaped.ok) expect(escaped.error.kind).toBe("InvalidDataValue")
|
||||
const removed = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.describe({ path: "thread.uploadFile" })`),
|
||||
)
|
||||
expect(removed.ok).toBe(false)
|
||||
if (!removed.ok) expect(removed.error.kind).toBe("UnknownTool")
|
||||
})
|
||||
|
||||
test("search defaults to 10 results and resolves exact tool paths", async () => {
|
||||
@@ -806,7 +791,7 @@ describe("CodeMode public contract", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const browse = await Effect.runPromise(runtime.execute(`return search({})`))
|
||||
const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`))
|
||||
expect(browse.ok).toBe(true)
|
||||
if (browse.ok) {
|
||||
const value = browse.value as {
|
||||
@@ -820,7 +805,9 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
for (const query of ["many.tool13", "tools.many.tool13"]) {
|
||||
const exact = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`))
|
||||
const exact = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
|
||||
)
|
||||
expect(exact.ok).toBe(true)
|
||||
if (exact.ok) {
|
||||
expect(exact.value).toStrictEqual({
|
||||
@@ -854,7 +841,9 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
// Empty query + namespace browses just that namespace, alphabetical by path.
|
||||
const browse = await Effect.runPromise(runtime.execute(`return search({ query: "", namespace: "github" })`))
|
||||
const browse = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "", namespace: "github" })`),
|
||||
)
|
||||
expect(browse.ok).toBe(true)
|
||||
if (browse.ok) {
|
||||
const value = browse.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -866,7 +855,9 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
// A query + namespace ranks within that namespace only.
|
||||
const scoped = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: "linear" })`))
|
||||
const scoped = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "linear" })`),
|
||||
)
|
||||
expect(scoped.ok).toBe(true)
|
||||
if (scoped.ok) {
|
||||
const value = scoped.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -874,7 +865,9 @@ describe("CodeMode public contract", () => {
|
||||
expect(value.items[0]?.path).toBe("tools.linear.list_issues")
|
||||
}
|
||||
|
||||
const invalid = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: 7 })`))
|
||||
const invalid = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: 7 })`),
|
||||
)
|
||||
expect(invalid.ok).toBe(false)
|
||||
if (!invalid.ok) expect(invalid.error.kind).toBe("InvalidToolInput")
|
||||
})
|
||||
@@ -899,7 +892,9 @@ describe("CodeMode public contract", () => {
|
||||
|
||||
// "attachment" appears in neither path nor description - only in the input schema's
|
||||
// property names, which the searchable text includes.
|
||||
const byParameter = await Effect.runPromise(runtime.execute(`return search({ query: "attachment" })`))
|
||||
const byParameter = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "attachment" })`),
|
||||
)
|
||||
expect(byParameter.ok).toBe(true)
|
||||
if (byParameter.ok) {
|
||||
const value = byParameter.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -908,7 +903,9 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
// Substring matching: a partial word ("docum") still hits the description.
|
||||
const bySubstring = await Effect.runPromise(runtime.execute(`return search({ query: "docum" })`))
|
||||
const bySubstring = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "docum" })`),
|
||||
)
|
||||
expect(bySubstring.ok).toBe(true)
|
||||
if (bySubstring.ok) {
|
||||
const value = bySubstring.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -935,7 +932,9 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
// "issues" still finds the singular-only tool (term OR singular(term) per field)...
|
||||
const plural = await Effect.runPromise(runtime.execute(`return search({ query: "issues", namespace: "tracker" })`))
|
||||
const plural = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "issues", namespace: "tracker" })`),
|
||||
)
|
||||
expect(plural.ok).toBe(true)
|
||||
if (plural.ok) {
|
||||
const value = plural.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -944,7 +943,7 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
|
||||
// ...while a true "issues" path match still outranks the singular-only description match.
|
||||
const ranked = await Effect.runPromise(runtime.execute(`return search({ query: "issues" })`))
|
||||
const ranked = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({ query: "issues" })`))
|
||||
expect(ranked.ok).toBe(true)
|
||||
if (ranked.ok) {
|
||||
const value = ranked.value as { items: Array<{ path: string }>; remaining: number }
|
||||
@@ -971,7 +970,7 @@ describe("CodeMode public contract", () => {
|
||||
alpha: { beta: simple("Middle"), aardvark: simple("First") },
|
||||
},
|
||||
})
|
||||
const browse = await Effect.runPromise(runtime.execute(`return search({})`))
|
||||
const browse = await Effect.runPromise(runtime.execute(`return await tools.$codemode.search({})`))
|
||||
expect(browse.ok).toBe(true)
|
||||
if (browse.ok) {
|
||||
const value = browse.value as { items: Array<{ path: string }>; remaining: number; next: unknown }
|
||||
@@ -984,7 +983,9 @@ describe("CodeMode public contract", () => {
|
||||
expect(value.next).toBeNull()
|
||||
}
|
||||
|
||||
const middle = await Effect.runPromise(runtime.execute(`return search({ limit: 1, offset: 1 })`))
|
||||
const middle = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 1 })`),
|
||||
)
|
||||
expect(middle.ok).toBe(true)
|
||||
if (middle.ok) {
|
||||
expect(middle.value).toMatchObject({
|
||||
@@ -994,7 +995,9 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
}
|
||||
|
||||
const exhausted = await Effect.runPromise(runtime.execute(`return search({ limit: 1, offset: 3 })`))
|
||||
const exhausted = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ limit: 1, offset: 3 })`),
|
||||
)
|
||||
expect(exhausted.ok).toBe(true)
|
||||
if (exhausted.ok) expect(exhausted.value).toStrictEqual({ items: [], remaining: 0, next: null })
|
||||
})
|
||||
@@ -1025,14 +1028,16 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
const instructions = runtime.instructions()
|
||||
expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with search(...))")
|
||||
expect(instructions).toContain(
|
||||
"Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)",
|
||||
)
|
||||
expect(instructions).toContain("- alpha (2 tools, 1 shown)")
|
||||
expect(instructions).toContain(" - tools.alpha.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
||||
expect(instructions).not.toContain("tools.alpha.expensive(")
|
||||
// Fully shown namespaces read cleanly (no "shown" annotation).
|
||||
expect(instructions).toContain("- beta (1 tool)")
|
||||
expect(instructions).toContain(" - tools.beta.cheap(input: {\n q: string,\n}): Promise<string> // Cheap")
|
||||
expect(instructions).toContain("Search returns complete callable signatures:\n- search(input: {")
|
||||
expect(instructions).toMatch(/\$codemode\.search/)
|
||||
})
|
||||
|
||||
test("charges inline JSDoc against the catalog token budget", () => {
|
||||
@@ -1053,7 +1058,9 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
|
||||
expect(runtime.catalog()[0]?.signature).toContain("/** A detailed identifier description.")
|
||||
expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 1 shown; find the rest with search(...))")
|
||||
expect(runtime.instructions()).toContain(
|
||||
"Available tools (PARTIAL - 0 of 1 shown; find the rest with tools.$codemode.search)",
|
||||
)
|
||||
expect(runtime.instructions()).not.toContain("tools.records.lookup(input:")
|
||||
})
|
||||
|
||||
@@ -1131,7 +1138,7 @@ describe("CodeMode public contract", () => {
|
||||
CodeMode.make({
|
||||
tools,
|
||||
discovery: { catalogBudget: 0 },
|
||||
}).execute(`return search({ query: "order", limit: 0.5 })`),
|
||||
}).execute(`return await tools.$codemode.search({ query: "order", limit: 0.5 })`),
|
||||
)
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
@@ -1139,7 +1146,9 @@ describe("CodeMode public contract", () => {
|
||||
|
||||
for (const offset of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1, "1"]) {
|
||||
const invalidOffset = await Effect.runPromise(
|
||||
CodeMode.make({ tools }).execute(`return search({ query: "order", offset: ${JSON.stringify(offset)} })`),
|
||||
CodeMode.make({ tools }).execute(
|
||||
`return await tools.$codemode.search({ query: "order", offset: ${JSON.stringify(offset)} })`,
|
||||
),
|
||||
)
|
||||
expect(invalidOffset.ok).toBe(false)
|
||||
if (!invalidOffset.ok) expect(invalidOffset.error.kind).toBe("InvalidToolInput")
|
||||
@@ -1190,4 +1199,8 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
expect(elapsedMs).toBeLessThan(3_000)
|
||||
})
|
||||
|
||||
test("reserves the discovery namespace", () => {
|
||||
expect(() => CodeMode.make({ tools: { $codemode: { lookup } } })).toThrow(/reserved for CodeMode discovery tools/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("Object.keys over tool references", () => {
|
||||
const namespaces = Object.keys(tools)
|
||||
return { namespaces, count: namespaces.length }
|
||||
`),
|
||||
).toEqual({ namespaces: ["github", "memory", "playwright"], count: 3 })
|
||||
).toEqual({ namespaces: ["github", "memory", "playwright", "$codemode"], count: 4 })
|
||||
})
|
||||
|
||||
test("enumerates tool names at a nested namespace", async () => {
|
||||
@@ -52,8 +52,8 @@ describe("Object.keys over tool references", () => {
|
||||
expect(await value(`return Object.keys(tools.github.list_issues)`)).toEqual([])
|
||||
})
|
||||
|
||||
test("search is a global built-in function", async () => {
|
||||
expect(await value(`return typeof search`)).toBe("function")
|
||||
test("the internal discovery namespace enumerates its callable surface", async () => {
|
||||
expect(await value(`return Object.keys(tools.$codemode)`)).toEqual(["search"])
|
||||
})
|
||||
|
||||
test("an unknown namespace is an UnknownTool error pointing at the discovery idioms", async () => {
|
||||
@@ -68,7 +68,7 @@ describe("Object.keys over tool references", () => {
|
||||
const failure = await error(`return Object.${method}(tools)`)
|
||||
expect(failure.kind).toBe("InvalidDataValue")
|
||||
expect(failure.message).toContain(
|
||||
`Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
|
||||
`Object.${method}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
|
||||
)
|
||||
}
|
||||
const nested = await error(`return Object.entries(tools.github)`)
|
||||
@@ -146,7 +146,7 @@ describe("for...in", () => {
|
||||
}
|
||||
return names
|
||||
`),
|
||||
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate"])
|
||||
).toEqual(["github.list_issues", "github.get_issue", "memory.search", "playwright.navigate", "$codemode.search"])
|
||||
})
|
||||
|
||||
test("unsupported values fail with a hint at for...of and Object.keys", async () => {
|
||||
|
||||
@@ -377,7 +377,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
runtime
|
||||
.execute(
|
||||
`
|
||||
return search({ query: "global health", namespace: "opencode", limit: 1 })
|
||||
return await tools.$codemode.search({ query: "global health", namespace: "opencode", limit: 1 })
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.provide(layer)),
|
||||
|
||||
@@ -342,7 +342,9 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
|
||||
|
||||
const search = async (query: string) => {
|
||||
const result = await Effect.runPromise(runtime.execute(`return search({ query: ${JSON.stringify(query)} })`))
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: ${JSON.stringify(query)} })`),
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
|
||||
@@ -434,7 +436,9 @@ describe("non-identifier tool paths", () => {
|
||||
})
|
||||
|
||||
test("search results return callable bracket-notation paths and signatures", async () => {
|
||||
const result = await Effect.runPromise(runtime.execute(`return search({ query: "resolve library" })`))
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`return await tools.$codemode.search({ query: "resolve library" })`),
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
|
||||
|
||||
@@ -128,8 +128,6 @@ export const fffLayer = Layer.effect(
|
||||
Fff.create({
|
||||
basePath: location.directory,
|
||||
aiMode: true,
|
||||
disableMmapCache: true,
|
||||
disableContentIndexing: true,
|
||||
}),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
@@ -232,13 +230,6 @@ export const fffLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const layer = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
if (Flag.OPENCODE_DISABLE_FFF || !Fff.available()) return ripgrepLayer
|
||||
const location = yield* Location.Service
|
||||
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
|
||||
return location.vcs ? fffLayer : ripgrepLayer
|
||||
}),
|
||||
)
|
||||
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
||||
|
||||
@@ -303,7 +303,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
|
||||
Info.make({
|
||||
new Info({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
methods: entry.methods,
|
||||
|
||||
@@ -64,7 +64,7 @@ const layer = Layer.effect(
|
||||
if (source.type === "local") {
|
||||
materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
new Info({
|
||||
name,
|
||||
path: source.path,
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
@@ -88,7 +88,7 @@ const layer = Layer.effect(
|
||||
seen.set(target, source.branch)
|
||||
materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
new Info({
|
||||
name,
|
||||
path: AbsolutePath.make(target),
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
|
||||
@@ -220,31 +220,8 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
yield* flushFragments()
|
||||
})
|
||||
|
||||
const failTools = Effect.fnUntraced(function* (error: SessionError.Error, mode: "all" | "hosted" | "uncalled") {
|
||||
let failed = false
|
||||
for (const [callID, tool] of tools) {
|
||||
if (
|
||||
tool.settled ||
|
||||
(mode === "hosted" && !tool.providerExecuted) ||
|
||||
(mode === "uncalled" && tool.called)
|
||||
)
|
||||
continue
|
||||
tool.settled = true
|
||||
failed = true
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
error,
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
}
|
||||
return failed
|
||||
})
|
||||
|
||||
const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
|
||||
yield* flush()
|
||||
yield* failTools(error, "uncalled")
|
||||
yield* startAssistant()
|
||||
if (replace || stepFailure === undefined) stepFailure = error
|
||||
})
|
||||
@@ -268,7 +245,20 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
|
||||
error: SessionError.Error,
|
||||
hostedOnly = false,
|
||||
) {
|
||||
return yield* failTools(error, hostedOnly ? "hosted" : "all")
|
||||
let failed = false
|
||||
for (const [callID, tool] of tools) {
|
||||
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
|
||||
tool.settled = true
|
||||
failed = true
|
||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: tool.assistantMessageID,
|
||||
callID,
|
||||
error,
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
}
|
||||
return failed
|
||||
})
|
||||
|
||||
const assistantMessageIDForTool = (callID: string) => {
|
||||
|
||||
@@ -12,8 +12,8 @@ import { Tool } from "./tool"
|
||||
export const name = "subagent"
|
||||
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const backgroundStarted = (sessionID: SessionSchema.ID) =>
|
||||
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.`
|
||||
const BACKGROUND_STARTED =
|
||||
"The subagent is working in the background. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
|
||||
@@ -168,11 +168,7 @@ export const Plugin = {
|
||||
if (background) {
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return {
|
||||
sessionID: child.id,
|
||||
status: "running" as const,
|
||||
output: backgroundStarted(child.id),
|
||||
}
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
}
|
||||
|
||||
const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe(
|
||||
@@ -184,11 +180,7 @@ export const Plugin = {
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(context.sessionID, child.id, input.description)
|
||||
return {
|
||||
sessionID: child.id,
|
||||
status: "running" as const,
|
||||
output: backgroundStarted(child.id),
|
||||
}
|
||||
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("Integration", () => {
|
||||
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
|
||||
.pipe(Scope.provide(scope))
|
||||
expect(yield* integrations.get(openai)).toEqual(
|
||||
Integration.Info.make({ id: openai, name: "OpenAI", methods: [], connections: [] }),
|
||||
new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
|
||||
)
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
expect(yield* integrations.list()).toEqual([
|
||||
Integration.Info.make({
|
||||
new Integration.Info({
|
||||
id: Integration.ID.make("acme"),
|
||||
name: "Acme",
|
||||
methods: [
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("ReferenceGuidance", () => {
|
||||
Layer.mock(Reference.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
Reference.Info.make({
|
||||
new Reference.Info({
|
||||
name: "docs",
|
||||
path: AbsolutePath.make("/docs"),
|
||||
description: "Use for product documentation",
|
||||
@@ -62,7 +62,7 @@ describe("ReferenceGuidance", () => {
|
||||
Layer.mock(Reference.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
Reference.Info.make({
|
||||
new Reference.Info({
|
||||
name: "docs",
|
||||
path: AbsolutePath.make("/docs"),
|
||||
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
|
||||
@@ -76,7 +76,7 @@ describe("ReferenceGuidance", () => {
|
||||
|
||||
it.effect("announces added and removed references as deltas", () => {
|
||||
const reference = (name: string, description: string) =>
|
||||
Reference.Info.make({
|
||||
new Reference.Info({
|
||||
name,
|
||||
path: AbsolutePath.make(`/${name}`),
|
||||
description,
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("Reference", () => {
|
||||
yield* references.transform((editor) => editor.add("docs", source)).pipe(Scope.provide(scope))
|
||||
|
||||
expect(yield* references.list()).toEqual([
|
||||
Reference.Info.make({ name: "docs", path, description: "Use for API documentation", hidden: true, source }),
|
||||
new Reference.Info({ name: "docs", path, description: "Use for API documentation", hidden: true, source }),
|
||||
])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
@@ -45,7 +45,7 @@ describe("Reference", () => {
|
||||
yield* references.transform((editor) => editor.add("sdk", source))
|
||||
|
||||
expect(yield* references.list()).toEqual([
|
||||
Reference.Info.make({
|
||||
new Reference.Info({
|
||||
name: "sdk",
|
||||
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
|
||||
source,
|
||||
@@ -66,7 +66,7 @@ describe("Reference", () => {
|
||||
yield* references.transform((editor) => editor.add("sdk", source))
|
||||
|
||||
expect(yield* references.list()).toEqual([
|
||||
Reference.Info.make({
|
||||
new Reference.Info({
|
||||
name: "sdk",
|
||||
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
|
||||
description: "Use for SDK implementation details",
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Model,
|
||||
ToolFailure,
|
||||
TransportReason,
|
||||
InvalidProviderOutputReason,
|
||||
InvalidRequestReason,
|
||||
RateLimitReason,
|
||||
type LLMClientShape,
|
||||
@@ -717,18 +716,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) =>
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
content: [
|
||||
kind === "tool input"
|
||||
? {
|
||||
type: "tool",
|
||||
id: fragmentID(kind, "partial"),
|
||||
state: {
|
||||
status: "error",
|
||||
error: { type: "provider.transport", message: "Provider unavailable" },
|
||||
},
|
||||
}
|
||||
: fixture.expectedContent,
|
||||
],
|
||||
content: [fixture.expectedContent],
|
||||
},
|
||||
])
|
||||
expect(requests).toHaveLength(1)
|
||||
@@ -3888,45 +3876,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles malformed streamed tool input before the provider failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Call a malformed tool")
|
||||
const failure = new LLMError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new InvalidProviderOutputReason({ message: "Invalid JSON input for tool call echo" }),
|
||||
})
|
||||
responseStream = Stream.fromIterable([
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolInputStart({ id: "call-malformed", name: "echo" }),
|
||||
LLMEvent.toolInputDelta({ id: "call-malformed", name: "echo", text: '{"text":"partial' }),
|
||||
]).pipe(Stream.concat(Stream.fail(failure)))
|
||||
|
||||
expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure)
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
|
||||
response = reply.stop()
|
||||
yield* admit(session, "Continue")
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
|
||||
{ type: "session.step.started.1" },
|
||||
{
|
||||
type: "session.tool.failed.1",
|
||||
data: {
|
||||
callID: "call-malformed",
|
||||
error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.step.failed.1",
|
||||
data: { error: { type: "provider.invalid-output", message: "Invalid JSON input for tool call echo" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue automatically after a provider error follows a local tool call", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -281,10 +281,7 @@ describe("SubagentTool", () => {
|
||||
},
|
||||
})
|
||||
const childID = outputSessionID(settled.output?.structured)
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
status: "running",
|
||||
output: expect.stringContaining(`id: ${childID}`),
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({ status: "running" })
|
||||
|
||||
const admission = Array.from(yield* Fiber.join(admitted))[0]
|
||||
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1097,27 +1097,6 @@ describe("HttpApiCodegen.generate", () => {
|
||||
expect(output.operations[0]?.success).toBe("stream")
|
||||
})
|
||||
|
||||
test("emits opaque Promise SSE fields as any", () => {
|
||||
const output = emitPromise(
|
||||
compileContract(
|
||||
api(
|
||||
HttpApiEndpoint.get("subscribe", "/event", {
|
||||
success: HttpApiSchema.StreamSse({
|
||||
data: Schema.Struct({
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||
label: Schema.Literal("unknown"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const types = output.files.find((file) => file.path === "types.ts")?.content
|
||||
|
||||
expect(types).toContain('readonly "metadata": { readonly [x: string]: any }')
|
||||
expect(types).toContain('readonly "label": "unknown"')
|
||||
})
|
||||
|
||||
test("preserves annotated stream response statuses", () => {
|
||||
const output = compile(
|
||||
api(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Schema } from "effect"
|
||||
|
||||
type JsonSchema = Record<string, unknown>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { isRecord } from "@opencode-ai/tui/util/record"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Schema } from "effect"
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path"
|
||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||
import { unique } from "remeda"
|
||||
import { Option, Schema } from "effect"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
@@ -15,14 +15,14 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { CurrentWorkingDirectory } from "./tui-cwd"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
|
||||
import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
|
||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
|
||||
export const Info = TuiConfig.Info
|
||||
export type Info = TuiConfig.Info
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
|
||||
import { createTuiAttention } from "@opencode-ai/tui/attention"
|
||||
import type { TuiConfig } from "@opencode-ai/tui/config/v1"
|
||||
import type { TuiConfig } from "@opencode-ai/tui/config"
|
||||
|
||||
type FocusEvent = "focus" | "blur"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import type { Resolved } from "@opencode-ai/tui/config/v1"
|
||||
import type { Resolved } from "@opencode-ai/tui/config"
|
||||
import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@opencode-ai/cli/mini/runtime.boot"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { spyOn } from "bun:test"
|
||||
import path from "path"
|
||||
import { resolve, type Info, type Resolved } from "@opencode-ai/tui/config/v1"
|
||||
import { resolve, type Info, type Resolved } from "@opencode-ai/tui/config"
|
||||
import { TuiConfig } from "../../src/config/tui"
|
||||
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
|
||||
import { TuiKeybind } from "@opencode-ai/tui/config/keybind"
|
||||
|
||||
type PluginSpec = string | [string, Record<string, unknown>]
|
||||
type PluginOrigin = {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"./tui": "./src/tui.ts",
|
||||
"./v2/effect": "./src/v2/effect/index.ts",
|
||||
"./v2/effect/*": "./src/v2/effect/*.ts",
|
||||
"./v2/tui/*": "./src/v2/tui/*.ts",
|
||||
"./v2": "./src/v2/promise/index.ts",
|
||||
"./v2/*": "./src/v2/promise/*.ts"
|
||||
},
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import type {
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
FormInfo,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
McpResource,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
OpenCodeClient,
|
||||
OpenCodeEvent,
|
||||
PermissionSavedInfo,
|
||||
PermissionV2Request,
|
||||
ProviderV2Info,
|
||||
ReferenceInfo,
|
||||
SessionInfo,
|
||||
SessionMessageInfo,
|
||||
SessionPendingInfo,
|
||||
ShellInfo,
|
||||
SkillInfo,
|
||||
} from "@opencode-ai/client"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
|
||||
interface LocationCollection<Value> {
|
||||
list(location?: LocationRef): Value[] | undefined
|
||||
refresh(location?: LocationRef): Promise<void>
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
readonly on: <Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
|
||||
) => () => void
|
||||
readonly listen: (handler: (event: { details: OpenCodeEvent }) => void) => () => void
|
||||
readonly session: {
|
||||
list(): SessionInfo[]
|
||||
get(sessionID: string): SessionInfo | undefined
|
||||
root(sessionID: string): string
|
||||
family(sessionID: string): string[]
|
||||
cost(sessionID: string): number
|
||||
status(sessionID: string): "idle" | "running"
|
||||
readonly pending: {
|
||||
list(sessionID: string): SessionPendingInfo[]
|
||||
refresh(sessionID: string): Promise<void>
|
||||
}
|
||||
refresh(sessionID: string): Promise<void>
|
||||
readonly message: {
|
||||
list(sessionID: string): SessionMessageInfo[]
|
||||
get(sessionID: string, messageID: string): SessionMessageInfo | undefined
|
||||
refresh(sessionID: string): Promise<void>
|
||||
}
|
||||
readonly permission: {
|
||||
list(sessionID: string): PermissionV2Request[] | undefined
|
||||
refresh(sessionID: string): Promise<void>
|
||||
}
|
||||
readonly form: {
|
||||
list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
|
||||
refresh(sessionID: string, location?: LocationRef): Promise<void>
|
||||
}
|
||||
}
|
||||
readonly project: {
|
||||
readonly permission: {
|
||||
list(projectID: string): PermissionSavedInfo[] | undefined
|
||||
refresh(projectID: string): Promise<void>
|
||||
}
|
||||
}
|
||||
readonly shell: {
|
||||
list(location?: LocationRef): ShellInfo[]
|
||||
get(id: string): ShellInfo | undefined
|
||||
refresh(location?: LocationRef): Promise<void>
|
||||
}
|
||||
readonly location: {
|
||||
default(): LocationRef
|
||||
refresh(location?: LocationRef): Promise<void>
|
||||
readonly agent: LocationCollection<AgentInfo>
|
||||
readonly command: LocationCollection<CommandInfo>
|
||||
readonly integration: LocationCollection<IntegrationInfo>
|
||||
readonly mcp: {
|
||||
readonly server: LocationCollection<McpServer>
|
||||
readonly resource: LocationCollection<McpResource>
|
||||
}
|
||||
readonly model: LocationCollection<ModelInfo>
|
||||
readonly provider: LocationCollection<ProviderV2Info>
|
||||
readonly reference: LocationCollection<ReferenceInfo>
|
||||
readonly skill: LocationCollection<SkillInfo>
|
||||
}
|
||||
}
|
||||
|
||||
export interface RouteDefinition {
|
||||
readonly name: string
|
||||
readonly render: (input: { readonly params: any }) => JSX.Element
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
register(definition: RouteDefinition): () => void
|
||||
navigate(input: { readonly name: string; readonly params?: any }): void
|
||||
current(): {
|
||||
readonly name: string
|
||||
readonly params: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface UI {
|
||||
readonly route: Route
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
readonly options: Record<string, any>
|
||||
readonly client: OpenCodeClient
|
||||
readonly data: Data
|
||||
readonly ui: UI
|
||||
}
|
||||
@@ -54,7 +54,6 @@ export const groupNames = {
|
||||
"server.event": "event",
|
||||
"server.pty": "pty",
|
||||
"server.shell": "shell",
|
||||
"server.mcp": "mcp",
|
||||
"server.question": "question",
|
||||
"server.reference": "reference",
|
||||
"server.project": "project",
|
||||
|
||||
@@ -92,13 +92,12 @@ export const Ref = Schema.Struct({
|
||||
name: Schema.String,
|
||||
}).annotate({ identifier: "Integration.Ref" })
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
export class Info extends Schema.Class<Info>("Integration.Info")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
methods: Schema.Array(Method),
|
||||
connections: Schema.Array(Connection.Info),
|
||||
}).annotate({ identifier: "Integration.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
}) {}
|
||||
|
||||
export const AttemptID = Schema.String.pipe(
|
||||
Schema.brand("Integration.AttemptID"),
|
||||
|
||||
@@ -30,11 +30,10 @@ export const Source = Schema.Union([LocalSource, GitSource])
|
||||
.annotate({ identifier: "Reference.Source" })
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
export class Info extends Schema.Class<Info>("Reference.Info")({
|
||||
name: Schema.String,
|
||||
path: AbsolutePath,
|
||||
description: Schema.String.pipe(optional),
|
||||
hidden: Schema.Boolean.pipe(optional),
|
||||
source: Source,
|
||||
}).annotate({ identifier: "Reference.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
}) {}
|
||||
|
||||
@@ -23,8 +23,8 @@ export const Status = Schema.Literals(["running", "exited", "timeout", "killed"]
|
||||
export type Status = typeof Status.Type
|
||||
|
||||
export const Time = Schema.Struct({
|
||||
started: Schema.Finite,
|
||||
completed: optional(Schema.Finite),
|
||||
started: Schema.Number,
|
||||
completed: optional(Schema.Number),
|
||||
})
|
||||
export interface Time extends Schema.Schema.Type<typeof Time> {}
|
||||
|
||||
@@ -42,15 +42,15 @@ export const Info = Schema.Struct({
|
||||
// Absolute path of the file capturing combined stdout/stderr. Page through it via `output`.
|
||||
file: Schema.String,
|
||||
pid: optional(NonNegativeInt),
|
||||
exit: optional(Schema.Finite),
|
||||
exit: optional(Schema.Number),
|
||||
// Always present; defaults to an empty object when the creator supplies no metadata.
|
||||
metadata: Metadata,
|
||||
time: Time,
|
||||
}).annotate({ identifier: "Shell.Info" })
|
||||
}).annotate({ identifier: "Shell" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
const Created = ephemeral({ type: "shell.created", schema: { info: Info } })
|
||||
const Exited = ephemeral({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Finite), status: Status } })
|
||||
const Exited = ephemeral({ type: "shell.exited", schema: { id: ID, exit: optional(Schema.Number), status: Status } })
|
||||
const Deleted = ephemeral({ type: "shell.deleted", schema: { id: ID } })
|
||||
export const Event = { Created, Exited, Deleted, Definitions: inventory(Created, Exited, Deleted) }
|
||||
|
||||
|
||||
@@ -12,10 +12,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.tsx",
|
||||
"./builtins": "./src/feature-plugins/builtins.ts",
|
||||
"./config/v1": "./src/config/v1/index.tsx",
|
||||
"./config/v1/keybind": "./src/config/v1/keybind.ts",
|
||||
"./config/v2": "./src/config/v2/index.ts",
|
||||
"./config/v2/keybind": "./src/config/v2/keybind.ts",
|
||||
"./config": "./src/config/index.tsx",
|
||||
"./context/args": "./src/context/args.tsx",
|
||||
"./context/epilogue": "./src/context/epilogue.tsx",
|
||||
"./context/exit": "./src/context/exit.tsx",
|
||||
@@ -33,6 +30,7 @@
|
||||
"./editor-zed": "./src/editor-zed.ts",
|
||||
"./runtime": "./src/runtime.tsx",
|
||||
"./terminal-win32": "./src/terminal-win32.ts",
|
||||
"./config/keybind": "./src/config/keybind.ts",
|
||||
"./keymap": "./src/keymap.tsx",
|
||||
"./prompt/content": "./src/prompt/content.ts",
|
||||
"./prompt/display": "./src/prompt/display.ts",
|
||||
@@ -57,6 +55,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { registerOpencodeSpinner } from "./component/register-spinner"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
@@ -53,6 +53,8 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogWorkspaceList } from "./component/dialog-workspace-list"
|
||||
import { DialogConsoleOrg } from "./component/dialog-console-org"
|
||||
import { ThemeProvider, useTheme } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
@@ -68,7 +70,7 @@ import * as Model from "./util/model"
|
||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config/v1"
|
||||
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config"
|
||||
import { createTuiApiAdapters } from "./plugin/adapters"
|
||||
import { createTuiApi } from "./plugin/api"
|
||||
import { createPluginRuntime, PluginRuntimeProvider, usePluginRuntime, type TuiPluginHost } from "./plugin/runtime"
|
||||
@@ -119,6 +121,7 @@ const appBindingCommands = [
|
||||
"variant.cycle",
|
||||
"variant.list",
|
||||
"provider.connect",
|
||||
"console.org.switch",
|
||||
"opencode.status",
|
||||
"server.pair",
|
||||
"opencode.debug",
|
||||
@@ -128,6 +131,7 @@ const appBindingCommands = [
|
||||
"help.show",
|
||||
"docs.open",
|
||||
"diff.open",
|
||||
"workspace.list",
|
||||
"app.debug",
|
||||
"app.console",
|
||||
"app.heap_snapshot",
|
||||
@@ -418,10 +422,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
const keymap = useOpencodeKeymap()
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const exit = useExit()
|
||||
@@ -435,7 +439,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
// the same problem on every refresh while still re-alerting if the state changes.
|
||||
const mcpAlerted: Record<string, string> = {}
|
||||
createEffect(() => {
|
||||
for (const server of data.location.mcp.server.list() ?? []) {
|
||||
for (const server of data.location.mcp.list() ?? []) {
|
||||
const status = server.status
|
||||
if (status.status !== "failed" && status.status !== "needs_auth") {
|
||||
delete mcpAlerted[server.name]
|
||||
@@ -520,7 +524,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
|
||||
kv.get("paste_summary_enabled", true),
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
|
||||
)
|
||||
|
||||
// Update terminal window title based on current route and session
|
||||
@@ -574,7 +578,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
|
||||
let continued = false
|
||||
createEffect(() => {
|
||||
if (continued || !args.continue) return
|
||||
if (continued || sync.status === "loading" || !args.continue) return
|
||||
continued = true
|
||||
const location = data.location.default()
|
||||
void sdk.api.session
|
||||
@@ -600,10 +604,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
.catch(toast.error)
|
||||
})
|
||||
|
||||
// Handle --session with --fork once.
|
||||
// Handle --session with --fork: wait for sync to be fully complete before forking
|
||||
// (session list loads in non-blocking phase for --session, so we must wait for "complete"
|
||||
// to avoid a race where reconcile overwrites the newly forked session)
|
||||
let forked = false
|
||||
createEffect(() => {
|
||||
if (forked || !args.sessionID || !args.fork) return
|
||||
if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
|
||||
forked = true
|
||||
void sdk.api.session
|
||||
.fork({ sessionID: args.sessionID })
|
||||
@@ -612,6 +618,13 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
})
|
||||
|
||||
const connected = useConnected()
|
||||
const currentWorktreeWorkspace = createMemo(() => {
|
||||
const workspaceID = project.workspace.current()
|
||||
if (!workspaceID) return
|
||||
const workspace = project.workspace.get(workspaceID)
|
||||
if (workspace?.type !== "worktree" || !workspace.directory) return
|
||||
return workspace
|
||||
})
|
||||
const appCommands = createMemo(() =>
|
||||
[
|
||||
{
|
||||
@@ -648,6 +661,31 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workspace.copy_path",
|
||||
title: "Copy worktree path",
|
||||
category: "Workspace",
|
||||
enabled: () => currentWorktreeWorkspace() !== undefined,
|
||||
run: async () => {
|
||||
const workspace = currentWorktreeWorkspace()
|
||||
if (!workspace?.directory) return
|
||||
await clipboard
|
||||
.write?.(workspace.directory)
|
||||
.then(() => toast.show({ message: "Copied worktree path", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "workspace.list",
|
||||
title: "Manage workspaces",
|
||||
category: "Workspace",
|
||||
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slashName: "workspaces",
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogWorkspaceList />)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
@@ -780,6 +818,21 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
},
|
||||
category: "Integration",
|
||||
},
|
||||
...(sync.data.console_state.switchableOrgCount > 1
|
||||
? [
|
||||
{
|
||||
name: "console.org.switch",
|
||||
title: "Switch org",
|
||||
suggested: Boolean(sync.data.console_state.activeOrgName),
|
||||
slashName: "org",
|
||||
slashAliases: ["orgs", "switch-org"],
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogConsoleOrg />)
|
||||
},
|
||||
category: "Provider",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "opencode.status",
|
||||
title: "View status",
|
||||
@@ -987,6 +1040,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
category: "System",
|
||||
run: async () => {
|
||||
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
|
||||
await sync.session.refresh()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
TuiAttentionSoundPack,
|
||||
TuiAttentionSoundPackInfo,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import { AttentionSoundName, type TuiConfig } from "./config/v1"
|
||||
import { AttentionSoundName, type TuiConfig } from "./config"
|
||||
import { Schema } from "effect"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import * as TuiAudio from "./audio"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useKeymapSelector,
|
||||
useOpencodeKeymap,
|
||||
} from "../keymap"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useTuiConfig } from "../config"
|
||||
|
||||
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createResource, createMemo, createSignal } from "solid-js"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { errorMessage } from "../util/error"
|
||||
import type { ExperimentalConsoleListOrgsResponse } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number]
|
||||
|
||||
const accountHost = (url: string) => {
|
||||
try {
|
||||
return new URL(url).host
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
const accountLabel = (item: Pick<OrgOption, "accountEmail" | "accountUrl">) =>
|
||||
`${item.accountEmail} ${accountHost(item.accountUrl)}`
|
||||
|
||||
export function DialogConsoleOrg() {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
|
||||
const [orgs] = createResource(() =>
|
||||
sdk.client.experimental.console
|
||||
.listOrgs({}, { throwOnError: true })
|
||||
.then((result) => result.data?.orgs ?? [])
|
||||
// Catch so the rejected resource never reaches the memos below: reading
|
||||
// orgs() in an errored state re-throws and tears down the dialog.
|
||||
.catch((error) => {
|
||||
setLoadError(error)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
|
||||
const showError = createMemo(() => Boolean(loadError()))
|
||||
|
||||
const current = createMemo(() => orgs()?.find((item) => item.active))
|
||||
|
||||
const options = createMemo(() => {
|
||||
if (showError()) return []
|
||||
const listed = orgs()
|
||||
if (listed === undefined) {
|
||||
return [
|
||||
{
|
||||
title: "Loading orgs...",
|
||||
value: "loading",
|
||||
onSelect: () => {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (listed.length === 0) {
|
||||
return [
|
||||
{
|
||||
title: "No orgs found",
|
||||
value: "empty",
|
||||
onSelect: () => {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return listed
|
||||
.toSorted((a, b) => {
|
||||
const activeAccountA = a.active ? 0 : 1
|
||||
const activeAccountB = b.active ? 0 : 1
|
||||
if (activeAccountA !== activeAccountB) return activeAccountA - activeAccountB
|
||||
|
||||
const accountCompare = accountLabel(a).localeCompare(accountLabel(b))
|
||||
if (accountCompare !== 0) return accountCompare
|
||||
|
||||
return a.orgName.localeCompare(b.orgName)
|
||||
})
|
||||
.map((item) => ({
|
||||
title: item.orgName,
|
||||
value: item,
|
||||
category: accountLabel(item),
|
||||
categoryView: (
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.accent}>{item.accountEmail}</text>
|
||||
<text fg={theme.textMuted}>{accountHost(item.accountUrl)}</text>
|
||||
</box>
|
||||
),
|
||||
onSelect: async () => {
|
||||
if (item.active) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
|
||||
await sdk.client.experimental.console.switchOrg(
|
||||
{
|
||||
accountID: item.accountID,
|
||||
orgID: item.orgID,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
|
||||
await sdk.client.instance.dispose()
|
||||
toast.show({
|
||||
message: `Switched to ${item.orgName}`,
|
||||
variant: "info",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect<string | OrgOption>
|
||||
title="Switch org"
|
||||
options={options()}
|
||||
current={current()}
|
||||
renderFilter={!showError()}
|
||||
locked={showError()}
|
||||
emptyView={
|
||||
showError() ? (
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.error} attributes={TextAttributes.BOLD}>
|
||||
Could not load orgs
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
|
||||
</box>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type {
|
||||
ConnectionInfo,
|
||||
IntegrationConnectOauthOutput,
|
||||
IntegrationInfo,
|
||||
IntegrationOAuthMethod,
|
||||
} from "@opencode-ai/client"
|
||||
import type { IntegrationConnectOauthOutput } from "@opencode-ai/client/promise"
|
||||
import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useData } from "../context/data"
|
||||
|
||||
@@ -5,11 +5,11 @@ import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useTheme, type Theme } from "../context/theme"
|
||||
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { McpServer } from "@opencode-ai/client"
|
||||
import type { McpServer } from "@opencode-ai/sdk/v2"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useTuiConfig } from "../config"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
@@ -45,7 +45,7 @@ export function DialogMcp() {
|
||||
|
||||
const servers = createMemo(() =>
|
||||
pipe(
|
||||
data.location.mcp.server.list() ?? [],
|
||||
data.location.mcp.list() ?? [],
|
||||
sortBy(
|
||||
(server) => statusMeta(server.status, theme).rank,
|
||||
(server) => server.name,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useData } from "../context/data"
|
||||
import { useSync } from "../context/sync"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { Locale } from "../util/locale"
|
||||
@@ -17,7 +17,7 @@ import { useCommandShortcut } from "../keymap"
|
||||
import { useProject } from "../context/project"
|
||||
import { Spinner } from "./spinner"
|
||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
|
||||
import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise"
|
||||
import { useRoute } from "../context/route"
|
||||
import { DialogProjectCopyName } from "./dialog-project-copy-name"
|
||||
|
||||
@@ -38,7 +38,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const sdk = useSDK()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
const sessionData = useData()
|
||||
const sync = useSync()
|
||||
const projectContext = useProject()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
@@ -132,13 +132,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
})
|
||||
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
|
||||
|
||||
const subdirectories = sessionData.session
|
||||
.list()
|
||||
.filter(
|
||||
(session) =>
|
||||
session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
|
||||
)
|
||||
.map((session) => session.location.directory)
|
||||
const subdirectories = sync.data.session
|
||||
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
|
||||
.map((session) => session.directory)
|
||||
.filter((directory) => !roots.some((root) => root.directory === directory))
|
||||
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
||||
.map((location) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InputRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useTuiConfig } from "../config"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useBindings, useCommandShortcut } from "../keymap"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import { createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { useSync } from "../context/sync"
|
||||
import { map, pipe, sortBy } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { Link } from "../ui/link"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
|
||||
import { DialogModel } from "./dialog-model"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { isConsoleManagedProvider } from "../util/provider-origin"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useBindings } from "../keymap"
|
||||
import { useClipboard } from "../context/clipboard"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
"opencode-go": 1,
|
||||
openai: 2,
|
||||
"github-copilot": 3,
|
||||
anthropic: 4,
|
||||
google: 5,
|
||||
}
|
||||
|
||||
const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__"
|
||||
const CUSTOM_PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||
|
||||
type ProviderOptionBase = {
|
||||
title: string
|
||||
value: string
|
||||
description?: string
|
||||
category: string
|
||||
}
|
||||
|
||||
type ProviderOption =
|
||||
| (ProviderOptionBase & {
|
||||
type: "provider"
|
||||
providerID: string
|
||||
})
|
||||
| (ProviderOptionBase & {
|
||||
type: "custom"
|
||||
})
|
||||
|
||||
export function providerOptions(list: { id: string; name: string }[]): ProviderOption[] {
|
||||
return [
|
||||
...pipe(
|
||||
list,
|
||||
sortBy(
|
||||
(x) => PROVIDER_PRIORITY[x.id] ?? 99,
|
||||
(x) => x.name.toLowerCase(),
|
||||
(x) => x.id,
|
||||
),
|
||||
map((provider) => ({
|
||||
type: "provider" as const,
|
||||
title: provider.name,
|
||||
value: provider.id,
|
||||
providerID: provider.id,
|
||||
description: {
|
||||
opencode: "(Recommended)",
|
||||
anthropic: "(API key)",
|
||||
openai: "(ChatGPT Plus/Pro or API key)",
|
||||
"opencode-go": "Low cost subscription for everyone",
|
||||
}[provider.id],
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Providers",
|
||||
})),
|
||||
),
|
||||
{
|
||||
type: "custom",
|
||||
title: "Other",
|
||||
value: CUSTOM_PROVIDER_OPTION_VALUE,
|
||||
description: "Custom provider",
|
||||
category: "Providers",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function normalizeCustomProviderID(value: string) {
|
||||
const providerID = value.trim().replace(/^@ai-sdk\//, "")
|
||||
if (!CUSTOM_PROVIDER_ID.test(providerID)) return
|
||||
return providerID
|
||||
}
|
||||
|
||||
export function createDialogProviderOptions() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
const onboarded = useConnected()
|
||||
|
||||
async function promptCustomProviderID(): Promise<string | undefined> {
|
||||
const value = await DialogPrompt.show(dialog, "Other", {
|
||||
placeholder: "Provider id",
|
||||
description: () => (
|
||||
<text fg={theme.textMuted}>
|
||||
This only stores a credential. Configure the provider in opencode.json to use it.
|
||||
</text>
|
||||
),
|
||||
})
|
||||
if (value === null) return
|
||||
|
||||
const providerID = normalizeCustomProviderID(value)
|
||||
if (providerID) return providerID
|
||||
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message:
|
||||
"Provider ids must start with a lowercase letter or number and only use lowercase letters, numbers, hyphens, and underscores",
|
||||
})
|
||||
return promptCustomProviderID()
|
||||
}
|
||||
|
||||
const options = createMemo(() => {
|
||||
return pipe(
|
||||
providerOptions(sync.data.provider_next.all),
|
||||
map((provider) => {
|
||||
if (provider.type === "custom") {
|
||||
return {
|
||||
title: provider.title,
|
||||
value: provider.value,
|
||||
description: provider.description,
|
||||
category: provider.category,
|
||||
async onSelect() {
|
||||
const providerID = await promptCustomProviderID()
|
||||
if (!providerID) return
|
||||
return dialog.replace(() => <ApiMethod providerID={providerID} title="API key" custom />)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const providerID = provider.providerID
|
||||
const consoleManaged = isConsoleManagedProvider(sync.data.console_state.consoleManagedProviders, providerID)
|
||||
const connected = sync.data.provider_next.connected.includes(providerID)
|
||||
|
||||
return {
|
||||
title: provider.title,
|
||||
value: provider.value,
|
||||
description: provider.description,
|
||||
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
|
||||
category: provider.category,
|
||||
gutter: connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||
async onSelect() {
|
||||
if (consoleManaged) return
|
||||
|
||||
const methods = sync.data.provider_auth[providerID] ?? [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
},
|
||||
]
|
||||
let index: number | null = 0
|
||||
if (methods.length > 1) {
|
||||
index = await new Promise<number | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title="Select auth method"
|
||||
options={methods.map((x, index) => ({
|
||||
title: x.label,
|
||||
value: index,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
if (index == null) return
|
||||
const method = methods[index]
|
||||
if (method.type === "oauth") {
|
||||
let inputs: Record<string, string> | undefined
|
||||
if (method.prompts?.length) {
|
||||
const value = await PromptsMethod({
|
||||
dialog,
|
||||
prompts: method.prompts,
|
||||
})
|
||||
if (!value) return
|
||||
inputs = value
|
||||
}
|
||||
|
||||
const result = await sdk.client.provider.oauth.authorize({
|
||||
providerID,
|
||||
method: index,
|
||||
inputs,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message: JSON.stringify(result.error),
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
if (result.data?.method === "code") {
|
||||
dialog.replace(() => (
|
||||
<CodeMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
}
|
||||
if (result.data?.method === "auto") {
|
||||
dialog.replace(() => (
|
||||
<AutoMethod providerID={providerID} title={method.label} index={index} authorization={result.data!} />
|
||||
))
|
||||
}
|
||||
}
|
||||
if (method.type === "api") {
|
||||
let metadata: Record<string, string> | undefined
|
||||
if (method.prompts?.length) {
|
||||
const value = await PromptsMethod({ dialog, prompts: method.prompts })
|
||||
if (!value) return
|
||||
metadata = value
|
||||
}
|
||||
return dialog.replace(() => (
|
||||
<ApiMethod providerID={providerID} title={method.label} metadata={metadata} />
|
||||
))
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
export function DialogProvider() {
|
||||
const options = createDialogProviderOptions()
|
||||
return <DialogSelect title="Connect a provider" options={options()} />
|
||||
}
|
||||
|
||||
interface AutoMethodProps {
|
||||
index: number
|
||||
providerID: string
|
||||
title: string
|
||||
authorization: ProviderAuthAuthorization
|
||||
}
|
||||
function AutoMethod(props: AutoMethodProps) {
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const clipboard = useClipboard()
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "c",
|
||||
desc: "Copy provider code",
|
||||
group: "Dialog",
|
||||
cmd: () => {
|
||||
const code =
|
||||
props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
|
||||
clipboard
|
||||
.write?.(code)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
onMount(async () => {
|
||||
const result = await sdk.client.provider.oauth.callback({
|
||||
providerID: props.providerID,
|
||||
method: props.index,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message:
|
||||
"name" in result.error && result.error.name === "ProviderAuthOauthCallbackFailed"
|
||||
? "OAuth authorization failed. Try /connect again."
|
||||
: JSON.stringify(result.error),
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={1}>
|
||||
<Link href={props.authorization.url} fg={theme.primary} />
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>Waiting for authorization...</text>
|
||||
<text fg={theme.text}>
|
||||
c <span style={{ fg: theme.textMuted }}>copy</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
interface CodeMethodProps {
|
||||
index: number
|
||||
title: string
|
||||
providerID: string
|
||||
authorization: ProviderAuthAuthorization
|
||||
}
|
||||
function CodeMethod(props: CodeMethodProps) {
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const [error, setError] = createSignal(false)
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={props.title}
|
||||
placeholder="Authorization code"
|
||||
onConfirm={async (value) => {
|
||||
const { error } = await sdk.client.provider.oauth.callback({
|
||||
providerID: props.providerID,
|
||||
method: props.index,
|
||||
code: value,
|
||||
})
|
||||
if (!error) {
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||
return
|
||||
}
|
||||
setError(true)
|
||||
}}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
|
||||
<Link href={props.authorization.url} fg={theme.primary} />
|
||||
<Show when={error()}>
|
||||
<text fg={theme.error}>Invalid code</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ApiMethodProps {
|
||||
providerID: string
|
||||
title: string
|
||||
metadata?: Record<string, string>
|
||||
custom?: boolean
|
||||
}
|
||||
function ApiMethod(props: ApiMethodProps) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title={props.title}
|
||||
placeholder="API key"
|
||||
description={() =>
|
||||
({
|
||||
opencode: (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API
|
||||
key.
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/zen</span> to get a key
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
"opencode-go": (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models
|
||||
with generous usage limits.
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Go to <span style={{ fg: theme.primary }}>https://opencode.ai/go</span> and enable OpenCode Go
|
||||
</text>
|
||||
</box>
|
||||
),
|
||||
})[props.providerID] ?? undefined
|
||||
}
|
||||
onConfirm={async (value) => {
|
||||
if (!value) return
|
||||
await sdk.client.auth.set({
|
||||
providerID: props.providerID,
|
||||
auth: {
|
||||
type: "api",
|
||||
key: value,
|
||||
...(props.metadata ? { metadata: props.metadata } : {}),
|
||||
},
|
||||
})
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
if (props.custom && !sync.data.provider_next.all.some((provider) => provider.id === props.providerID)) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: `Saved credential for ${props.providerID}. Configure it in opencode.json to use it.`,
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
dialog.replace(() => <DialogModel providerID={props.providerID} />)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface PromptsMethodProps {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
prompts: NonNullable<ProviderAuthMethod["prompts"]>[number][]
|
||||
}
|
||||
async function PromptsMethod(props: PromptsMethodProps) {
|
||||
const inputs: Record<string, string> = {}
|
||||
for (const prompt of props.prompts) {
|
||||
if (prompt.when) {
|
||||
const value = inputs[prompt.when.key]
|
||||
if (value === undefined) continue
|
||||
const matches = prompt.when.op === "eq" ? value === prompt.when.value : value !== prompt.when.value
|
||||
if (!matches) continue
|
||||
}
|
||||
|
||||
if (prompt.type === "select") {
|
||||
const value = await new Promise<string | null>((resolve) => {
|
||||
props.dialog.replace(
|
||||
() => (
|
||||
<DialogSelect
|
||||
title={prompt.message}
|
||||
options={prompt.options.map((x) => ({
|
||||
title: x.label,
|
||||
value: x.value,
|
||||
description: x.hint,
|
||||
}))}
|
||||
onSelect={(option) => resolve(option.value)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
if (value === null) return null
|
||||
inputs[prompt.key] = value
|
||||
continue
|
||||
}
|
||||
|
||||
const value = await new Promise<string | null>((resolve) => {
|
||||
props.dialog.replace(
|
||||
() => (
|
||||
<DialogPrompt title={prompt.message} placeholder={prompt.placeholder} onConfirm={(value) => resolve(value)} />
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
if (value === null) return null
|
||||
inputs[prompt.key] = value
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import type { SessionInfo } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useDialog } from "../ui/dialog"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useData } from "../context/data"
|
||||
import type { LocationRef } from "@opencode-ai/client"
|
||||
import type { LocationRef } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type DialogSkillProps = {
|
||||
location?: LocationRef
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { fileURLToPath } from "bun"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useData } from "../context/data"
|
||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
|
||||
export type DialogStatusProps = {}
|
||||
|
||||
export function DialogStatus() {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const { theme } = useTheme()
|
||||
const dialog = useDialog()
|
||||
|
||||
const mcp = createMemo(() => data.location.mcp.server.list() ?? [])
|
||||
const mcp = createMemo(() => data.location.mcp.list() ?? [])
|
||||
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
|
||||
|
||||
const plugins = createMemo(() => {
|
||||
const list = sync.data.config.plugin ?? []
|
||||
const result = list.map((item) => {
|
||||
const value = typeof item === "string" ? item : item[0]
|
||||
if (value.startsWith("file://")) {
|
||||
const path = fileURLToPath(value)
|
||||
const parts = path.split("/")
|
||||
const filename = parts.pop() || path
|
||||
if (!filename.includes(".")) return { name: filename }
|
||||
const basename = filename.split(".")[0]
|
||||
if (basename === "index") {
|
||||
const dirname = parts.pop()
|
||||
const name = dirname || basename
|
||||
return { name }
|
||||
}
|
||||
return { name: basename }
|
||||
}
|
||||
const index = value.lastIndexOf("@")
|
||||
if (index <= 0) return { name: value, version: "latest" }
|
||||
const name = value.substring(0, index)
|
||||
const version = value.substring(index + 1)
|
||||
return { name, version }
|
||||
})
|
||||
return result.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
@@ -63,6 +94,76 @@ export function DialogStatus() {
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
{sync.data.lsp.length > 0 && (
|
||||
<box>
|
||||
<text fg={theme.text}>{sync.data.lsp.length} LSP Servers</text>
|
||||
<For each={sync.data.lsp}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
error: theme.error,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
<Show when={enabledFormatters().length > 0} fallback={<text fg={theme.text}>No Formatters</text>}>
|
||||
<box>
|
||||
<text fg={theme.text}>{enabledFormatters().length} Formatters</text>
|
||||
<For each={enabledFormatters()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: theme.success,
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text wrapMode="word" fg={theme.text}>
|
||||
<b>{item.name}</b>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={plugins().length > 0} fallback={<text fg={theme.text}>No Plugins</text>}>
|
||||
<box>
|
||||
<text fg={theme.text}>{plugins().length} Plugins</text>
|
||||
<For each={plugins()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: theme.success,
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text wrapMode="word" fg={theme.text}>
|
||||
<b>{item.name}</b>
|
||||
{item.version && <span style={{ fg: theme.textMuted }}> @{item.version}</span>}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { ExperimentalWorkspaceAdapterListResponse, Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useProject } from "../context/project"
|
||||
import { useRoute } from "../context/route"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
|
||||
type Adapter = ExperimentalWorkspaceAdapterListResponse[number]
|
||||
|
||||
export type WorkspaceSelection =
|
||||
| {
|
||||
type: "none"
|
||||
}
|
||||
| {
|
||||
type: "new"
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
| {
|
||||
type: "existing"
|
||||
workspaceID: string
|
||||
workspaceType: string
|
||||
workspaceName: string
|
||||
}
|
||||
|
||||
type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" }
|
||||
type ExistingWorkspaceSelectValue = { workspace: Workspace }
|
||||
|
||||
export function recentConnectedWorkspaces<WorkspaceInfo extends { id: string; timeUsed: number | string }>(input: {
|
||||
workspaces: readonly WorkspaceInfo[]
|
||||
status: (workspaceID: string) => string | undefined
|
||||
limit?: number
|
||||
omitWorkspaceID?: string
|
||||
}) {
|
||||
const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected")
|
||||
const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed))
|
||||
const recent = workspaces.slice(0, input.limit ?? 3)
|
||||
|
||||
return { recent, hasMore: recent.length < workspaces.length }
|
||||
}
|
||||
|
||||
export function warpReminderText(dir: string) {
|
||||
return `<system-reminder>The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
|
||||
}
|
||||
|
||||
async function loadWorkspaceAdapters(input: {
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
}) {
|
||||
const dir = input.sync.path.directory || process.cwd()
|
||||
try {
|
||||
const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir })
|
||||
if (response.error) throw response.error
|
||||
return response.data
|
||||
} catch (err) {
|
||||
input.toast.show({
|
||||
title: "Failed to load workspace adapters",
|
||||
message: errorMessage(err),
|
||||
variant: "error",
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function openWorkspaceSelect(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
input.dialog.clear()
|
||||
await input.sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||
await input.project.workspace.sync().catch(() => undefined)
|
||||
const adapters = await loadWorkspaceAdapters(input)
|
||||
if (!adapters) return
|
||||
input.dialog.replace(() => <DialogWorkspaceSelect adapters={adapters} onSelect={input.onSelect} />)
|
||||
}
|
||||
|
||||
export async function warpWorkspaceSession(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
project: ReturnType<typeof useProject>
|
||||
toast: ReturnType<typeof useToast>
|
||||
sourceWorkspaceID?: string
|
||||
workspaceID: string | null
|
||||
sessionID: string
|
||||
copyChanges: boolean
|
||||
done?: () => void
|
||||
}): Promise<boolean> {
|
||||
let result
|
||||
try {
|
||||
result = await input.sdk.client.experimental.workspace.warp({
|
||||
id: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
copyChanges: input.copyChanges,
|
||||
})
|
||||
} catch (err) {
|
||||
input.toast.show({
|
||||
title: "Failed to warp session",
|
||||
message: errorMessage(err),
|
||||
variant: "error",
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!result?.data) {
|
||||
if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") {
|
||||
await DialogAlert.show(
|
||||
input.dialog,
|
||||
"Unable to Warp Session",
|
||||
"Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.",
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
input.toast.show({
|
||||
title: "Failed to warp session",
|
||||
message: errorMessage(result?.error ?? "no response"),
|
||||
variant: "error",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
input.project.workspace.set(input.workspaceID)
|
||||
|
||||
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
|
||||
const dir = input.project.instance.directory() || input.sync.path.directory
|
||||
if (dir) {
|
||||
await input.sdk.client.session
|
||||
.promptAsync({
|
||||
sessionID: input.sessionID,
|
||||
workspace: input.workspaceID ?? undefined,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: warpReminderText(dir),
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()])
|
||||
|
||||
if (input.done) {
|
||||
input.done()
|
||||
return true
|
||||
}
|
||||
input.dialog.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
export async function confirmWorkspaceFileChanges(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sourceWorkspaceID?: string
|
||||
}) {
|
||||
const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined)
|
||||
const fileChangeChoice = status?.data?.length
|
||||
? await DialogWorkspaceFileChanges.show(input.dialog, status.data)
|
||||
: "no"
|
||||
if (!fileChangeChoice) return
|
||||
return fileChangeChoice === "yes"
|
||||
}
|
||||
|
||||
export function DialogWorkspaceSelect(props: {
|
||||
adapters?: Adapter[]
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[] | undefined>(props.adapters)
|
||||
const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined))
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
void (async () => {
|
||||
if (adapters()) return
|
||||
const res = await loadWorkspaceAdapters({ sdk, sync, toast })
|
||||
if (!res) return
|
||||
setAdapters(res)
|
||||
})()
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceSelectValue>[]>(() => {
|
||||
const list = adapters()
|
||||
if (!list) return []
|
||||
const { recent, hasMore } = recentConnectedWorkspaces({
|
||||
workspaces: project.workspace.list(),
|
||||
status: project.workspace.status,
|
||||
omitWorkspaceID: omittedWorkspaceID(),
|
||||
})
|
||||
return [
|
||||
...list.map((adapter) => ({
|
||||
title: adapter.name,
|
||||
value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name },
|
||||
description: adapter.description,
|
||||
category: "New workspace",
|
||||
})),
|
||||
{
|
||||
title: "None",
|
||||
value: { type: "none" as const },
|
||||
description: "Use the local project",
|
||||
category: "Choose workspace",
|
||||
},
|
||||
...recent.map((workspace: Workspace) => ({
|
||||
title: workspace.name,
|
||||
description: `(${workspace.type})`,
|
||||
value: {
|
||||
type: "existing" as const,
|
||||
workspaceID: workspace.id,
|
||||
workspaceType: workspace.type,
|
||||
workspaceName: workspace.name,
|
||||
},
|
||||
category: "Choose workspace",
|
||||
})),
|
||||
...(hasMore
|
||||
? [
|
||||
{
|
||||
title: "View all workspaces",
|
||||
value: { type: "existing-list" as const },
|
||||
description: "Choose from all workspaces",
|
||||
category: "Choose workspace",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
|
||||
if (!adapters()) return null
|
||||
return (
|
||||
<DialogSelect<WorkspaceSelectValue>
|
||||
title="Warp"
|
||||
skipFilter={true}
|
||||
renderFilter={false}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (!option.value) return
|
||||
if (option.value.type === "none") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "new") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "existing") {
|
||||
void props.onSelect(option.value)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.replace(() => (
|
||||
<DialogExistingWorkspaceSelect omitWorkspaceID={omittedWorkspaceID()} onSelect={props.onSelect} />
|
||||
))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogExistingWorkspaceSelect(props: {
|
||||
omitWorkspaceID?: string
|
||||
onSelect: (selection: WorkspaceSelection) => Promise<void> | void
|
||||
}) {
|
||||
const project = useProject()
|
||||
|
||||
const options = createMemo<DialogSelectOption<ExistingWorkspaceSelectValue>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.filter((workspace) => project.workspace.status(workspace.id) === "connected")
|
||||
.filter((workspace) => workspace.id !== props.omitWorkspaceID)
|
||||
.map((workspace: Workspace) => ({
|
||||
title: workspace.name,
|
||||
description: `(${workspace.type})`,
|
||||
value: { workspace },
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect<ExistingWorkspaceSelectValue>
|
||||
title="Existing Workspace"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
void props.onSelect({
|
||||
type: "existing",
|
||||
workspaceID: option.value.workspace.id,
|
||||
workspaceType: option.value.workspace.type,
|
||||
workspaceName: option.value.workspace.name,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { VcsFileStatus } from "@opencode-ai/client"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { VcsFileStatus } from "@opencode-ai/sdk/v2"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FilePath } from "../ui/file-path"
|
||||
import { Locale } from "../util/locale"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useTuiConfig } from "../config"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
|
||||
@@ -33,13 +33,10 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const [store, setStore] = createStore({ active: "yes" as WorkspaceFileChangesChoice })
|
||||
const height = createMemo(() => Math.min(props.files.length, 8))
|
||||
const fileNameWidth = createMemo(
|
||||
() => Math.max(2, Math.min(60, dimensions().width - 2) - 6 - Math.max(7, ...props.files.map(changeCountWidth))),
|
||||
)
|
||||
const fileNameWidth = createMemo(() => 48 - Math.max(Math.max(7, ...props.files.map(changeCountWidth)) - 7, 0))
|
||||
|
||||
function confirm() {
|
||||
props.onSelect(store.active)
|
||||
@@ -96,7 +93,9 @@ export function DialogWorkspaceFileChanges(props: {
|
||||
<box width={2} flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{statusLabel(item.status)}</text>
|
||||
</box>
|
||||
<FilePath value={item.file} maxWidth={fileNameWidth()} fg={theme.textMuted} />
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{Locale.truncateLeft(item.file, fileNameWidth())}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} minWidth={7} flexShrink={0} justifyContent="flex-end">
|
||||
<text>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useProject } from "../context/project"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type WorkspaceOption = { workspace: Workspace }
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const project = useProject()
|
||||
const { theme } = useTheme()
|
||||
const [deleting, setDeleting] = createSignal<string>()
|
||||
const [removing, setRemoving] = createSignal<string>()
|
||||
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
|
||||
|
||||
const current = createMemo(() => {
|
||||
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
|
||||
return project.workspace.current()
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
|
||||
project.workspace
|
||||
.list()
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((workspace) => {
|
||||
const status = project.workspace.status(workspace.id)
|
||||
return {
|
||||
title:
|
||||
removing() === workspace.id
|
||||
? "Deleting..."
|
||||
: deleting() === workspace.id
|
||||
? `Delete ${workspace.name}? Press delete again`
|
||||
: workspace.name,
|
||||
value: { workspace },
|
||||
footer: workspace.type,
|
||||
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
|
||||
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}>●</text>,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
function showDetails(workspace: Workspace) {
|
||||
setExpanded(workspace.id, (open) => !open)
|
||||
}
|
||||
|
||||
async function remove(workspace: Workspace) {
|
||||
if (removing()) return
|
||||
if (deleting() !== workspace.id) {
|
||||
setDeleting(workspace.id)
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(undefined)
|
||||
setRemoving(workspace.id)
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
|
||||
error: err,
|
||||
}))
|
||||
if (result?.error) {
|
||||
setRemoving(undefined)
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete workspace",
|
||||
message: errorMessage(result.error),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (current() === workspace.id) {
|
||||
project.workspace.set(undefined)
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
setRemoving(undefined)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
|
||||
void project.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
options={options()}
|
||||
onMove={(option) => {
|
||||
setDeleting(undefined)
|
||||
}}
|
||||
onSelect={(option) => showDetails(option.value.workspace)}
|
||||
actions={[
|
||||
{
|
||||
command: "session.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => void remove(option.value.workspace),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useBindings } from "../keymap"
|
||||
|
||||
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
active: "restore" as "cancel" | "restore",
|
||||
})
|
||||
|
||||
const options = ["cancel", "restore"] as const
|
||||
|
||||
async function confirm() {
|
||||
if (store.active === "cancel") {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const result = await props.onRestore?.()
|
||||
if (result === false) return
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{ key: "return", desc: "Confirm workspace option", group: "Dialog", cmd: () => void confirm() },
|
||||
{ key: "left", desc: "Cancel workspace restore", group: "Dialog", cmd: () => setStore("active", "cancel") },
|
||||
{ key: "right", desc: "Restore workspace", group: "Dialog", cmd: () => setStore("active", "restore") },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Workspace Unavailable
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
This session is attached to a workspace that is no longer available.
|
||||
</text>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Would you like to restore this session into a new workspace?
|
||||
</text>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
|
||||
<For each={options}>
|
||||
{(item) => (
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={item === store.active ? theme.primary : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item)
|
||||
void confirm()
|
||||
}}
|
||||
>
|
||||
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import { createStore } from "solid-js/store"
|
||||
import { useEditorContext } from "../../context/editor"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useData } from "../../context/data"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useTuiConfig } from "../../config/v1"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
@@ -21,7 +22,7 @@ import type { PromptInfo, PromptPartRef } from "../../prompt/history"
|
||||
import { useFrecency } from "../../prompt/frecency"
|
||||
import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap"
|
||||
import { displayCharAt, mentionTriggerIndex } from "../../prompt/display"
|
||||
import type { FileSystemEntry } from "@opencode-ai/client"
|
||||
import type { FileSystemEntry } from "@opencode-ai/sdk/v2"
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hashIndex = input.lastIndexOf("#")
|
||||
@@ -85,6 +86,7 @@ export function Autocomplete(props: {
|
||||
}) {
|
||||
const editor = useEditorContext()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const slashes = useCommandSlashes()
|
||||
@@ -283,7 +285,7 @@ export function Autocomplete(props: {
|
||||
})
|
||||
|
||||
function normalizeMentionPath(filePath: string) {
|
||||
const baseDir = location()?.directory || project.instance.directory() || paths.cwd
|
||||
const baseDir = location()?.directory || sync.path.directory || paths.cwd
|
||||
const absolute = path.resolve(filePath)
|
||||
const relative = path.relative(baseDir, absolute)
|
||||
|
||||
@@ -361,7 +363,7 @@ export function Autocomplete(props: {
|
||||
const options: AutocompleteOption[] = []
|
||||
const width = props.anchor().width - 4
|
||||
|
||||
for (const res of data.location.mcp.resource.list(location()) ?? []) {
|
||||
for (const res of Object.values(sync.data.mcp_resource)) {
|
||||
options.push({
|
||||
display: Locale.truncateMiddle(res.name, width),
|
||||
// Match the name only; matching the URI caused unrelated fuzzy hits.
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Spinner } from "../spinner"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useRoute } from "../../context/route"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useEvent } from "../../context/event"
|
||||
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
|
||||
import { normalizePromptContent, openEditor } from "../../editor"
|
||||
@@ -36,8 +37,10 @@ import { usePromptStash } from "../../prompt/stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { createColors, createFrames } from "../../ui/spinner"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { DialogIntegration } from "../dialog-integration"
|
||||
@@ -46,9 +49,11 @@ import { useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createFadeIn } from "../../util/signal"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap"
|
||||
import { useTuiConfig } from "../../config/v1"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { usePromptWorkspace } from "./workspace"
|
||||
import { usePromptMove } from "./move"
|
||||
import { readLocalAttachment } from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
@@ -152,6 +157,7 @@ export function Prompt(props: PromptProps) {
|
||||
const editor = useEditorContext()
|
||||
const route = useRoute()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const currentLocation = useLocation()
|
||||
const tuiConfig = useTuiConfig()
|
||||
@@ -214,6 +220,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
const editorContextLabelState = createMemo(() => editor.labelState())
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
const workspace = usePromptWorkspace(props.sessionID)
|
||||
const move = usePromptMove({
|
||||
projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(),
|
||||
sessionID: () => props.sessionID,
|
||||
@@ -263,6 +270,13 @@ export function Prompt(props: PromptProps) {
|
||||
if (!props.disabled) input.cursorColor = theme.text
|
||||
})
|
||||
|
||||
const lastUserMessage = createMemo(() => {
|
||||
if (!props.sessionID) return undefined
|
||||
const messages = sync.data.message[props.sessionID]
|
||||
if (!messages) return undefined
|
||||
return messages.findLast((m): m is UserMessage => m.role === "user")
|
||||
})
|
||||
|
||||
const usage = createMemo(() => {
|
||||
if (!props.sessionID) return
|
||||
const session = data.session.get(props.sessionID)
|
||||
@@ -516,6 +530,17 @@ export function Prompt(props: PromptProps) {
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Warp",
|
||||
desc: "Change the workspace for the session",
|
||||
name: "workspace.set",
|
||||
category: "Session",
|
||||
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slashName: "warp",
|
||||
run: () => {
|
||||
workspace.open()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Move session",
|
||||
desc: "Move to another project dir",
|
||||
@@ -548,6 +573,7 @@ export function Prompt(props: PromptProps) {
|
||||
"prompt.skills",
|
||||
"session.interrupt",
|
||||
"session.background",
|
||||
"workspace.set",
|
||||
"session.move",
|
||||
]),
|
||||
}))
|
||||
@@ -932,6 +958,8 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
async function submitInner() {
|
||||
workspace.clearNotice()
|
||||
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
// composed character (e.g. Korean hangul) to the store, so read
|
||||
// plainText directly and sync before any downstream reads.
|
||||
@@ -940,7 +968,7 @@ export function Prompt(props: PromptProps) {
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (move.creating()) return false
|
||||
if (workspace.creating() || move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
if (!store.prompt.text) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
@@ -956,11 +984,29 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
|
||||
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
|
||||
const workspaceID = workspaceSession?.workspaceID
|
||||
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
|
||||
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaceUnavailable
|
||||
onRestore={() => {
|
||||
workspace.open()
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
))
|
||||
return false
|
||||
}
|
||||
|
||||
const variant = local.model.variant.current()
|
||||
let sessionID = props.sessionID
|
||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||
let finishMoveProgress = false
|
||||
if (sessionID == null) {
|
||||
const selectedWorkspace = workspace.selection()
|
||||
const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
|
||||
|
||||
const directory = await move.getDirectory()
|
||||
if (move.pending() && !directory) return false
|
||||
finishMoveProgress = Boolean(move.progress())
|
||||
@@ -968,7 +1014,9 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const created = await sdk.api.session
|
||||
.create({
|
||||
location: directory ? { directory } : location,
|
||||
location: directory
|
||||
? { directory, workspaceID }
|
||||
: { directory: location.directory, workspaceID: workspaceID ?? location.workspaceID },
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
@@ -1099,9 +1147,18 @@ export function Prompt(props: PromptProps) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const messageID = SessionMessage.ID.create()
|
||||
data.session.input.optimistic(sessionID, {
|
||||
id: messageID,
|
||||
type: "user",
|
||||
text: inputText,
|
||||
agents: store.prompt.agents,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const error = await sdk.api.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
id: messageID,
|
||||
text: inputText,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
@@ -1111,6 +1168,7 @@ export function Prompt(props: PromptProps) {
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
data.session.input.rollback(sessionID, messageID)
|
||||
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
||||
return false
|
||||
}
|
||||
@@ -1191,7 +1249,7 @@ export function Prompt(props: PromptProps) {
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (
|
||||
(lineCount >= 3 || pastedContent.length > 150) &&
|
||||
kv.get("paste_summary_enabled", true)
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
|
||||
) {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
@@ -1301,7 +1359,7 @@ export function Prompt(props: PromptProps) {
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent =
|
||||
status() === "running"
|
||||
? local.agent.current()
|
||||
? (local.agent.list().find((agent) => agent.id === lastUserMessage()?.agent) ?? local.agent.current())
|
||||
: local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.id) : theme.border
|
||||
return {
|
||||
@@ -1507,6 +1565,41 @@ export function Prompt(props: PromptProps) {
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={workspace.notice()}>
|
||||
{(notice) => (
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.accent}>{notice()}</text>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={workspace.label()}>
|
||||
{(label) => (
|
||||
<box paddingLeft={3} flexDirection="row" gap={1}>
|
||||
<Show when={workspace.creating()}>
|
||||
<Spinner color={theme.accent} />
|
||||
</Show>
|
||||
<text fg={workspace.creating() ? theme.accent : theme.text}>
|
||||
{(() => {
|
||||
const item = label()
|
||||
if (item.type === "new") {
|
||||
if (workspace.creating())
|
||||
return `Creating ${item.workspaceType}${".".repeat(workspace.creatingDots())}`
|
||||
return (
|
||||
<>
|
||||
Workspace <span style={{ fg: theme.textMuted }}>(new {item.workspaceType})</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
Workspace <span style={{ fg: theme.textMuted }}>{item.workspaceName}</span>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3}>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import {
|
||||
confirmWorkspaceFileChanges,
|
||||
openWorkspaceSelect,
|
||||
warpWorkspaceSession,
|
||||
type WorkspaceSelection,
|
||||
} from "../dialog-workspace-create"
|
||||
import type { WorkspaceStatus } from "../workspace-label"
|
||||
|
||||
export function usePromptWorkspace(sessionID?: string) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const [selection, setSelection] = createSignal<WorkspaceSelection>()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [notice, setNotice] = createSignal<string>()
|
||||
|
||||
async function create(selection: Extract<WorkspaceSelection, { type: "new" }>) {
|
||||
setCreating(true)
|
||||
let result
|
||||
try {
|
||||
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
|
||||
} catch (err) {
|
||||
setSelection(undefined)
|
||||
setCreating(false)
|
||||
toast.show({ title: "Creating workspace failed", message: errorMessage(err), variant: "error" })
|
||||
return
|
||||
}
|
||||
if (result.error || !result.data) {
|
||||
setSelection(undefined)
|
||||
setCreating(false)
|
||||
toast.show({
|
||||
title: "Creating workspace failed",
|
||||
message: errorMessage(result.error ?? "no response"),
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await project.workspace.sync()
|
||||
const workspace = result.data
|
||||
setSelection({
|
||||
type: "existing",
|
||||
workspaceID: workspace.id,
|
||||
workspaceType: workspace.type,
|
||||
workspaceName: workspace.name,
|
||||
})
|
||||
setCreating(false)
|
||||
return workspace
|
||||
}
|
||||
|
||||
async function warp(selection: WorkspaceSelection) {
|
||||
if (!sessionID) {
|
||||
setSelection(selection)
|
||||
dialog.clear()
|
||||
if (selection.type === "new") void create(selection)
|
||||
return
|
||||
}
|
||||
const sourceWorkspaceID = project.workspace.current()
|
||||
const copyChanges = await confirmWorkspaceFileChanges({ dialog, sdk, sourceWorkspaceID })
|
||||
if (copyChanges === undefined) return
|
||||
setSelection(selection)
|
||||
dialog.clear()
|
||||
|
||||
const workspace =
|
||||
selection.type === "none"
|
||||
? { id: null, name: "local project" }
|
||||
: selection.type === "existing"
|
||||
? { id: selection.workspaceID, name: selection.workspaceName }
|
||||
: await create(selection)
|
||||
if (!workspace) return
|
||||
|
||||
const warped = await warpWorkspaceSession({
|
||||
dialog,
|
||||
sdk,
|
||||
sync,
|
||||
project,
|
||||
toast,
|
||||
sourceWorkspaceID,
|
||||
workspaceID: workspace.id,
|
||||
sessionID,
|
||||
copyChanges,
|
||||
})
|
||||
if (warped) showNotice(workspace.name)
|
||||
}
|
||||
|
||||
function showNotice(name: string) {
|
||||
setNotice(`Warped to ${name}`)
|
||||
setTimeout(() => setNotice(undefined), 4000)
|
||||
}
|
||||
|
||||
function clearNotice() {
|
||||
setNotice(undefined)
|
||||
}
|
||||
|
||||
function open() {
|
||||
void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!creating()) {
|
||||
setCreatingDots(3)
|
||||
return
|
||||
}
|
||||
const timer = setInterval(() => setCreatingDots((dots) => (dots % 3) + 1), 1000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
const label = createMemo<
|
||||
| { type: "new"; workspaceType: string }
|
||||
| { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus }
|
||||
| undefined
|
||||
>(() => {
|
||||
const selected = selection()
|
||||
if (!selected) return
|
||||
if (selected.type === "none") return
|
||||
if (sessionID && !creating()) return
|
||||
if (selected.type === "new") return { type: "new", workspaceType: selected.workspaceType }
|
||||
return {
|
||||
type: "existing",
|
||||
workspaceType: selected.workspaceType,
|
||||
workspaceName: selected.workspaceName,
|
||||
status: selected.type === "existing" ? "connected" : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return { selection, creating, creatingDots, notice, label, open, warp, clearNotice }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useTheme } from "../context/theme"
|
||||
|
||||
export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) {
|
||||
const { theme } = useTheme()
|
||||
const color = () => {
|
||||
if (props.status === "connected") return theme.success
|
||||
if (props.status === "error") return theme.error
|
||||
return theme.textMuted
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.icon ? <span style={{ fg: color() }}>● </span> : undefined}
|
||||
<span style={{ fg: theme.text }}>{props.name}</span> <span style={{ fg: theme.textMuted }}>({props.type})</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -97,6 +97,8 @@ export const Definitions = {
|
||||
session_interrupt: keybind("escape", "Interrupt current session"),
|
||||
session_background: keybind("ctrl+b", "Background blocking session tools"),
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
|
||||
session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"),
|
||||
session_queued_prompts: keybind("<leader>q", "Manage queued prompts"),
|
||||
session_child_first: keybind("down,<leader>down", "Toggle subagent picker"),
|
||||
session_child_cycle: keybind("right", "Go to next child session"),
|
||||
@@ -123,6 +125,7 @@ export const Definitions = {
|
||||
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
|
||||
mcp_list: keybind("none", "List MCP servers"),
|
||||
provider_connect: keybind("none", "Connect integration"),
|
||||
console_org_switch: keybind("none", "Switch console organization"),
|
||||
agent_list: keybind("<leader>a", "List agents"),
|
||||
agent_cycle: keybind("tab", "Next agent"),
|
||||
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
|
||||
@@ -144,6 +147,7 @@ export const Definitions = {
|
||||
messages_undo: keybind("<leader>u", "Undo message"),
|
||||
messages_redo: keybind("<leader>r", "Redo message"),
|
||||
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
|
||||
tool_details: keybind("none", "Toggle tool details visibility"),
|
||||
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
prompt_submit: keybind("none", "Submit prompt"),
|
||||
@@ -152,6 +156,7 @@ export const Definitions = {
|
||||
prompt_stash: keybind("none", "Stash prompt"),
|
||||
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
|
||||
prompt_stash_list: keybind("none", "List stashed prompts"),
|
||||
workspace_set: keybind("none", "Set workspace"),
|
||||
|
||||
input_clear: keybind("ctrl+c", "Clear input field"),
|
||||
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
|
||||
@@ -300,6 +305,8 @@ export const CommandMap = {
|
||||
session_interrupt: "session.interrupt",
|
||||
session_background: "session.background",
|
||||
session_compact: "session.compact",
|
||||
session_toggle_timestamps: "session.toggle.timestamps",
|
||||
session_toggle_generic_tool_output: "session.toggle.generic_tool_output",
|
||||
session_queued_prompts: "session.queued_prompts",
|
||||
session_child_first: "session.child.first",
|
||||
session_child_cycle: "session.child.next",
|
||||
@@ -325,6 +332,7 @@ export const CommandMap = {
|
||||
model_cycle_favorite_reverse: "model.cycle_favorite_reverse",
|
||||
mcp_list: "mcp.list",
|
||||
provider_connect: "provider.connect",
|
||||
console_org_switch: "console.org.switch",
|
||||
agent_list: "agent.list",
|
||||
agent_cycle: "agent.cycle",
|
||||
agent_cycle_reverse: "agent.cycle.reverse",
|
||||
@@ -345,6 +353,7 @@ export const CommandMap = {
|
||||
messages_undo: "session.undo",
|
||||
messages_redo: "session.redo",
|
||||
messages_toggle_conceal: "session.toggle.conceal",
|
||||
tool_details: "session.toggle.actions",
|
||||
display_thinking: "session.toggle.thinking",
|
||||
prompt_submit: "prompt.submit",
|
||||
prompt_editor_context_clear: "prompt.editor_context.clear",
|
||||
@@ -352,6 +361,7 @@ export const CommandMap = {
|
||||
prompt_stash: "prompt.stash",
|
||||
prompt_stash_pop: "prompt.stash.pop",
|
||||
prompt_stash_list: "prompt.stash.list",
|
||||
workspace_set: "workspace.set",
|
||||
input_clear: "prompt.clear",
|
||||
input_paste: "prompt.paste",
|
||||
input_submit: "input.submit",
|
||||
@@ -1,97 +0,0 @@
|
||||
export * as TuiConfigV2 from "."
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
|
||||
export const Plugin = Schema.Union([
|
||||
Schema.String,
|
||||
Schema.Struct({
|
||||
package: Schema.String,
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
}),
|
||||
])
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
theme: Schema.optional(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
mode: Schema.optional(Schema.Literals(["system", "dark", "light"])),
|
||||
}),
|
||||
),
|
||||
keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
|
||||
plugins: Schema.optional(Schema.Array(Plugin)),
|
||||
leader: Schema.optional(
|
||||
Schema.Struct({
|
||||
timeout: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
|
||||
}),
|
||||
),
|
||||
scroll: Schema.optional(
|
||||
Schema.Struct({
|
||||
speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))),
|
||||
acceleration: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
attention: Schema.optional(
|
||||
Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean),
|
||||
notifications: Schema.optional(Schema.Boolean),
|
||||
sound: Schema.optional(Schema.Boolean),
|
||||
volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
|
||||
sound_pack: Schema.optional(Schema.String),
|
||||
sounds: Schema.optional(
|
||||
Schema.Record(
|
||||
Schema.Literals(["default", "question", "permission", "error", "done", "subagent_done"]),
|
||||
Schema.optionalKey(Schema.String),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
diffs: Schema.optional(
|
||||
Schema.Struct({
|
||||
wrap: Schema.optional(Schema.Literals(["word", "none"])),
|
||||
tree: Schema.optional(Schema.Boolean),
|
||||
single: Schema.optional(Schema.Boolean),
|
||||
view: Schema.optional(Schema.Literals(["auto", "split", "unified"])),
|
||||
}),
|
||||
),
|
||||
terminal: Schema.optional(
|
||||
Schema.Struct({
|
||||
title: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
composer: Schema.optional(
|
||||
Schema.Struct({
|
||||
file_context: Schema.optional(Schema.Boolean),
|
||||
paste_summary: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
session: Schema.optional(
|
||||
Schema.Struct({
|
||||
sidebar: Schema.optional(Schema.Literals(["auto", "hide"])),
|
||||
scrollbar: Schema.optional(Schema.Boolean),
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])),
|
||||
group_exploration: Schema.optional(Schema.Boolean),
|
||||
directory_filter: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
which_key: Schema.optional(
|
||||
Schema.Struct({
|
||||
layout: Schema.optional(Schema.Literals(["dock", "overlay"])),
|
||||
pending_preview: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
hints: Schema.optional(
|
||||
Schema.Struct({
|
||||
tips: Schema.optional(Schema.Boolean),
|
||||
getting_started: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
updates: Schema.optional(
|
||||
Schema.Struct({
|
||||
skipped: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
animations: Schema.optional(Schema.Boolean),
|
||||
mouse: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
@@ -1,6 +0,0 @@
|
||||
export * as TuiKeybind from "./keybind"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const KeybindOverrides = Schema.Struct({})
|
||||
export type KeybindOverrides = Schema.Schema.Type<typeof KeybindOverrides>
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
FormInfo,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
McpResource,
|
||||
McpServer,
|
||||
ModelInfo,
|
||||
PermissionSavedInfo,
|
||||
@@ -22,12 +21,11 @@ import type {
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
SessionInfo,
|
||||
SessionPendingInfo,
|
||||
ShellInfo,
|
||||
SessionMessageUser,
|
||||
Shell,
|
||||
SkillInfo,
|
||||
OpenCodeEvent,
|
||||
} from "@opencode-ai/client"
|
||||
import type { Data } from "@opencode-ai/plugin/v2/tui/context"
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
@@ -46,20 +44,17 @@ type LocationData = {
|
||||
agent?: AgentInfo[]
|
||||
command?: CommandInfo[]
|
||||
integration?: IntegrationInfo[]
|
||||
mcp?: {
|
||||
server?: McpServer[]
|
||||
resource?: McpResource[]
|
||||
}
|
||||
mcp?: McpServer[]
|
||||
model?: ModelInfo[]
|
||||
provider?: ProviderV2Info[]
|
||||
reference?: ReferenceInfo[]
|
||||
// Currently running shell commands for this location, keyed by shell id. Entries are removed
|
||||
// once the command exits or is deleted, so this only ever holds in-flight shells.
|
||||
shell?: Record<string, ShellInfo>
|
||||
shell?: Record<string, Shell>
|
||||
skill?: SkillInfo[]
|
||||
}
|
||||
|
||||
type Store = {
|
||||
type Data = {
|
||||
session: {
|
||||
info: Record<string, SessionInfo>
|
||||
// Family index keyed by a family's root (or furthest-known-ancestor when the
|
||||
@@ -68,9 +63,7 @@ type Store = {
|
||||
family: Record<string, string[]>
|
||||
status: Record<string, DataSessionStatus>
|
||||
message: Record<string, SessionMessageInfo[]>
|
||||
pending: Record<string, SessionPendingInfo[]>
|
||||
input: Record<string, string[]>
|
||||
compaction: Record<string, string[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
||||
form: Record<string, FormWithLocation[]>
|
||||
@@ -92,15 +85,13 @@ function locationQuery(ref?: LocationRef) {
|
||||
export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
name: "Data",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<Store>({
|
||||
const [store, setStore] = createStore<Data>({
|
||||
session: {
|
||||
info: {},
|
||||
family: {},
|
||||
status: {},
|
||||
message: {},
|
||||
pending: {},
|
||||
input: {},
|
||||
compaction: {},
|
||||
permission: {},
|
||||
form: {},
|
||||
},
|
||||
@@ -115,6 +106,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
directory: process.cwd(),
|
||||
})
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
const optimisticInput = new Map<string, Set<string>>()
|
||||
let bootstrapping: Promise<void> | undefined
|
||||
let connected = false
|
||||
|
||||
@@ -122,36 +114,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
setStore("session", "status", sessionID, status)
|
||||
}
|
||||
|
||||
function addCompaction(sessionID: string, inputID: string) {
|
||||
if (store.session.compaction[sessionID]?.includes(inputID)) return
|
||||
setStore("session", "compaction", sessionID, [...(store.session.compaction[sessionID] ?? []), inputID])
|
||||
}
|
||||
|
||||
function addPending(item: SessionPendingInfo) {
|
||||
if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id)) return
|
||||
setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item])
|
||||
}
|
||||
|
||||
function removePending(sessionID: string, inputID?: string) {
|
||||
if (!inputID) return
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
sessionID,
|
||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
|
||||
)
|
||||
}
|
||||
|
||||
function removeCompaction(sessionID: string, inputID?: string) {
|
||||
if (!inputID || !store.session.compaction[sessionID]?.includes(inputID)) return
|
||||
setStore(
|
||||
"session",
|
||||
"compaction",
|
||||
sessionID,
|
||||
store.session.compaction[sessionID].filter((id) => id !== inputID),
|
||||
)
|
||||
}
|
||||
|
||||
const message = {
|
||||
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
||||
setStore(
|
||||
@@ -254,15 +216,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
messageIndex.delete(sessionID)
|
||||
optimisticInput.delete(sessionID)
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
delete draft.info[sessionID]
|
||||
delete draft.status[sessionID]
|
||||
delete draft.message[sessionID]
|
||||
delete draft.pending[sessionID]
|
||||
delete draft.input[sessionID]
|
||||
delete draft.compaction[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
delete draft.form[sessionID]
|
||||
for (const [rootID, family] of Object.entries(draft.family)) {
|
||||
@@ -350,7 +311,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
break
|
||||
case "session.input.promoted": {
|
||||
removePending(event.data.sessionID, event.data.inputID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return
|
||||
@@ -370,39 +330,35 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
break
|
||||
}
|
||||
case "session.input.admitted":
|
||||
addPending({
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
admittedSeq: event.durable.seq,
|
||||
timeCreated: event.created,
|
||||
...event.data.input,
|
||||
})
|
||||
case "session.input.admitted": {
|
||||
const inputs = optimisticInput.get(event.data.sessionID)
|
||||
if (inputs?.delete(event.data.inputID) && inputs.size === 0) optimisticInput.delete(event.data.sessionID)
|
||||
if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
|
||||
setStore("session", "input", event.data.sessionID, [
|
||||
...(store.session.input[event.data.sessionID] ?? []),
|
||||
event.data.inputID,
|
||||
])
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(
|
||||
draft,
|
||||
index,
|
||||
const item =
|
||||
event.data.input.type === "user"
|
||||
? {
|
||||
id: event.data.inputID,
|
||||
type: "user",
|
||||
type: "user" as const,
|
||||
...event.data.input.data,
|
||||
time: { created: event.created },
|
||||
}
|
||||
: {
|
||||
id: event.data.inputID,
|
||||
type: "synthetic",
|
||||
type: "synthetic" as const,
|
||||
...event.data.input.data,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
}
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return message.append(draft, index, item)
|
||||
draft[position] = item
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.instructions.updated":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
@@ -661,18 +617,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
setSessionStatus(event.data.sessionID, "running")
|
||||
break
|
||||
case "session.compaction.admitted":
|
||||
addPending({
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
admittedSeq: event.durable.seq,
|
||||
timeCreated: event.created,
|
||||
type: "compaction",
|
||||
})
|
||||
addCompaction(event.data.sessionID, event.data.inputID)
|
||||
break
|
||||
case "session.compaction.started":
|
||||
removePending(event.data.sessionID, event.data.inputID)
|
||||
removeCompaction(event.data.sessionID, event.data.inputID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
@@ -749,8 +695,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.compaction.failed":
|
||||
removePending(event.data.sessionID, event.data.inputID)
|
||||
removeCompaction(event.data.sessionID, event.data.inputID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
|
||||
const current = draft[position]
|
||||
@@ -842,10 +786,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
// so the mcp list refreshes here rather than off integration.updated.
|
||||
case "mcp.status.changed":
|
||||
if (bootstrapping) break
|
||||
void result.location.mcp.server.refresh(event.location)
|
||||
break
|
||||
case "mcp.resources.changed":
|
||||
void result.location.mcp.resource.refresh(event.location)
|
||||
void result.location.mcp.refresh(event.location)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -879,6 +820,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.session.status[sessionID] ?? "idle"
|
||||
},
|
||||
input: {
|
||||
optimistic(sessionID: string, item: SessionMessageUser) {
|
||||
const inputs = optimisticInput.get(sessionID) ?? new Set<string>()
|
||||
inputs.add(item.id)
|
||||
optimisticInput.set(sessionID, inputs)
|
||||
if (!store.session.input[sessionID]?.includes(item.id))
|
||||
setStore("session", "input", sessionID, [...(store.session.input[sessionID] ?? []), item.id])
|
||||
message.update(sessionID, (draft, index) => message.append(draft, index, item))
|
||||
},
|
||||
rollback(sessionID: string, inputID: string) {
|
||||
const inputs = optimisticInput.get(sessionID)
|
||||
if (!inputs?.delete(inputID)) return
|
||||
if (inputs.size === 0) optimisticInput.delete(sessionID)
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.input[sessionID] = (draft.input[sessionID] ?? []).filter((id) => id !== inputID)
|
||||
const messages = draft.message[sessionID]
|
||||
const position = messageIndex.get(sessionID)?.get(inputID)
|
||||
if (!messages || position === undefined) return
|
||||
messages.splice(position, 1)
|
||||
messageIndex.set(sessionID, new Map(messages.map((item, index) => [item.id, index])))
|
||||
}),
|
||||
)
|
||||
},
|
||||
list(sessionID: string) {
|
||||
return store.session.input[sessionID] ?? []
|
||||
},
|
||||
@@ -886,40 +851,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.session.input[sessionID]?.includes(inputID) ?? false
|
||||
},
|
||||
},
|
||||
compaction: {
|
||||
list(sessionID: string) {
|
||||
return store.session.compaction[sessionID] ?? []
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
await result.session.pending.refresh(sessionID)
|
||||
},
|
||||
},
|
||||
pending: {
|
||||
list(sessionID: string) {
|
||||
return store.session.pending[sessionID] ?? []
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
const pending = await sdk.api.session.pending.list({ sessionID })
|
||||
setStore("session", "pending", sessionID, reconcile(pending))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
sessionID,
|
||||
reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||
)
|
||||
setStore(
|
||||
"session",
|
||||
"compaction",
|
||||
sessionID,
|
||||
reconcile(pending.filter((item) => item.type === "compaction").map((item) => item.id)),
|
||||
)
|
||||
},
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
setStore("session", "info", sessionID, await sdk.api.session.get({ sessionID }))
|
||||
registerSession(sessionID)
|
||||
},
|
||||
message: {
|
||||
ids(sessionID: string) {
|
||||
return (store.session.message[sessionID] ?? []).map((message) => message.id)
|
||||
},
|
||||
list(sessionID: string) {
|
||||
return store.session.message[sessionID] ?? []
|
||||
},
|
||||
@@ -1039,31 +978,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
server: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.mcp.list({ location: locationQuery(ref) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
mcp: { ...store.location[key]?.mcp, server: result.data },
|
||||
})
|
||||
},
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp
|
||||
},
|
||||
resource: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.mcp.resource.catalog({ location: locationQuery(ref) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
mcp: { ...store.location[key]?.mcp, resource: result.data.resources },
|
||||
})
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api["server.mcp"].list({ location: locationQuery(ref) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, { ...store.location[key], mcp: result.data })
|
||||
},
|
||||
},
|
||||
model: {
|
||||
@@ -1108,7 +1029,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
},
|
||||
}
|
||||
result satisfies Data
|
||||
|
||||
async function bootstrap() {
|
||||
if (bootstrapping) return bootstrapping
|
||||
@@ -1160,8 +1080,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
result.location.refresh(),
|
||||
result.location.agent.refresh(),
|
||||
result.location.integration.refresh(),
|
||||
result.location.mcp.server.refresh(),
|
||||
result.location.mcp.resource.refresh(),
|
||||
result.location.mcp.refresh(),
|
||||
result.location.model.refresh(),
|
||||
result.location.provider.refresh(),
|
||||
result.location.reference.refresh(),
|
||||
@@ -1209,14 +1128,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
sdk.event.listen(({ details }) => {
|
||||
if (details.type === "server.connected") {
|
||||
const messages = connected ? Object.keys(store.session.message) : []
|
||||
const compactions = connected ? Object.keys(store.session.compaction) : []
|
||||
connected = true
|
||||
refreshActive()
|
||||
void Promise.allSettled([
|
||||
bootstrap(),
|
||||
...messages.map(result.session.message.refresh),
|
||||
...compactions.map(result.session.compaction.refresh),
|
||||
])
|
||||
void Promise.allSettled([bootstrap(), ...messages.map(result.session.message.refresh)])
|
||||
return
|
||||
}
|
||||
handleEvent(details)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useProject } from "./project"
|
||||
import { useSync } from "./sync"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
|
||||
export function useDirectory() {
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const paths = useTuiPaths()
|
||||
return createMemo(() => {
|
||||
const directory = project.instance.path().directory || paths.cwd
|
||||
return abbreviateHome(directory, paths.home)
|
||||
const result = abbreviateHome(directory, paths.home)
|
||||
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
type EventMetadata = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { useSync } from "./sync"
|
||||
import { useEvent } from "./event"
|
||||
import path from "path"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
@@ -51,6 +52,7 @@ export function recentModels(
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
@@ -208,6 +210,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
if (sync.data.config.model) {
|
||||
const { providerID, modelID } = parseModel(sync.data.config.model)
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
return {
|
||||
providerID,
|
||||
modelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of modelStore.recent) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
@@ -441,7 +453,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const slots = createMemo(() => {
|
||||
const existing = new Set(data.session.list().filter((x) => x.parentID === undefined).map((x) => x.id))
|
||||
const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id))
|
||||
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
|
||||
})
|
||||
|
||||
@@ -495,11 +507,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const mcp = {
|
||||
isEnabled(name: string) {
|
||||
return data.location.mcp.server.list()?.find((item) => item.name === name)?.status.status === "connected"
|
||||
const status = sync.data.mcp[name]
|
||||
return status?.status === "connected"
|
||||
},
|
||||
async toggle(name: string) {
|
||||
const status = data.location.mcp.server.list()?.find((item) => item.name === name)?.status.status
|
||||
if (status === "connected") {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
// Disable: disconnect the MCP
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LocationRef } from "@opencode-ai/client"
|
||||
import type { LocationRef } from "@opencode-ai/sdk/v2"
|
||||
import { createContext, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
|
||||
const context = createContext<Accessor<LocationRef | undefined>>()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { batch } from "solid-js"
|
||||
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
|
||||
|
||||
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
|
||||
name: "Project",
|
||||
init: () => {
|
||||
@@ -14,7 +17,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
config: "",
|
||||
worktree: "",
|
||||
directory: process.cwd(),
|
||||
}
|
||||
} satisfies Path
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
project: {
|
||||
@@ -27,26 +30,42 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
},
|
||||
workspace: {
|
||||
current: undefined as string | undefined,
|
||||
list: [] as Workspace[],
|
||||
status: {} as Record<string, WorkspaceStatus>,
|
||||
},
|
||||
})
|
||||
|
||||
async function sync() {
|
||||
const workspace = store.workspace.current
|
||||
const location = { workspace }
|
||||
const current = await sdk.api.location.get({ location })
|
||||
const directories = await sdk.api.project.directories({ projectID: current.project.id, location })
|
||||
const [instancePath, project] = await Promise.all([
|
||||
sdk.client.path.get({ workspace }),
|
||||
sdk.api.project.current({ location }),
|
||||
])
|
||||
const directories = await sdk.api.project.directories({ projectID: project.id, location })
|
||||
batch(() => {
|
||||
setStore(
|
||||
"instance",
|
||||
"path",
|
||||
reconcile({ ...defaultPath, worktree: current.project.directory, directory: current.directory }),
|
||||
)
|
||||
setStore("project", "id", current.project.id)
|
||||
setStore("project", "worktree", current.project.directory)
|
||||
setStore("instance", "path", reconcile(instancePath.data || defaultPath))
|
||||
setStore("project", "id", project.id)
|
||||
setStore("project", "worktree", project.directory)
|
||||
setStore("project", "mainDir", directories.findLast((item) => item.strategy === undefined)?.directory)
|
||||
})
|
||||
}
|
||||
|
||||
async function syncWorkspace() {
|
||||
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!listed?.data) return
|
||||
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
|
||||
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
|
||||
|
||||
batch(() => {
|
||||
setStore("workspace", "list", reconcile(listed.data))
|
||||
setStore("workspace", "status", reconcile(next))
|
||||
if (!listed.data.some((item) => item.id === store.workspace.current)) {
|
||||
setStore("workspace", "current", undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
data: store,
|
||||
project() {
|
||||
@@ -69,6 +88,19 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
||||
if (store.workspace.current === workspace) return
|
||||
setStore("workspace", "current", workspace)
|
||||
},
|
||||
list() {
|
||||
return store.workspace.list
|
||||
},
|
||||
get(workspaceID: string) {
|
||||
return store.workspace.list.find((item) => item.id === workspaceID)
|
||||
},
|
||||
status(workspaceID: string) {
|
||||
return store.workspace.status[workspaceID]
|
||||
},
|
||||
statuses() {
|
||||
return store.workspace.status
|
||||
},
|
||||
sync: syncWorkspace,
|
||||
},
|
||||
sync,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
@@ -93,7 +93,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (event.done) return new Error("Event stream disconnected")
|
||||
if ("durable" in event.value)
|
||||
log.debug("event", {
|
||||
log.info("event", {
|
||||
type: event.value.type,
|
||||
aggregateID: event.value.durable.aggregateID,
|
||||
seq: event.value.durable.seq,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
Agent,
|
||||
Command,
|
||||
Config,
|
||||
ConsoleState,
|
||||
FormatterStatus,
|
||||
LspStatus,
|
||||
McpResource,
|
||||
@@ -10,6 +11,8 @@ import type {
|
||||
Part,
|
||||
PermissionRequest,
|
||||
Provider,
|
||||
ProviderAuthMethod,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
FileDiffInfo,
|
||||
@@ -19,6 +22,11 @@ import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useProject } from "./project"
|
||||
|
||||
const emptyConsoleState: ConsoleState = {
|
||||
consoleManagedProviders: [],
|
||||
switchableOrgCount: 0,
|
||||
}
|
||||
|
||||
export const {
|
||||
context: SyncContext,
|
||||
use: useSync,
|
||||
@@ -30,6 +38,10 @@ export const {
|
||||
const [store, setStore] = createStore<{
|
||||
status: "loading" | "partial" | "complete"
|
||||
provider: Provider[]
|
||||
provider_default: Record<string, string>
|
||||
provider_next: ProviderListResponse
|
||||
console_state: ConsoleState
|
||||
provider_auth: Record<string, ProviderAuthMethod[]>
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
permission: Record<string, PermissionRequest[]>
|
||||
@@ -47,6 +59,14 @@ export const {
|
||||
}>({
|
||||
status: "complete",
|
||||
provider: [],
|
||||
provider_default: {},
|
||||
provider_next: {
|
||||
all: [],
|
||||
default: {},
|
||||
connected: [],
|
||||
},
|
||||
console_state: emptyConsoleState,
|
||||
provider_auth: {},
|
||||
agent: [],
|
||||
command: [],
|
||||
permission: {},
|
||||
|
||||
@@ -23,7 +23,7 @@ import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useKV } from "./kv"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useTuiConfig } from "../config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
@@ -4,45 +4,24 @@ import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
const id = "internal:home-footer"
|
||||
|
||||
function Directory(props: { api: TuiPluginApi; maxWidth: number }) {
|
||||
function Directory(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const destination = useHomeSessionDestination()
|
||||
const paths = useTuiPaths()
|
||||
const dir = createMemo(() => {
|
||||
const selected = destination?.destination()
|
||||
if (!selected || selected.type === "new") return
|
||||
const out = abbreviateHome(selected.directory, paths.home)
|
||||
const branch =
|
||||
selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
|
||||
return { path: abbreviateHome(selected.directory, paths.home), branch }
|
||||
if (branch) return out + ":" + branch
|
||||
return out
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={dir()}>
|
||||
{(value) => {
|
||||
const suffix = () => (value().branch ? `:${value().branch}` : "")
|
||||
const suffixWidth = () => Math.min(Bun.stringWidth(suffix()), Math.max(0, props.maxWidth - 2))
|
||||
return (
|
||||
<box flexDirection="row" minWidth={0}>
|
||||
<FilePath
|
||||
value={value().path}
|
||||
maxWidth={Math.max(2, props.maxWidth - suffixWidth())}
|
||||
fg={theme().textMuted}
|
||||
/>
|
||||
<Show when={suffix()}>
|
||||
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
|
||||
{suffix()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
)
|
||||
return <Show when={dir()}>{(value) => <text fg={theme().textMuted}>{value()}</text>}</Show>
|
||||
}
|
||||
|
||||
function Mcp(props: { api: TuiPluginApi }) {
|
||||
@@ -83,16 +62,6 @@ function Version(props: { api: TuiPluginApi }) {
|
||||
}
|
||||
|
||||
function View(props: { api: TuiPluginApi }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const mcpWidth = createMemo(() => {
|
||||
const list = props.api.state.mcp()
|
||||
if (list.length === 0) return 0
|
||||
const count = list.filter((item) => item.status === "connected").length
|
||||
return Bun.stringWidth(`⊙ ${count} MCP /status`) + 2
|
||||
})
|
||||
const directoryWidth = createMemo(() =>
|
||||
Math.max(2, dimensions().width - 8 - Bun.stringWidth(props.api.app.version) - mcpWidth()),
|
||||
)
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
@@ -104,7 +73,7 @@ function View(props: { api: TuiPluginApi }) {
|
||||
flexShrink={0}
|
||||
gap={2}
|
||||
>
|
||||
<Directory api={props.api} maxWidth={directoryWidth()} />
|
||||
<Directory api={props.api} />
|
||||
<Mcp api={props.api} />
|
||||
<box flexGrow={1} />
|
||||
<Version api={props.api} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { createMemo, For, Show, createSignal } from "solid-js"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import { Locale } from "../../util/locale"
|
||||
|
||||
const id = "internal:sidebar-files"
|
||||
|
||||
@@ -31,11 +31,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
|
||||
<For each={list()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<FilePath
|
||||
value={item.file}
|
||||
maxWidth={Math.max(2, 36 - changeCountWidth(item))}
|
||||
fg={theme().textMuted}
|
||||
/>
|
||||
<text fg={theme().textMuted} wrapMode="none">
|
||||
{Locale.truncateLeft(item.file, Math.max(2, 36 - changeCountWidth(item)))}
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={item.additions}>
|
||||
<text fg={theme().diffAdded}>+{item.additions}</text>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
|
||||
const id = "internal:sidebar-footer"
|
||||
|
||||
@@ -17,12 +16,16 @@ function View(props: { api: TuiPluginApi; directory: string }) {
|
||||
)
|
||||
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
|
||||
const show = createMemo(() => !has() && !done())
|
||||
const location = createMemo(() => {
|
||||
const path = createMemo(() => {
|
||||
const out = abbreviateHome(props.directory, paths.home)
|
||||
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
|
||||
return { path: abbreviateHome(props.directory, paths.home), branch }
|
||||
const text = branch ? out + ":" + branch : out
|
||||
const list = text.split("/")
|
||||
return {
|
||||
parent: list.slice(0, -1).join("/"),
|
||||
name: list.at(-1) ?? "",
|
||||
}
|
||||
})
|
||||
const suffix = createMemo(() => (location().branch ? `:${location().branch}` : ""))
|
||||
const suffixWidth = createMemo(() => Math.min(Bun.stringWidth(suffix()), 36))
|
||||
|
||||
return (
|
||||
<box gap={1}>
|
||||
@@ -59,19 +62,10 @@ function View(props: { api: TuiPluginApi; directory: string }) {
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" minWidth={0}>
|
||||
<FilePath
|
||||
value={location().path}
|
||||
maxWidth={Math.max(2, 38 - suffixWidth())}
|
||||
fg={theme().textMuted}
|
||||
basenameFg={theme().text}
|
||||
/>
|
||||
<Show when={suffix()}>
|
||||
<text width={suffixWidth()} wrapMode="none" truncate fg={theme().textMuted}>
|
||||
{suffix()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<text>
|
||||
<span style={{ fg: theme().textMuted }}>{path().parent}/</span>
|
||||
<span style={{ fg: theme().text }}>{path().name}</span>
|
||||
</text>
|
||||
<text fg={theme().textMuted}>
|
||||
<span style={{ fg: theme().success }}>•</span> <b>Open</b>
|
||||
<span style={{ fg: theme().text }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui"
|
||||
import type { FileDiffInfo, FileDiffLegacyInfo } from "@opencode-ai/client"
|
||||
import type { FileDiffInfo, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
TextAttributes,
|
||||
type BorderSides,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
|
||||
import { useBindings, useCommandShortcut } from "../../keymap"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import path from "path"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
@@ -44,7 +43,7 @@ const VCS_DIFF_CONTEXT_LINES = 12
|
||||
const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree"
|
||||
const KV_SINGLE_PATCH = "diff_viewer_single_patch"
|
||||
const KV_VIEW = "diff_viewer_view"
|
||||
type DiffMode = "working" | "branch" | "last-turn"
|
||||
type DiffMode = "git" | "branch" | "last-turn"
|
||||
type DiffViewerFocus = "patches" | "files"
|
||||
type DiffView = "split" | "unified"
|
||||
type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number }
|
||||
@@ -57,7 +56,7 @@ type DiffFile = {
|
||||
readonly status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
const normalizeDiffs = (diffs: readonly (FileDiffInfo | FileDiffLegacyInfo)[]): DiffFile[] =>
|
||||
const normalizeDiffs = (diffs: readonly (VcsFileDiff | FileDiffInfo | SnapshotFileDiff)[]): DiffFile[] =>
|
||||
diffs.flatMap((item) =>
|
||||
item.file
|
||||
? [
|
||||
@@ -91,7 +90,6 @@ function diffSourceLabel(mode: DiffMode) {
|
||||
|
||||
function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const sdk = useSDK()
|
||||
const themeState = useTheme()
|
||||
const theme = () => props.api.theme.current
|
||||
const params = () =>
|
||||
@@ -103,7 +101,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
returnRoute?: TuiRouteCurrent
|
||||
}
|
||||
| undefined
|
||||
const mode = () => params()?.mode ?? "working"
|
||||
const mode = () => params()?.mode ?? "git"
|
||||
const diffInput = createMemo(() => {
|
||||
const sessionID = params()?.sessionID
|
||||
return {
|
||||
@@ -124,12 +122,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
return normalizeDiffs(result.data ?? [])
|
||||
}
|
||||
|
||||
const result = await sdk.api.vcs.diff(
|
||||
{
|
||||
location: input.directory ? { directory: input.directory } : undefined,
|
||||
mode: input.mode,
|
||||
context: VCS_DIFF_CONTEXT_LINES,
|
||||
},
|
||||
const result = await props.api.client.vcs.diff(
|
||||
{ directory: input.directory, mode: input.mode, context: VCS_DIFF_CONTEXT_LINES },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
return normalizeDiffs(result.data ?? [])
|
||||
})
|
||||
@@ -691,7 +686,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
return [
|
||||
{
|
||||
title: "Working tree",
|
||||
value: "working" as const,
|
||||
value: "git" as const,
|
||||
description: "Show current git changes",
|
||||
},
|
||||
...(vcs?.branch && vcs.default_branch && vcs.branch !== vcs.default_branch
|
||||
@@ -1065,7 +1060,7 @@ const tui: TuiPlugin = async (api) => {
|
||||
namespace: "palette",
|
||||
run() {
|
||||
api.route.navigate(ROUTE, {
|
||||
mode: "working",
|
||||
mode: "git",
|
||||
sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined,
|
||||
returnRoute: api.route.current,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from "@opentui/keymap/extras"
|
||||
import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { useTuiConfig } from "./config/v1"
|
||||
import { TuiKeybind } from "./config/v1/keybind"
|
||||
import { useTuiConfig } from "./config"
|
||||
import { TuiKeybind } from "./config/keybind"
|
||||
|
||||
export const LEADER_TOKEN = "leader"
|
||||
export const OPENCODE_BASE_MODE = "base"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiConfig } from "../config/v1"
|
||||
import type { TuiConfig } from "../config"
|
||||
import type { useEvent } from "../context/event"
|
||||
import type { useRoute } from "../context/route"
|
||||
import type { useSDK } from "../context/sdk"
|
||||
@@ -100,7 +100,7 @@ function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
|
||||
function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
|
||||
return {
|
||||
get ready() {
|
||||
return true
|
||||
return sync.ready
|
||||
},
|
||||
get config() {
|
||||
return sync.data.config
|
||||
@@ -120,7 +120,7 @@ function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useD
|
||||
},
|
||||
session: {
|
||||
count() {
|
||||
return data.session.list().length
|
||||
return sync.data.session.length
|
||||
},
|
||||
get(sessionID) {
|
||||
return sync.session.get(sessionID)
|
||||
@@ -150,19 +150,13 @@ function stateApi(sync: ReturnType<typeof useSync>, data: ReturnType<typeof useD
|
||||
return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
|
||||
},
|
||||
mcp() {
|
||||
return (data.location.mcp.server.list() ?? [])
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.flatMap((item) =>
|
||||
item.status.status === "pending"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: item.name,
|
||||
status: item.status.status,
|
||||
error: item.status.status === "failed" ? item.status.error : undefined,
|
||||
},
|
||||
],
|
||||
)
|
||||
return Object.entries(sync.data.mcp)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, item]) => ({
|
||||
name,
|
||||
status: item.status,
|
||||
error: item.status === "failed" ? item.error : undefined,
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Legacy `api.command` bridge for v1 plugins; remove in v2.
|
||||
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { TuiKeybind } from "../config/v1/keybind"
|
||||
import { TuiKeybind } from "../config/keybind"
|
||||
import type { DialogContext } from "../ui/dialog"
|
||||
|
||||
const COMMAND_PALETTE_SHOW = "command.palette.show"
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
TuiPluginInstallResult,
|
||||
TuiPluginStatus,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import type { TuiConfig } from "../config/v1"
|
||||
import type { TuiConfig } from "../config"
|
||||
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { createPluginRoutes } from "./api"
|
||||
import { createSlots, type HostSlots } from "./slots"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import type { SessionPromptInput } from "@opencode-ai/client"
|
||||
import type { SessionPromptInput } from "@opencode-ai/client/promise"
|
||||
import type { Types } from "effect"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prompt, type PromptRef } from "../component/prompt"
|
||||
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useRouteData } from "../context/route"
|
||||
@@ -9,7 +10,7 @@ import { useLocal } from "../context/local"
|
||||
import { usePluginRuntime } from "../plugin/runtime"
|
||||
import { useEditorContext } from "../context/editor"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useTuiConfig } from "../config/v1"
|
||||
import { useTuiConfig } from "../config"
|
||||
import { HomeSessionDestinationProvider } from "./home/session-destination"
|
||||
import { useData } from "../context/data"
|
||||
import { LocationProvider } from "../context/location"
|
||||
@@ -23,6 +24,7 @@ const placeholder = {
|
||||
|
||||
export function Home() {
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const sync = useSync()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const [ref, setRef] = createSignal<PromptRef | undefined>()
|
||||
@@ -59,12 +61,12 @@ export function Home() {
|
||||
once = true
|
||||
}
|
||||
|
||||
// Wait for the model store to be ready before auto-submitting --prompt.
|
||||
// Wait for sync and model store to be ready before auto-submitting --prompt
|
||||
createEffect(() => {
|
||||
const r = ref()
|
||||
if (sent) return
|
||||
if (!r) return
|
||||
if (!local.model.ready) return
|
||||
if (!sync.ready || !local.model.ready) return
|
||||
if (!args.prompt) return
|
||||
if (r.current.text !== args.prompt) return
|
||||
sent = true
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user