Compare commits

...
21 Commits
Author SHA1 Message Date
Adam 1ead17547b fix(core): name hosted provider opencode web search (#48001) 2026-09-08 12:28:12 -05:00
Aiden Cline 0c1bf08ca6 feat(core): add Poe browser OAuth (#47883) 2026-09-08 12:15:17 -05:00
Aiden Cline 8b09f6415a refactor(codemode): export runtime value classes as Values (#48000) 2026-09-08 12:11:34 -05:00
Adam e791afdfa3 feat(core): add console web search (#47293) 2026-09-08 12:08:53 -05:00
Dax Raad e655fed6c3 fix(updates): reject retired next channel 2026-09-08 13:06:08 -04:00
ccbc018072 fix(tui): honor configured worktree strategy (#47991)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-08 12:46:57 -04:00
James Long 4432956490 fix(worktree): accept the strategy's returned directory (#47997) 2026-09-08 12:45:57 -04:00
Dax Raad 375bf4908f feat(updates): configure per-channel gradual rollouts 2026-09-08 12:34:59 -04:00
James Long cc6bff39a0 feat(tui): manage worktrees and explicitly move sessions (#47984) 2026-09-08 12:20:25 -04:00
James Long 8a5709324f fix(worktree): resolve configured paths relative to project (#47990) 2026-09-08 12:03:53 -04:00
James Long be58ca806c fix(cli): recover dev hot reloads and preserve routes (#47979) 2026-09-08 11:19:51 -04:00
Dax 9e42e5cc4c feat(core): refresh console provider config periodically (#47980) 2026-09-08 11:17:12 -04:00
opencode-agent[bot] c2a1649dd4 chore: update nix node_modules hashes 2026-09-08 14:19:25 +00:00
James Long ded9c7e505 feat(cli): add Vite-powered TUI development entrypoint (#47950) 2026-09-08 10:00:16 -04:00
f9bc2233dd fix(app): align desktop agent and model switching (#47286)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-08 18:16:08 +08:00
Simon Klee 7487999e06 tabs: add compact session tab rail (#47938) 2026-09-08 12:11:16 +02:00
Simon Klee 2eea36e731 mini: add more minimal output presets. (#47931) 2026-09-08 11:53:02 +02:00
Simon Klee 4fef8edbe8 mini: add clear-screen command (#47928) 2026-09-08 11:24:36 +02:00
Simon Klee 50c552f763 tui: add tool filtering option to Markdown exports (#47929) 2026-09-08 11:24:29 +02:00
Luke Parker a3d5923aca fix(session-ui): stop refetching missing shell output (#47926) 2026-09-08 09:12:15 +00:00
Luke Parker ea2c0184ce fix(app): release attachment blobs when no draft references them (#47922) 2026-09-08 09:11:59 +00:00
112 changed files with 4812 additions and 900 deletions
+1
View File
@@ -168,6 +168,7 @@
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
"solid-refresh": "0.6.3",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-EKhY3iZDrbNrBhntWpSdtLcmNLte6yVBxpIrCxr1uNM=",
"aarch64-linux": "sha256-0OjDGZHgcnnk6IxkfK6ogeeqsGTqY/dcaZ/XzT23sgA=",
"aarch64-darwin": "sha256-Zk51gnOicaLtPuqCYfgARhm2TjL222w1Y0Em288o0YY=",
"x86_64-darwin": "sha256-hvDZ9zCV6zOSx6i7JZ1kVUMht+JI/jc8/y+aYrNHQ1E="
"x86_64-linux": "sha256-/5VErB3NjnKi0/LHqqJgcDadD9woNLMZZYxUjriRvJI=",
"aarch64-linux": "sha256-CTXqFEvQIiKDe0OmtdkdY8KLQtQOxdsJNCom/Clzc1c=",
"aarch64-darwin": "sha256-vF2+/jgWhF1Smef9U3nSpTS3RI5ZcriV0mjg1q9s8YM=",
"x86_64-darwin": "sha256-suCQ+yDT048D3EbjFAzplyZedYZYAihseLkqg6c+wHc="
}
}
+2
View File
@@ -9,6 +9,8 @@
"scripts": {
"dev": "bun run --cwd packages/cli src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -1,6 +1,25 @@
import { expect, test } from "bun:test"
import type { Page, Route } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { createMockServerHandler, mockOpenCodeServer } from "../../utils/mock-server"
test("serves an empty config document list for composer defaults", async () => {
const server = createMockServerHandler({
provider: {},
directory: "C:/OpenCode",
project: {},
sessions: [],
pageMessages: () => ({ items: [] }),
})
try {
const response = await server.handler(
new Request("http://localhost/api/config?location%5Bdirectory%5D=C%3A%2FOpenCode"),
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual([])
} finally {
await server.dispose()
}
})
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
@@ -342,6 +342,7 @@ async function mockServers(
if (route.request().method() === "GET" && sessionPermission)
return json(route, { data: options.sessionPending?.[sessionPermission[1]!] ?? [] })
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
@@ -602,6 +602,7 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback()
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/config") return json(route, [])
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
+1
View File
@@ -39,6 +39,7 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(HttpApiEndpoint.get("reference", "/api/reference", { success: Json }))
.add(HttpApiEndpoint.get("config", "/api/config", { success: Json }))
.add(HttpApiEndpoint.get("agent", "/api/agent", { success: Json }))
.add(HttpApiEndpoint.get("provider", "/api/provider", { success: Json }))
.add(HttpApiEndpoint.get("model", "/api/model", { success: Json }))
+1
View File
@@ -207,6 +207,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
)
.handleAll({
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
config: () => Effect.succeed([]),
reference: () =>
Effect.succeed({
location: {
+68 -38
View File
@@ -1,4 +1,5 @@
import { batch, type Accessor, createMemo, startTransition } from "solid-js"
import { batch, type Accessor, createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import type { ComposerControls } from "./adapter"
import { useLayout } from "@/shell/state/layout"
import { useLocal, type ModelKey, type ModelSelection } from "@/providers/models/selection"
@@ -9,6 +10,7 @@ import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useModels } from "@/providers/models/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/providers/models/variant"
import { useComposerState } from "./persistence"
import { useConfiguredModel } from "@/providers/models/configured"
export function createComposerControls(input: { sessionKey: Accessor<string>; model?: ModelSelection }) {
const layout = useLayout()
@@ -31,6 +33,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
!(input.model ?? local.model).ready() ||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
!providers.ready(),
},
@@ -43,15 +46,31 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
}
export function createComposerModelSelection(input: {
agent: () => { model?: ModelKey; variant?: string } | undefined
agent: () => { name: string; model?: ModelKey; variant?: string } | undefined
}) {
const sdk = useWorkspaceLocation()
const models = useModels()
const local = useLocal()
const prompt = useComposerState()
const configuredModel = useConfiguredModel()
const [remembered, setRemembered] = createStore<Record<string, ModelKey | undefined>>({})
createEffect(
on(
() => input.agent()?.name,
(name, previous) => {
if (!name || !previous || name === previous) return
batch(() => {
const model = prompt.model.current()
setRemembered(previous, model ? { providerID: model.providerID, modelID: model.modelID } : undefined)
prompt.model.set(remembered[name] ? { ...remembered[name] } : undefined)
})
},
),
)
const providers = useProviders(() => sdk().directory)
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const valid = (model: ModelKey) => {
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
const provider = providers.all().get(model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
@@ -62,7 +81,8 @@ export function createComposerModelSelection(input: {
return modelID ? [{ providerID: provider.id, modelID }] : []
})[0]
const current = () => {
const key = [prompt.model.current(), input.agent()?.model, recent(), fallback()].find(
if (!configuredModel.ready()) return
const key = [prompt.model.current(), input.agent()?.model, configuredModel(), recent(), fallback()].find(
(item): item is ModelKey => !!item && valid(item),
)
return key ? models.find(key) : undefined
@@ -74,7 +94,9 @@ export function createComposerModelSelection(input: {
.filter((item): item is NonNullable<typeof item> => !!item),
)
const selection = {
ready: models.ready,
trackSessionCommit: local.model.trackSessionCommit,
remembered: () => Object.fromEntries(Object.entries(remembered).map(([name, model]) => [name, { model }])),
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
current,
recent: recentModels,
list: models.list,
@@ -83,19 +105,24 @@ export function createComposerModelSelection(input: {
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
if (index === -1) return
const next = items[(index + direction + items.length) % items.length]
const next =
items[
index === -1 ? (direction === 1 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
]
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
void startTransition(() =>
batch(() => {
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
}),
)
batch(() => {
if (item && !valid(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
prompt.model.set(
item ? { ...item, variant: same ? (selection.variant.current() ?? null) : undefined } : undefined,
)
if (!item) return
models.setVisibility(item, true)
if (options?.recent) models.recent.push(item)
})
},
visible: models.visible,
setVisibility: models.setVisibility,
@@ -104,38 +131,41 @@ export function createComposerModelSelection(input: {
const item = input.agent()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
},
selected() {
return prompt.model.current()?.variant
const model = prompt.model.current()
return model && valid(model) ? model.variant : undefined
},
current() {
const resolved = resolveModelVariant({
const model = current()
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
return Object.keys(current()?.variants ?? {})
},
set(value: string | undefined) {
void startTransition(() =>
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
}),
)
batch(() => {
const model = current()
if (!model) return
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
},
cycle() {
const variants = this.list()
@@ -143,13 +173,13 @@ export function createComposerModelSelection(input: {
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
selected: this.current() ?? null,
configured: undefined,
}),
)
},
},
} satisfies ModelSelection
}
return selection
return selection satisfies ModelSelection
}
+152 -6
View File
@@ -91,6 +91,8 @@ function session(input: {
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
command?: ComposerSession["api"]["command"]
switchAgent?: ComposerSession["api"]["switchAgent"]
switchModel?: ComposerSession["api"]["switchModel"]
}): ComposerSession {
return {
id: "session-1",
@@ -99,12 +101,16 @@ function session(input: {
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
switchAgent: async () => {
input.calls.push("switch-agent")
},
switchModel: async () => {
input.calls.push("switch-model")
},
switchAgent:
input.switchAgent ??
(async () => {
input.calls.push("switch-agent")
}),
switchModel:
input.switchModel ??
(async () => {
input.calls.push("switch-model")
}),
shell: input.shell ?? (async () => undefined),
command: input.command ?? (async () => undefined),
},
@@ -122,6 +128,146 @@ function session(input: {
}
describe("Composer submission", () => {
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const agent = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const committed = Promise.withResolvers<void>()
const completed = Promise.withResolvers<void>()
const target = session({
calls,
prompt: async () => {
throw new Error("command must not call prompt")
},
switchAgent: async (request) => {
expect(request.agent).toBe("build")
calls.push("agent")
started.resolve()
await agent.promise
},
switchModel: async (request) => {
expect(request.model).toEqual({ providerID: "provider-1", id: "model-1", variant: "balanced" })
calls.push("model")
await committed.promise
},
command: async (request) => {
expect(request).toMatchObject({ command: "review", text: "changes", delivery: "steer" })
expect(request).not.toHaveProperty("model")
expect(request).not.toHaveProperty("agent")
calls.push("command")
completed.resolve()
},
})
selected.model.selection = {
...selection,
trackSessionCommit: (_id, value) => {
expect(value).toEqual({
agent: "build",
model: { providerID: "provider-1", modelID: "model-1" },
variant: "balanced",
})
calls.push("track")
return () => calls.push("cancel")
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter, undefined, "normal", () => [{ name: "review" }]).submit(new Event("submit"))
await started.promise
expect(calls).toEqual(["track", "agent"])
selected.agents.current = "plan"
selected.model.selection = { ...selection, variant: { ...selection.variant, current: () => "high" } }
agent.resolve()
committed.resolve()
await completed.promise
expect(calls).toEqual(["track", "agent", "model", "command"])
})
test("commits the model even when cached session state already matches", async () => {
const state = createMemoryComposerState({ prompt: "continue" }).capture()
const calls: string[] = []
const done = Promise.withResolvers<void>()
const target = session({
calls,
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
prompt: async () => done.resolve(),
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(adapter).submit(new Event("submit"))
await done.promise
expect(calls).toEqual(["switch-model", "prompt"])
})
test("cancels selection tracking and does not execute a command when selection fails", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
const selected = controls()
const failed = Promise.withResolvers<unknown>()
const error = new Error("model unavailable")
const target = session({
calls,
prompt: async () => {
calls.push("prompt")
},
switchModel: async () => {
throw error
},
command: async () => {
calls.push("command")
},
})
selected.model.selection = {
...selection,
trackSessionCommit: () => {
calls.push("track")
return () => {
calls.push("cancel")
}
},
}
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls: () => selected,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
await submitInput(
adapter,
{ missingSelection() {}, failed: (_kind, error) => failed.resolve(error) },
"normal",
() => [{ name: "review" }],
).submit(new Event("submit"))
expect(await failed.promise).toBe(error)
expect(calls).toEqual(["track", "switch-agent", "cancel"])
expect(state.current()[0]).toMatchObject({ content: "/review changes" })
})
test("submits a slash skill with its trailing text and attachments", async () => {
const state = createMemoryComposerState({ prompt: "/show-me explain " }).capture()
state.set([
+38 -23
View File
@@ -11,6 +11,7 @@ import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { blobDataUrl } from "@/runtime/persistence/drafts"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -88,7 +89,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value).then(
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -124,9 +125,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(session, { ...value, delivery: "steer" }, command).catch((error) =>
failSubmission(input, session, "command", error, restore, value.id),
)
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
return
}
} finally {
@@ -315,8 +319,10 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -328,7 +334,33 @@ async function sendCommand(
})
}
async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
async function applySelection(
session: ComposerSession,
selection: ComposerSelection,
track?: ModelSelection["trackSessionCommit"],
) {
const cancel = track?.(session.id, selection)
try {
const current = session.current()
if (current?.agent !== selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
}
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
await session.api.switchModel({
sessionID: session.id,
model: { id: selection.model.modelID, providerID: selection.model.providerID, variant: selection.variant },
})
} catch (error) {
cancel?.()
throw error
}
}
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
@@ -336,24 +368,7 @@ async function sendPrompt(session: ComposerSession, value: ComposerSubmission) {
// waits behind, so it runs with the session selection at delivery time (the
// intended selection stays recorded in its metadata).
if (value.delivery === "steer") {
const current = session.current()
if (current?.agent !== value.selection.agent) {
await session.api.switchAgent({ sessionID: session.id, agent: value.selection.agent })
}
if (
current?.model?.providerID !== value.selection.model.providerID ||
current.model.id !== value.selection.model.modelID ||
(current.model.variant ?? "default") !== (value.selection.variant ?? "default")
) {
await session.api.switchModel({
sessionID: session.id,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
})
}
await applySelection(session, value.selection, track)
}
const admission = {
@@ -110,6 +110,7 @@ export function createNewSessionComposerAdapter(props: {
agent: selection.agent,
model: selection.model,
variant: selection.variant ?? null,
choices: model.remembered(),
})
if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id })
submission.retarget(
@@ -0,0 +1,28 @@
import { createEffect, createMemo, on } from "solid-js"
import { useData } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
export function useConfiguredModel() {
const data = useData()
const location = useWorkspaceLocation()
const server = useServerSDK()
createEffect(
on(
() => [location().directory, server.connection.status()] as const,
([directory]) => {
void data.location.config.sync({ directory }).catch(() => undefined)
},
),
)
const documents = () => data.location.config.list({ directory: location().directory })
const model = createMemo(() => {
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
const model = entry?.type === "document" ? entry.info.model : undefined
if (!model) return
if (typeof model !== "string") return { providerID: model.providerID, modelID: model.model, variant: model.variant }
const [providerID, ...parts] = model.split("/")
return { providerID, modelID: parts.join("/"), variant: undefined }
})
return Object.assign(model, { ready: () => documents() !== undefined })
}
+2 -2
View File
@@ -122,10 +122,10 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
setStore("variant", { [key]: value })
setStore("variant", { [key]: value ?? "default" })
return
}
setStore("variant", key, value)
setStore("variant", key, value ?? "default")
}
return {
@@ -1,7 +1,7 @@
import { Popover } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/providers/models/selection"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useDialog } from "@opencode/ui/context/dialog"
import { popularProviders } from "@/providers/catalog/providers"
import { Button } from "@opencode/ui/button"
@@ -27,7 +27,7 @@ import "@/settings/settings.css"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelState = ModelSelection
type ModelItem = ReturnType<ModelState["list"]>[number]
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
+144 -83
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode/ui/context"
import { base64Encode } from "@opencode/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Schema, SchemaGetter } from "effect"
import { useModels } from "@/providers/models/models"
@@ -16,6 +16,7 @@ import { useData } from "@/runtime/server/current"
import { normalizeAgentList } from "@/runtime/server/global-sync/utils"
import { useServerSDK } from "@/runtime/server/client"
import { ScopedKey, type ServerScope } from "@/runtime/server/scope"
import { useConfiguredModel } from "./configured"
const ModelKeySchema = Schema.Struct({
providerID: Schema.String,
@@ -24,11 +25,15 @@ const ModelKeySchema = Schema.Struct({
})
export type ModelKey = typeof ModelKeySchema.Type
const StateSchema = Schema.Struct({
agent: Persistence.optional(Schema.String),
const ChoiceSchema = Schema.Struct({
model: Persistence.optional(ModelKeySchema),
variant: Persistence.optional(Schema.NullOr(Schema.String)),
})
const StateSchema = Schema.Struct({
...ChoiceSchema.fields,
agent: Persistence.optional(Schema.String),
choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)),
})
type State = typeof StateSchema.Type
const SessionsSchema = Schema.Record(
@@ -78,6 +83,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
const configuredModel = useConfiguredModel()
const id = createMemo(() => params.id || undefined)
const list = createMemo(() =>
@@ -98,16 +104,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
current?: string
draft?: State
promoting?: State
last?: {
type: "agent" | "model" | "variant"
agent?: string
model?: ModelKey | null
variant?: string | null
}
}>({
current: list()[0]?.name,
draft: undefined,
last: undefined,
})
const validModel = (model: ModelKey) => {
@@ -176,13 +175,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const fallback = createMemo<ModelKey | undefined>(() => recentModel() ?? defaultModel())
const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel))
const durable = () => {
const session = id()
return session ? data.session.get(session) : undefined
}
const agent = {
list,
visible: agentsVisible,
current() {
return pickAgent(agentsVisible() ? (scope()?.agent ?? store.current) : "build")
return pickAgent(scope()?.agent ?? durable()?.agent ?? (agentsVisible() ? store.current : "build"))
},
set(name: string | undefined) {
const item = pickAgent(name)
@@ -192,25 +195,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
batch(() => {
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
variant: item.variant ?? null,
})
const previous = snapshot()
if (previous.agent === item.name) return
const prev = scope()
const choices = {
...prev?.choices,
...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}),
}
setStore("current", item.name)
const next = {
agent: item.name,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
model: choices[item.name]?.model,
variant: choices[item.name]?.variant,
choices,
} satisfies State
const session = id()
if (session) {
setSaved("session", session, next)
return
}
setStore("draft", next)
write(next)
// Pin both choices while the agent and model acknowledgments arrive separately.
const selected = current()
if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id })
})
},
move(direction: 1 | -1) {
@@ -230,8 +232,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const current = createMemo(() => {
if (!configuredModel.ready()) return
const item = firstModel(
() => scope()?.model,
() => {
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const model = session?.model
return model && { providerID: model.providerID, modelID: model.id }
},
() => agent.current()?.model,
fallback,
)
@@ -243,26 +252,41 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const item = agent.current()
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
const global = configuredModel()
return (
getConfiguredAgentVariant({
agent: { model: item.model, variant: item.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
}) ??
getConfiguredAgentVariant({
agent: { model: global, variant: global?.variant },
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
})
)
}
const selected = () => scope()?.variant
const selected = () => {
const draft = scope()
if (draft?.model && validModel(draft.model)) return draft.variant
const session = durable()
if (session?.agent && session.agent !== agent.current()?.name) return
const value = session?.model
if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null
}
const snapshot = () => {
const model = current()
const selected = current()
return {
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
variant: selected(),
model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined,
variant: selected ? (model.variant.current() ?? null) : undefined,
} satisfies State
}
const write = (next: Partial<State>) => {
const state = {
...(scope() ?? { agent: agent.current()?.name }),
...scope(),
agent: agent.current()?.name,
...next,
} satisfies State
@@ -274,22 +298,59 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setStore("draft", state)
}
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean))
const pending = new Map<string, State>()
const sameSelection = (a: State, b: State) =>
a.agent === b.agent &&
a.model?.providerID === b.model?.providerID &&
a.model?.modelID === b.model?.modelID &&
(a.variant ?? "default") === (b.variant ?? "default")
const reconcile = (sessionID: string) => {
const expected = pending.get(sessionID)
const session = data.session.get(sessionID)
if (!expected || !session?.model) return
if (
!sameSelection(expected, {
agent: session.agent,
model: { providerID: session.model.providerID, modelID: session.model.id },
variant: session.model.variant,
})
)
return
pending.delete(sessionID)
const draft = saved.session[sessionID]
if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return
setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined })
}
onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID)))
onCleanup(
serverSDK.event.on("session.deleted", (event) => {
pending.delete(event.data.sessionID)
setSaved("session", event.data.sessionID, undefined)
}),
)
const model = {
ready: models.ready,
ready: Object.assign(() => models.ready() && configuredModel.ready(), { promise: models.ready.promise }),
current,
recent,
list: models.list,
trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) {
pending.set(sessionID, selection)
reconcile(sessionID)
return () => {
if (pending.get(sessionID) === selection) pending.delete(sessionID)
}
},
cycle(direction: 1 | -1) {
const items = recent()
const item = current()
if (!item) return
const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id)
if (index === -1) return
let next = index + direction
let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction
if (next < 0) next = items.length - 1
if (next >= items.length) next = 0
@@ -298,21 +359,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model.set({ providerID: entry.provider.id, modelID: entry.id })
},
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
startTransition(() =>
batch(() => {
setStore("last", {
type: "model",
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
})
write({ model: item })
if (!item) return
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
}),
)
batch(() => {
if (item && !validModel(item)) return
const previous = current()
const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID
write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined })
if (!item) return
// A session draft owns its variant even when preferences change in another session.
if (id() && !same) write({ variant: model.variant.current() ?? null })
models.setVisibility(item, true)
if (!options?.recent) return
models.recent.push(item)
})
},
visible(item: ModelKey) {
return models.visible(item)
@@ -324,16 +382,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
configured,
selected,
current() {
const resolved = resolveModelVariant({
const model = current()
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
preferred: model ? models.variant.get({ providerID: model.provider.id, modelID: model.id }) : undefined,
})
if (resolved) return resolved
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
if (saved && this.list().includes(saved)) return saved
},
list() {
const item = current()
@@ -341,21 +396,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return Object.keys(item.variants)
},
set(value: string | undefined) {
startTransition(() =>
batch(() => {
const model = current()
setStore("last", {
type: "variant",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
})
write({ variant: value ?? null })
if (model) {
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value ?? undefined)
}
}),
)
batch(() => {
const model = current()
if (!model) return
write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null })
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
})
},
cycle() {
const items = this.list()
@@ -363,8 +409,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
this.set(
cycleModelVariant({
variants: items,
selected: this.selected(),
configured: this.configured(),
selected: this.current() ?? null,
configured: undefined,
}),
)
},
@@ -383,20 +429,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
// Creation already owns the active selection; keep only agent memory once it is in the read model.
// Otherwise a first-message command's configured overrides would stay hidden behind this handoff.
const created = data.session.get(session)
const selection = created?.model
const committed =
selection &&
sameSelection(next, {
agent: created.agent,
model: { providerID: selection.providerID, modelID: selection.id },
variant: selection.variant,
})
? { choices: next.choices }
: next
const key = handoffKey(serverSDK.scope, dir, session)
handoff.set(key, next)
handoff.set(key, committed)
if (dir === sdk().directory) {
setSaved("session", session, next)
setSaved("session", session, committed)
}
setStore("promoting", next)
setStore("promoting", committed)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (durable()?.model) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
@@ -412,4 +472,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
})
export type ModelSelection = ReturnType<typeof useLocal>["model"]
export type ModelSelection = Omit<ReturnType<typeof useLocal>["model"], "trackSessionCommit"> &
Partial<Pick<ReturnType<typeof useLocal>["model"], "trackSessionCommit">>
+2 -2
View File
@@ -6,13 +6,13 @@ import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { useTheme } from "@opencode/ui/theme"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/providers/models/selection"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
import { useIntegrations } from "@/providers/catalog/integrations"
import { decode64 } from "@/runtime/persistence/base64"
import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelState = ModelSelection
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
@@ -64,14 +64,14 @@ describe("model variant", () => {
expect(value).toBe("xhigh")
})
test("wraps from configured last variant to first", () => {
test("cycles from configured last variant to default", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "xhigh",
})
expect(value).toBe("low")
expect(value).toBeUndefined()
})
test("cycles from an explicit default to the first variant", () => {
@@ -83,4 +83,22 @@ describe("model variant", () => {
expect(value).toBe("low")
})
test("prefers a saved variant to configuration, including explicit Default", () => {
const input = { variants: ["low", "high"], selected: undefined, configured: "high" }
expect(resolveModelVariant({ ...input, preferred: "low" })).toBe("low")
expect(resolveModelVariant({ ...input, preferred: "default" })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: null })).toBeUndefined()
expect(resolveModelVariant({ ...input, preferred: "low", selected: "high" })).toBe("high")
expect(cycleModelVariant({ ...input, preferred: "high" })).toBeUndefined()
expect(cycleModelVariant({ ...input, preferred: "default" })).toBe("low")
})
test("normalizes unavailable selections instead of silently applying another variant", () => {
expect(resolveModelVariant({ variants: ["low"], selected: "high", configured: "low" })).toBeUndefined()
expect(
resolveModelVariant({ variants: ["low"], selected: undefined, preferred: "high", configured: "low" }),
).toBeUndefined()
expect(cycleModelVariant({ variants: [], selected: undefined, configured: undefined })).toBeUndefined()
})
})
+5 -17
View File
@@ -16,6 +16,7 @@ type VariantInput = {
variants: string[]
selected: string | null | undefined
configured: string | undefined
preferred?: string
}
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
@@ -24,29 +25,16 @@ export function getConfiguredAgentVariant(input: { agent: Agent | undefined; mod
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}
export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
const value = input.selected ?? input.preferred ?? input.configured
return value && value !== "default" && input.variants.includes(value) ? value : undefined
}
export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
if (input.configured && input.variants.includes(input.configured)) {
const index = input.variants.indexOf(input.configured)
if (index === input.variants.length - 1) return input.variants[0]
return input.variants[index + 1]
}
return input.variants[0]
const current = resolveModelVariant(input)
return input.variants[current ? input.variants.indexOf(current) + 1 : 0]
}
@@ -230,3 +230,72 @@ describe("draft store text externalization", () => {
expect(JSON.parse(memory.documents.get("doc")!).prompt[0].content.blob.ids).toHaveLength(1)
})
})
describe("draft store image retention", () => {
const image = (byte: number) => new Blob([new Uint8Array(6).fill(byte)], { type: "image/png" })
const fresh = (grace = 0) => {
const memory = memoryDriver()
return { memory, store: createDraftStore(memory.driver, { grace }) }
}
// Release timers fire on the macrotask queue; a zero grace has fired after one tick.
const tick = () => new Promise((resolve) => setTimeout(resolve, 5))
// An image with no object URL left gets a new one when its bytes are uploaded again.
const released = async (store: ReturnType<typeof createDraftStore>, byte: number, url: string) =>
(await store.putBlob(image(byte))).url !== url
test("an uploaded image no document references is released after the grace", async () => {
const { store } = fresh()
const orphan = await store.putBlob(image(1))
await tick()
expect(await released(store, 1, orphan.url)).toBe(true)
})
test("an image referenced within the grace is kept", async () => {
const { store } = fresh(50)
const pasted = await store.putBlob(image(2))
await store.setDocument("pinned", { prompt: [{ type: "image", blob: pasted }] })
await new Promise((resolve) => setTimeout(resolve, 80))
expect(await released(store, 2, pasted.url)).toBe(false)
})
test("saving a document without an image or removing the document releases it", async () => {
const { store } = fresh()
const dropped = await store.putBlob(image(3))
const removed = await store.putBlob(image(4))
await store.setDocument("edited", { prompt: [{ type: "image", blob: dropped }] })
await store.setDocument("closed", { prompt: [{ type: "image", blob: removed }] })
await tick()
expect(await released(store, 3, dropped.url)).toBe(false)
expect(await released(store, 4, removed.url)).toBe(false)
await store.setDocument("edited", { prompt: [{ type: "text", content: "typed over it" }] })
await store.removeItem("closed")
await tick()
expect(await released(store, 3, dropped.url)).toBe(true)
expect(await released(store, 4, removed.url)).toBe(true)
})
test("an image referenced by two documents survives until both drop it", async () => {
const { store } = fresh()
const shared = await store.putBlob(image(5))
await store.setDocument("composer", { prompt: [{ type: "image", blob: shared }] })
await store.setDocument("history", { entries: [{ prompt: [{ type: "image", blob: shared }] }] })
await store.setDocument("composer", { prompt: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(false)
await store.setDocument("history", { entries: [] })
await tick()
expect(await released(store, 5, shared.url)).toBe(true)
})
test("loading a document pins the images it references", async () => {
const { memory, store } = fresh()
const id = await memory.driver.putBlob(image(6))
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
await tick()
expect(await released(store, 6, url)).toBe(false)
await store.removeItem("loaded")
await tick()
expect(await released(store, 6, url)).toBe(true)
})
})
+84 -21
View File
@@ -28,23 +28,77 @@ export const draftTextThreshold = 16 * 1024
export const draftTextChunk = 64 * 1024
const textCacheLimit = 64
const urls = new Map<string, string>()
// The object URL already pins the Blob for the page's lifetime; keeping the Blob itself lets a
// collected image be uploaded again without fetching the URL.
const held = new Map<string, Blob>()
// Decoded image bytes the renderer pins through object URLs. Every consumer of a `blob.url` is a
// persisted draft document (composer prompt, prompt history), so an image is pinned exactly while a
// stored document references it. Once the last reference disappears (removed from a draft, sent, or
// a discarded duplicate paste) the URL is revoked after this grace, which covers the persist delay
// between a paste and the save that references it, and the submit → history handoff.
export const retainedBlobGrace = 30_000
type Retained = { blob: Blob; url: string; release: ReturnType<typeof setTimeout> | undefined }
const retained = new Map<string, Retained>()
// Document keys that reference each image id; an id with no keys is released after the grace.
const refs = new Map<string, Set<string>>()
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
// ones); live references still carry the original.
const aliases = new Map<string, string>()
function blobUrl(id: string, blob: Blob) {
const existing = urls.get(id)
if (existing) return existing
function blobUrl(id: string, blob: Blob, grace?: number) {
const existing = retained.get(id)
if (existing) return existing.url
const url = URL.createObjectURL(blob)
urls.set(id, url)
held.set(id, blob)
// Without a grace the image has no store to reference it from and stays for the page's lifetime.
const release = grace === undefined || refs.get(id)?.size ? undefined : setTimeout(() => revoke(id), grace)
retained.set(id, { blob, url, release })
return url
}
// Record which image ids `key` now references; ids it dropped are released once no other document
// references them, ids it gained stay pinned.
function retain(key: string, ids: ReadonlySet<string>, grace: number) {
for (const [id, keys] of refs) {
if (ids.has(id) || !keys.delete(key) || keys.size) continue
refs.delete(id)
const entry = retained.get(id)
if (entry) entry.release = setTimeout(() => revoke(id), grace)
}
for (const id of ids) {
const keys = refs.get(id) ?? new Set<string>()
keys.add(key)
refs.set(id, keys)
const entry = retained.get(id)
if (!entry) continue
clearTimeout(entry.release)
entry.release = undefined
}
}
function revoke(id: string) {
const entry = retained.get(id)
if (!entry) return
URL.revokeObjectURL(entry.url)
retained.delete(id)
for (const [from, to] of aliases) if (to === id) aliases.delete(from)
}
// Image ids a document references: `{ blob: { id } }` parts, not text chunk lists.
function imageIDs(value: unknown, into = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
value.forEach((entry) => imageIDs(entry, into))
return into
}
if (!value || typeof value !== "object") return into
const item = value as Record<string, unknown>
const blob = item.blob
if (blob && typeof blob === "object" && !("kind" in blob)) {
const id = (blob as Record<string, unknown>).id
if (typeof id === "string") into.add(id)
return into
}
Object.values(item).forEach((entry) => imageIDs(entry, into))
return into
}
async function blobID(blob: Blob) {
const bytes = crypto.subtle
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
@@ -60,24 +114,25 @@ export async function createBlobReference(blob: Blob): Promise<BlobReference> {
return { id, url: blobUrl(id, blob) }
}
export function createDraftStore(driver: Driver): DraftStore {
export function createDraftStore(driver: Driver, options: { grace?: number } = {}): DraftStore {
const grace = options.grace ?? retainedBlobGrace
const versions = new Map<string, number>()
const loading = new Map<string, Promise<string | undefined>>()
const loadBlobUrl = (id: string) => {
const existing = urls.get(id)
if (existing) return existing
const existing = retained.get(id)
if (existing) return existing.url
const pending = loading.get(id)
if (pending) return pending
const next = driver
.getBlob(id)
.then((blob) => (blob ? blobUrl(id, blob) : undefined))
.then((blob) => (blob ? blobUrl(id, blob, grace) : undefined))
.finally(() => loading.delete(id))
loading.set(id, next)
return next
}
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob) }
return { id, url: blobUrl(id, blob, grace) }
}
// Keyed by chunk content so unchanged chunks are never hashed or sent again while the draft is
// edited. Bounded because each entry pins up to draftTextChunk characters. A hit is safe even if
@@ -145,8 +200,8 @@ export function createDraftStore(driver: Driver): DraftStore {
if (typeof blob.id === "string") {
// A live reference keeps the id it was created with; publish the id its bytes now live under.
const id = aliases.get(blob.id) ?? blob.id
const kept = held.get(id)
const url = typeof blob.url === "string" ? blob.url : urls.get(id)
const kept = retained.get(id)?.blob
const url = typeof blob.url === "string" ? blob.url : retained.get(id)?.url
if (kept) sources.set(id, { blob: async () => kept })
else if (url) sources.set(id, { blob: () => fetch(url).then((response) => response.blob()) })
return { ...item, blob: { id } }
@@ -192,8 +247,7 @@ export function createDraftStore(driver: Driver): DraftStore {
remember(chunks, next, source.chunk)
return
}
held.set(next, blob)
blobUrl(next, blob)
blobUrl(next, blob, grace)
if (next === id) return
// Later encodes of the still-live reference resolve straight to the new id. Re-point any
// earlier alias chain so lookups stay one step.
@@ -225,14 +279,19 @@ export function createDraftStore(driver: Driver): DraftStore {
// stays visible until the bytes are back. Covers a blob collected while a cache, another tab,
// or the composer's history still held its id.
const missing = await driver.set(key, JSON.stringify(encoded), true)
if (missing.length === 0) return
if (missing.length === 0) {
retain(key, imageIDs(encoded), grace)
return
}
const renamed = await restore(missing, sources)
if (versions.get(key) !== version) return
const unrestored = missing.filter((id) => !renamed.has(id))
if (unrestored.length)
console.error(`[persistence] draft ${key} references blobs with no bytes to restore`, unrestored)
// Anything still missing has no bytes anywhere; the owning codec drops such references on read.
await driver.set(key, JSON.stringify(rename(encoded, renamed)), false)
const final = rename(encoded, renamed)
await driver.set(key, JSON.stringify(final), false)
retain(key, imageIDs(final), grace)
}
return {
getItem: async (key) => {
@@ -241,6 +300,8 @@ export function createDraftStore(driver: Driver): DraftStore {
const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(value)
// Let the owning persistence codec apply its invalid-document policy.
if (Option.isNone(parsed)) return value
// A loaded document is live in the composer: pin its images before decode mints their URLs.
retain(key, imageIDs(parsed.value), grace)
return JSON.stringify(await decode(parsed.value))
},
setItem: (key, value) => setDocument(key, JSON.parse(value)),
@@ -248,6 +309,7 @@ export function createDraftStore(driver: Driver): DraftStore {
removeItem: async (key) => {
versions.set(key, (versions.get(key) ?? 0) + 1)
await driver.remove(key)
retain(key, new Set(), grace)
},
putBlob,
}
@@ -360,7 +422,8 @@ function referenced(json: string) {
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const data = await fetch(blob.url).then((response) => response.blob())
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
+2 -6
View File
@@ -20,7 +20,7 @@ import { useComposerCommands } from "@/composer/commands"
import { useSessionCommands } from "../commands/use-session-commands"
import type { SessionModel } from "../model"
import type { SessionScreenLayout } from "../screen-layout"
import { restorePromptModel, syncPromptModel, syncSessionModel } from "../session-model-helpers"
import { syncPromptModel, syncSessionModel } from "../session-model-helpers"
import type { SessionTimelineInteraction } from "../timeline/interaction"
import { createSessionRevert } from "../revert"
import { SessionComposerRegion } from "./session-composer-region"
@@ -62,14 +62,10 @@ export function createActiveSessionRegion(input: {
},
),
)
let restoredModelSession: string | undefined
createEffect(() => {
const id = input.session.identity.params.id
if (!id || !prompt.ready() || !local.session.ready()) return
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
}
// Prompt model is a submission mirror. Local drafts and durable session state own selection.
syncPromptModel(local, prompt)
})
createEffect(
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
import { resetSessionModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: { providerID: string; modelID: string; variant?: string } }) => ({
sessionID: "session",
@@ -54,8 +54,7 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
set() {},
variant: { current: () => "high", set() {} },
variant: { current: () => "high" },
},
},
{
@@ -77,8 +76,7 @@ describe("syncPromptModel", () => {
{
model: {
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
set() {},
variant: { current: () => model.variant, set() {} },
variant: { current: () => model.variant },
},
},
{
@@ -93,54 +91,26 @@ describe("syncPromptModel", () => {
})
})
describe("restorePromptModel", () => {
test("restores the persisted prompt model into session selection", () => {
describe("stale prompt model", () => {
test("replaces the submission mirror without changing the effective selection", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
syncPromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
set() {},
set: (value) => calls.push(value),
},
},
)
expect(restored).toBe(true)
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
})
test("does nothing without a persisted prompt model", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
{
model: {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
current: () => undefined,
set: (variant) => calls.push(variant),
},
},
},
{
model: {
current: () => undefined,
set() {},
},
},
)
expect(restored).toBe(false)
expect(calls).toEqual([])
expect(calls).toEqual([{ providerID: "openai", modelID: "gpt", variant: undefined }])
})
})
@@ -12,10 +12,8 @@ type Local = {
type ModelSelection = {
model: {
current(): { id: string; provider: { id: string } } | undefined
set(model: { providerID: string; modelID: string }): void
variant: {
current(): string | undefined
set(variant: string | undefined): void
}
}
}
@@ -51,18 +49,3 @@ export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
return
prompt.model.set(next)
}
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
const model = prompt.model.current()
if (!model) return false
const current = local.model.current()
if (
current?.provider.id === model.providerID &&
current.id === model.modelID &&
local.model.variant.current() === (model.variant ?? undefined)
)
return true
local.model.set({ providerID: model.providerID, modelID: model.modelID })
local.model.variant.set(model.variant ?? undefined)
return true
}
@@ -0,0 +1,459 @@
import { afterEach, expect, mock, test } from "bun:test"
import { createRequire } from "node:module"
import { createComponent, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import type { Agent } from "@/runtime/server/types"
import type { ModelKey } from "@/providers/models/selection"
import { ServerScope } from "@/runtime/server/scope"
// Bun does not compile Solid JSX. Compile the real context provider with the
// same presets as Vite instead of replacing LocalProvider's implementation.
const require = createRequire(import.meta.url)
const solid = createRequire(require.resolve("vite-plugin-solid"))
const { transformSync } = solid("@babel/core")
Bun.plugin({
name: "selection-solid-context",
setup(build) {
build.onLoad({ filter: /[\\/]ui[\\/]src[\\/]context[\\/]helper\.tsx$/ }, async (args) => ({
contents: transformSync(await Bun.file(args.path).text(), {
filename: args.path,
presets: [solid.resolve("babel-preset-solid"), solid.resolve("@babel/preset-typescript")],
}).code,
loader: "js",
}))
},
})
type Commit = { agent?: string; model?: { providerID: string; id: string; variant?: string } }
type Event = { data: { sessionID: string } }
type ConfigModel = string | { providerID: string; model: string; variant?: string }
const key = (modelID: string): ModelKey => ({ providerID: "provider", modelID })
const durable = (modelID: string, variant?: string, agent = "build"): Commit => ({
agent,
model: { providerID: "provider", id: modelID, variant },
})
const agent = (name: string, model?: ModelKey, variant?: string): Agent => ({
name,
mode: "primary",
permission: [],
options: {},
model,
variant,
})
let active: ReturnType<typeof fixture>
mock.module("@solidjs/router", () => ({ useParams: () => active.state.route }))
mock.module("@/runtime/server/current", () => ({ useData: () => active.data }))
mock.module("@/runtime/server/client", () => ({ useServerSDK: () => active.sdk }))
mock.module("@/runtime/server/runtime", () => ({ useGlobal: () => ({ models: active.preferences }) }))
mock.module("@/workspaces/location", () => ({ useWorkspaceLocation: () => () => ({ directory: active.directory }) }))
mock.module("@/settings/model", () => ({
useSettings: () => ({ visibility: { customAgents: () => active.state.visible } }),
}))
mock.module("@/composer/persistence", () => ({ useComposerState: () => active.prompt }))
mock.module("@/shell/state/layout", () => ({ useLayout: () => undefined }))
mock.module("@/runtime/platform/platform", () => ({
usePlatform: () => ({
platform: "web",
openExternal() {},
restart: async () => {},
notify: async () => {},
}),
}))
const { LocalProvider, useLocal } = await import("@/providers/models/selection")
const { ModelsProvider } = await import("@/providers/models/models")
const { Persist } = await import("@/runtime/persistence/storage")
const { createMemoryComposerState } = await import("@/composer/state")
const { createComposerModelSelection } = await import("@/composer/selection")
const cleanups: Array<() => void> = []
afterEach(() =>
cleanups
.splice(0)
.reverse()
.forEach((dispose) => dispose()),
)
function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigModel; preferred?: string } = {}) {
const directory = `/selection-test/${crypto.randomUUID()}`
const [state, set] = createStore({
visible: true,
configLoaded: true,
connection: "connected",
route: { id: "ses_a" as string | undefined },
agents: input.agents ?? [agent("build"), agent("plan")],
config: input.config as ConfigModel | undefined,
sessions: { ses_a: input.session } as Record<string, Commit | undefined>,
providers: [{ id: "provider", name: "Provider", package: "@ai-sdk/test", activation: "enabled" }],
models: ["a", "b", "c"].map((id) => ({
id,
modelID: id,
providerID: "provider",
name: `Model ${id}`,
settings: {},
headers: {},
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: ["low", "high"].map((id) => ({ id, settings: {} })),
time: { released: 1 },
cost: [],
status: "active",
enabled: true,
limit: { context: 128_000, output: 8192 },
})),
})
const [preferences, setPreferences] = createStore({
user: [] as Array<ModelKey & { visibility: "show" | "hide" }>,
recent: [] as ModelKey[],
variant: (input.preferred ? { "provider/a": input.preferred } : {}) as Record<string, string>,
})
const events = new Map<string, Set<(event: Event) => void>>()
const configLoads: string[] = []
const result = {
prompt: createMemoryComposerState(),
directory,
state,
set,
setPreferences,
preferences: { store: preferences, set: setPreferences, ready: () => true, recent: () => preferences.recent },
data: {
session: { get: (id: string) => state.sessions[id] },
location: {
agent: { list: () => state.agents },
config: {
list: () => (state.configLoaded ? [{ type: "document", info: { model: state.config } }] : undefined),
sync: async () => {
configLoads.push(state.connection)
},
},
provider: { list: () => state.providers },
model: { list: () => state.models },
integration: { list: () => [] },
},
},
sdk: {
scope: ServerScope.local,
connection: { status: () => state.connection },
event: {
on(type: string, handler: (event: Event) => void) {
const handlers = events.get(type) ?? new Set()
events.set(type, handlers)
handlers.add(handler)
return () => handlers.delete(handler)
},
},
},
emit(type: string, sessionID = "ses_a") {
events.get(type)?.forEach((handler) => handler({ data: { sessionID } }))
},
configLoads,
mount(draft = false) {
active = result
let local!: ReturnType<typeof useLocal>
let composer: ReturnType<typeof createComposerModelSelection> | undefined
const dispose = createRoot((dispose) => {
createComponent(ModelsProvider, {
directory,
get children() {
return createComponent(LocalProvider, {
get children() {
local = useLocal()
if (draft) composer = createComposerModelSelection({ agent: local.agent.current })
return null
},
})
},
})
return dispose
})
cleanups.push(dispose)
return { local, composer, dispose }
},
}
const target = Persist.serverWorkspace(ServerScope.local, directory, "model-selection")
cleanups.push(() => localStorage.removeItem(`${target.storage}:${target.key}`))
return result
}
function selection(local: ReturnType<typeof useLocal>) {
return {
agent: local.agent.current()?.name,
model: local.model.current()?.id,
variant: local.model.variant.current(),
}
}
test("restores durable agents even when the agent selector is hidden", () => {
const f = fixture({ session: durable("b", "high", "plan") })
f.set("visible", false)
const { local } = f.mount()
expect(local.agent.visible()).toBe(false)
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("waits for initial configuration and reloads it after reconnecting", () => {
const f = fixture({ config: "provider/b" })
f.set("configLoaded", false)
const { local } = f.mount()
expect(local.model.ready()).toBe(false)
expect(local.model.current()).toBeUndefined()
f.set("connection", "reconnecting")
f.set("connection", "connected")
expect(f.configLoads).toEqual(["connected", "reconnecting", "connected"])
f.set("configLoaded", true)
expect(local.model.ready()).toBe(true)
expect(local.model.current()?.id).toBe("b")
})
test("new-session promotion does not mask a command's durable overrides", () => {
const f = fixture({ session: durable("a", "low") })
const { local } = f.mount()
local.session.promote(f.directory, "ses_a", { agent: "build", model: key("a"), variant: "low" })
f.set("sessions", "ses_a", durable("b", "high", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("new-session drafts remember each agent's model and hand off inactive choices", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
f.set("route", "id", undefined)
const { local, composer } = f.mount(true)
if (!composer) throw new Error("missing draft composer")
composer.set(key("c"))
composer.variant.set("high")
local.agent.set("plan")
expect(composer.current()?.id).toBe("b")
expect(composer.variant.current()).toBeUndefined()
local.agent.set("build")
expect(composer.current()?.id).toBe("c")
expect(composer.variant.current()).toBe("high")
local.agent.set("plan")
const choices = composer.remembered()
f.set("sessions", "ses_a", durable("b", undefined, "plan"))
local.session.promote(f.directory, "ses_a", { agent: "plan", model: key("b"), choices })
f.set("route", "id", "ses_a")
local.agent.set("build")
expect(local.model.current()?.id).toBe("c")
})
test("session model picks snapshot the variant rather than following another session's preferences", () => {
const f = fixture({ session: durable("a") })
const { local } = f.mount()
f.setPreferences("variant", "provider/b", "low")
local.model.set(key("b"))
f.setPreferences("variant", "provider/b", "high")
expect(local.model.variant.current()).toBe("low")
})
test("remembers distinct variants for agents using the same model, scoped to the session", () => {
const f = fixture({ session: durable("a", "low"), agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.agent.set("plan")
local.model.variant.set("low")
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
f.set("sessions", "ses_b", durable("b", "high"))
f.set("route", "id", "ses_b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("restores each agent's model and variant after provider remount", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("b"))] })
const first = f.mount()
first.local.model.variant.set("high")
first.local.agent.set("plan")
first.local.model.set(key("c"))
first.local.model.variant.set("low")
first.dispose()
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
local.agent.set("build")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
local.agent.set("plan")
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test("changing models drops the old variant even when both models support it", () => {
const f = fixture({ session: durable("a", "high") })
const { local } = f.mount()
local.model.set(key("b"))
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
f.setPreferences("variant", "provider/c", "low")
local.model.set(key("c"))
expect(local.model.variant.current()).toBe("low")
})
test("restores durable selection ahead of agent, global, and saved variant defaults", () => {
const f = fixture({
session: durable("a", "low", "plan"),
agents: [agent("build"), agent("plan", key("b"), "high")],
config: { providerID: "provider", model: "c", variant: "high" },
preferred: "high",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "low" })
})
test("uses historical message selection only when durable and local selection are absent", () => {
const f = fixture()
const { local } = f.mount()
local.session.restore({ sessionID: "ses_b", agent: "plan", model: key("b") })
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: undefined })
local.session.restore({ sessionID: "ses_a", agent: "plan", model: { ...key("b"), variant: "low" } })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "low" })
local.model.variant.set("high")
local.session.restore({ sessionID: "ses_a", agent: "build", model: key("c") })
expect(selection(local)).toEqual({ agent: "plan", model: "b", variant: "high" })
})
test("invalid durable models fall through agent, global, recent, and connected defaults", () => {
const f = fixture({
session: durable("removed", "high"),
agents: [agent("build", key("b"), "low")],
config: "provider/c",
})
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("agents", [agent("build", key("removed"))])
expect(local.model.current()?.id).toBe("c")
f.setPreferences("recent", [key("removed"), key("b")])
f.set("config", "provider/removed")
expect(local.model.current()?.id).toBe("b")
f.setPreferences("recent", [key("removed")])
expect(local.model.current()?.id).toBe("a")
f.set("providers", [])
expect(local.model.current()).toBeUndefined()
})
test("global and agent model/variant defaults react to configuration replacement", () => {
const f = fixture({ config: { providerID: "provider", model: "a", variant: "low" } })
const { local } = f.mount()
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("config", { providerID: "provider", model: "b", variant: "high" })
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "high" })
f.set("agents", [agent("build", key("a"), "low")])
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "low" })
f.set("agents", [agent("build", key("c"), "high")])
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
f.set("agents", [agent("build")])
f.set("config", "provider/b")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: undefined })
})
test("durable and explicitly selected Default override a saved variant preference", () => {
const f = fixture({
session: durable("a"),
preferred: "high",
config: { providerID: "provider", model: "a", variant: "low" },
})
const { local } = f.mount()
expect(local.model.variant.current()).toBeUndefined()
local.model.variant.set(undefined)
expect(f.preferences.store.variant["provider/a"]).toBe("default")
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_a")
f.setPreferences("variant", "provider/a", "high")
expect(local.model.variant.current()).toBeUndefined()
f.set("route", "id", "ses_b")
expect(local.model.variant.current()).toBe("high")
f.set("route", "id", "ses_a")
expect(local.model.variant.current()).toBeUndefined()
})
test("waits for both commit acknowledgments, then releases only the matching draft", () => {
const f = fixture({ session: durable("b", "low") })
const { local } = f.mount()
local.agent.set("plan")
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "plan", model: key("a"), variant: "high" })
f.set("sessions", "ses_a", durable("b", "low", "plan"))
f.emit("session.agent.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
f.set("sessions", "ses_a", durable("a", "high", "plan"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low", "plan"))
expect(selection(local)).toEqual({ agent: "plan", model: "c", variant: "low" })
})
test.each(["a", "b"])("a delayed commit preserves the newer %s/low selection", (model) => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.model.set(key(model))
local.model.variant.set("low")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model, variant: "low" })
})
test("a delayed commit preserves a newer agent even when model and variant match", () => {
const f = fixture({ agents: [agent("build", key("a")), agent("plan", key("a"))] })
const { local } = f.mount()
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
local.agent.set("plan")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "plan", model: "a", variant: "high" })
})
test("a commit received while its session is inactive does not discard its local selection", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
f.set("sessions", "ses_b", durable("b", "low"))
f.set("route", "id", "ses_b")
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
expect(selection(local)).toEqual({ agent: "build", model: "b", variant: "low" })
f.set("sessions", "ses_a", durable("c", "low"))
f.set("route", "id", "ses_a")
expect(selection(local)).toEqual({ agent: "build", model: "a", variant: "high" })
})
test("cancelling a failed commit retains the draft and does not cancel a newer commit", () => {
const f = fixture()
const { local } = f.mount()
local.model.set(key("a"))
local.model.variant.set("high")
const cancel = local.model.trackSessionCommit("ses_a", { agent: "build", model: key("a"), variant: "high" })
cancel()
f.set("sessions", "ses_a", durable("a", "high"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "low"))
expect(local.model.current()?.id).toBe("a")
local.model.set(key("b"))
local.model.variant.set("low")
local.model.trackSessionCommit("ses_a", { agent: "build", model: key("b"), variant: "low" })
cancel()
f.set("sessions", "ses_a", durable("b", "low"))
f.emit("session.model.selected")
f.set("sessions", "ses_a", durable("c", "high"))
expect(selection(local)).toEqual({ agent: "build", model: "c", variant: "high" })
})
test.each([1, -1] as const)("cycles %p from outside recents to the correct end and wraps", (direction) => {
const f = fixture({ session: durable("a") })
f.setPreferences("recent", [key("removed"), key("b"), key("c")])
const { local } = f.mount()
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "c" : "b")
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
})
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
test("LocalProvider selection integration", async () => {
// Isolate provider module substitutions from the rest of the browser suite.
const child = Bun.spawn(
[
process.execPath,
"test",
"--conditions=browser",
"--preload",
"./happydom.ts",
"./test-browser/fixtures/model-selection.ts",
],
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
)
const [status, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(status, stdout + stderr).toBe(0)
}, 30_000)
+25
View File
@@ -0,0 +1,25 @@
# Vite TUI entrypoint
From the repository root:
```sh
bun run dev:vite:live /path/to/project
```
This uses the normal CLI and its real TUI through Vite + `solid-refresh`. For an explicit server or private backend, use `dev:vite` with `--server URL` or `--standalone` respectively. Plain `dev:vite` uses normal CLI service discovery; `dev:vite:live` explicitly connects to the installed server without replacing it.
- Component edits hot-update through the existing Solid refresh runtime.
- Full reloads await TUI cleanup and restore the current route: the selected session, Home workspace, or plugin page. They do not preserve composer drafts or other component-local state, and do not replay launch prompts, route prompts, `--continue`, or `--fork`.
- Correcting syntax errors retries a failed reload. The backend stays alive.
- Refreshable components get local error boundaries. A render failure during a hot update triggers one full UI reload. If the fresh render also fails, the error appears in the shared themed Dialog rather than causing a reload loop. Only the latest error is shown. Escape dismisses it; saving retries failed components. State within remounted components can still reset, especially when several components share an edited file.
- Launcher/config/dependency changes require restarting the development client.
`vite.ts` registers a Bun runtime module that supplies the Vite runner for the CLI's existing static `@opencode/tui` import. This registration runs only in the dev launcher; production handlers and their import graph are unchanged. `tui.ts` owns Vite and the TUI lifecycle. `entry.ts` loads the real application source through Vite. `host.js` keeps lifecycle ownership outside Vite's reloadable module cache. No production CLI handler, TUI component, or route changes are needed.
`refresh.ts` delegates component replacement to stock `solid-refresh`, wrapping each returned component proxy in Solid's standard ErrorBoundary. It preserves registered context identities during module evaluation: Vite's native runner can re-evaluate cyclic dependencies without invoking their HMR accept callbacks, which is too late for stock context patching. `refresh-runtime.d.ts` supplies types for the package's existing deep runtime export.
Vite redirects imports of the TUI route context through `route.tsx`, a dev-only wrapper around the real provider. It saves plain route snapshots in the external `host.js`, including nested Home location and plugin page data. Production route code is unchanged. Recovery is armed only for a hot update, consumed before requesting a full reload, and disarmed when the update settles or the full reload starts.
The entry initializes the error overlay after loading the app graph because the shared dialog and theme modules themselves use the refresh runtime.
Tested on Linux/Bun with full-app rendering, message/palette HMR, draft preservation, and native-terminal full reload/error recovery. External native-loaded plugins remain experimental across full reloads because their process-lifetime runtime mappings can retain an older Solid generation.
+17
View File
@@ -0,0 +1,17 @@
/// <reference types="vite/client" />
import { host } from "@opencode/cli/vite-host"
import { configureErrorOverlay } from "./refresh"
if (import.meta.hot) {
import.meta.hot.on("vite:afterUpdate", () => queueMicrotask(() => host.settle?.()))
import.meta.hot.on("vite:beforeFullReload", async () => {
await host.stop?.()
host.reset?.()
})
}
const { run } = await import("../../tui/src/index")
// Theme/dialog modules use refresh themselves; initialize their overlay after the app graph loads.
const { ErrorOverlay } = await import("./error-overlay")
configureErrorOverlay(ErrorOverlay)
await host.mount?.(run)
+56
View File
@@ -0,0 +1,56 @@
/* @refresh skip */
import { BoxRenderable, TextAttributes } from "@opentui/core"
import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { onCleanup, onMount } from "solid-js"
import { useTheme } from "../../tui/src/context/theme"
import { Dialog } from "../../tui/src/ui/dialog"
import { Keymap } from "../../tui/src/context/keymap"
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const focus = renderer.currentFocusedRenderable
onCleanup(Keymap.use().mode.push("modal"))
Keymap.createLayer(() => ({
mode: "modal",
priority: 1000,
commands: [{ bind: "escape", title: "Close hot reload error", group: "Development", run: props.onClose }],
}))
onMount(() => focus?.blur())
onCleanup(() => {
if (focus && !focus.isDestroyed) focus.focus()
})
return (
<Portal
ref={(container) => {
if (!(container instanceof BoxRenderable)) return
// Anchor Portal's wrapper above the app rather than after it in root layout.
container.position = "absolute"
container.left = 0
container.top = 0
container.zIndex = 5000
}}
>
<Dialog centered onClose={props.onClose}>
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Error while hot reloading
</text>
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
esc
</text>
</box>
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
{props.error instanceof Error ? props.error.message : String(props.error)}
</text>
<text flexShrink={0} fg={theme.text.subdued}>
{props.component} · Fix the component and save to retry.
</text>
</box>
</Dialog>
</Portal>
)
}
+16
View File
@@ -0,0 +1,16 @@
import type { Effect, Fiber, FileSystem } from "effect"
import type { TuiInput } from "@opencode/tui"
import type { Global } from "@opencode/util/global"
import type { Route } from "../../tui/src/context/route"
export type Run = (input: TuiInput) => Effect.Effect<void, unknown, Global.Service | FileSystem.FileSystem>
export declare const host: {
active?: Fiber.Fiber<void, unknown>
mount?: (app: Run) => Promise<void>
stop?: () => Promise<void>
reset?: () => void
recover?: () => boolean
settle?: () => void
route?: Route
}
+2
View File
@@ -0,0 +1,2 @@
// External to Vite's module cache: keep lifecycle and route state across reloads.
export const host = {}
+3
View File
@@ -0,0 +1,3 @@
declare module "solid-refresh/dist/solid-refresh.mjs" {
export * from "solid-refresh"
}
+66
View File
@@ -0,0 +1,66 @@
/// <reference types="vite/client" />
import { createComponent, createSignal, ErrorBoundary, onCleanup, Show, type JSX } from "solid-js"
import { $$component, $$refresh, type Registry } from "solid-refresh/dist/solid-refresh.mjs"
import type { ErrorOverlay } from "./error-overlay"
import { host } from "@opencode/cli/vite-host"
let overlay: typeof ErrorOverlay
const [activeError, setActiveError] = createSignal<symbol>()
export function configureErrorOverlay(component: typeof ErrorOverlay) {
overlay = component
}
export { $$context, $$decline, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
export { refresh as $$refresh }
export { component as $$component }
function refresh(...args: Parameters<typeof $$refresh>) {
// The native runner can re-evaluate a dependency in a cycle without accepting
// an update for that module. Preserve context identity now, before an updated
// consumer renders, rather than waiting for solid-refresh's accept callback.
const previous = args[1].data?.["solid-refresh"]
args[2].contexts.forEach((entry, id) => {
const old = previous?.contexts.get(id)
if (!old) return
old.context.defaultValue = entry.context.defaultValue
entry.context.id = old.context.id
entry.context.Provider = old.context.Provider
})
$$refresh(...args)
}
function component<P extends Record<string, unknown>>(
registry: Registry,
id: string,
render: (props: P) => JSX.Element,
options?: Parameters<typeof $$component>[3],
) {
const proxy = $$component(registry, id, render, options)
return (props: P) =>
createComponent(ErrorBoundary, {
fallback(error: unknown, reset: () => void) {
if (host.recover?.()) return null
const token = Symbol(id)
// Several instances can fail in one update. Stack neither dialogs nor translucent backdrops.
setActiveError(token)
onCleanup(() => {
if (activeError() === token) setActiveError(undefined)
})
// Retry only this failed subtree. Resetting the app's boundary destroys its providers and route.
import.meta.hot?.on("vite:afterUpdate", reset)
onCleanup(() => import.meta.hot?.off("vite:afterUpdate", reset))
return createComponent(Show, {
keyed: true,
get when() {
return activeError() === token
},
get children() {
return createComponent(overlay, { component: id, error, onClose: () => setActiveError(undefined) })
},
})
},
get children() {
return createComponent(proxy, props)
},
})
}
+43
View File
@@ -0,0 +1,43 @@
import { createEffect, on, type ComponentProps } from "solid-js"
import { unwrap } from "solid-js/store"
import { host } from "@opencode/cli/vite-host"
import { RouteProvider, useRoute } from "../../tui/src/context/route"
export {
useRoute,
useRouteData,
type Route,
type HomeRoute,
type SessionRoute,
type PluginRoute,
} from "../../tui/src/context/route"
export { ReloadableRouteProvider as RouteProvider }
function ReloadableRouteProvider(props: ComponentProps<typeof RouteProvider>) {
return (
<RouteProvider {...props} initialRoute={host.route ?? props.initialRoute}>
<RememberRoute />
{props.children}
</RouteProvider>
)
}
function RememberRoute() {
const route = useRoute()
createEffect(
on(
() => JSON.stringify(route.data),
() => {
// A route's prompt is a one-shot handoff, not a composer draft.
const value = structuredClone(unwrap({ ...route.data }))
host.route =
value.type === "home"
? { type: "home", location: value.location }
: value.type === "session"
? { type: "session", sessionID: value.sessionID }
: value
},
),
)
return null
}
+188
View File
@@ -0,0 +1,188 @@
import { createRequire } from "node:module"
import path from "node:path"
import { Effect, Exit, Fiber } from "effect"
import { createRunnableDevEnvironment, createServer, isRunnableDevEnvironment } from "vite"
import solid from "vite-plugin-solid"
import refresh from "solid-refresh/babel"
import { host, type Run } from "./host.js"
const require = createRequire(import.meta.url)
export const run: Run = Effect.fn("Tui.vite")(function* (input: Parameters<Run>[0]) {
const fork = Effect.runForkWith(yield* Effect.context<Effect.Services<ReturnType<Run>>>())
const finished = Promise.withResolvers<Exit.Exit<void, unknown>>()
let initial = true
let recoverable = false
host.route = undefined
host.settle = () => {
recoverable = false
}
host.stop = async () => {
const fiber = host.active
host.active = undefined
if (fiber) await Effect.runPromise(Fiber.interrupt(fiber))
}
host.mount = async (app) => {
await host.stop?.()
const fiber = fork(
app({
...input,
args: initial
? input.args
: { ...input.args, prompt: undefined, sessionID: undefined, continue: false, fork: false },
terminalHandoff: initial ? input.terminalHandoff : undefined,
}),
)
initial = false
host.active = fiber
fiber.addObserver((exit) => {
if (host.active !== fiber) return
host.active = undefined
finished.resolve(exit)
})
}
const server = yield* Effect.acquireRelease(
Effect.tryPromise(() =>
createServer({
root: path.resolve(import.meta.dirname, "../../tui"),
configFile: false,
appType: "custom",
clearScreen: false,
logLevel: "error",
server: { middlewareMode: true, ws: false },
resolve: {
alias: [
{ find: /^solid-js(?:\/dist\/solid.js)?$/, replacement: require.resolve("solid-js/dist/dev.js") },
{
find: /^solid-js\/store(?:\/dist\/store.js)?$/,
replacement: require.resolve("solid-js/store/dist/dev.js"),
},
],
},
plugins: [
{
name: "tui-refresh-boundaries",
enforce: "pre",
async resolveId(source, importer) {
if (importer === path.join(import.meta.dirname, "route.tsx")) return
if (!source.endsWith("/route") && !source.endsWith("/route.tsx")) return
const resolved = await this.resolve(source, importer, { skipSelf: true })
if (resolved?.id === path.resolve(import.meta.dirname, "../../tui/src/context/route.tsx"))
return path.join(import.meta.dirname, "route.tsx")
},
load(id) {
if (id === "/@solid-refresh")
return `export * from ${JSON.stringify(path.join(import.meta.dirname, "refresh.ts"))}`
},
},
solid({
hot: false,
dev: true,
solid: { generate: "universal", moduleName: "@opentui/solid" },
// Enable the existing refresh plugin in Vite's non-browser environment.
babel: { plugins: [[refresh, { bundler: "vite" }]] },
}),
{
name: "tui-recovery",
hotUpdate() {
if (this.environment.name !== "native") return
recoverable = Boolean(host.active)
if (host.active) return
this.environment.moduleGraph.invalidateAll()
this.environment.hot.send({ type: "full-reload" })
return []
},
},
],
environments: {
native: {
consumer: "server",
resolve: {
conditions: ["bun", "development", "module"],
externalConditions: ["bun", "node"],
noExternal: [
"solid-js",
"solid-refresh",
"@opentui/solid",
"@opentui/keymap",
"opentui-spinner",
/^@solid-primitives\//,
"@opencode/plugin",
"@opencode/client",
"@opencode/latex",
"@opencode/merman",
],
// Exact subpaths are needed for workspace TypeScript exports.
external: [
"@opentui/core",
"@opentui/core/testing",
"effect",
"@opencode/cli/vite-host",
"@opencode/client",
"@opencode/client/effect/service",
"@opencode/client/promise",
"@opencode/core/util/slug",
"@opencode/schema",
"@opencode/schema/event",
"@opencode/schema/project",
"@opencode/schema/session-id",
"@opencode/schema/session-inbox",
"@opencode/schema/session-message",
"@opencode/schema/skill",
"@opencode/schema/token-usage",
"@opencode/schema/vcs",
"@opencode/schema/worktree",
"@opencode/simulation/frontend",
"@opencode/simulation/protocol",
"@opencode/theme/tui",
"@opencode/theme/tui/v1",
"@opencode/util/activity-calendar",
"@opencode/util/flock",
"@opencode/util/global",
"@opencode/util/hash",
"@opencode/util/session-title-fallback",
],
},
optimizeDeps: { noDiscovery: true, include: [] },
dev: {
createEnvironment: (name, config) =>
createRunnableDevEnvironment(name, config, {
runnerOptions: {
sourcemapInterceptor: false,
hmr: { logger: { debug() {}, error: (error) => console.error(error) } },
},
}),
},
},
},
}),
),
(server) =>
Effect.promise(async () => {
await host.stop?.()
await server.close()
}),
)
const environment = server.environments.native
if (!isRunnableDevEnvironment(environment)) return yield* Effect.die(new Error("Expected a runnable environment"))
host.reset = () => {
recoverable = false
environment.runner.clearCache()
}
host.recover = () => {
if (!recoverable) return false
recoverable = false
queueMicrotask(() => {
input.log?.("warn", "TUI hot update failed; reloading", {})
environment.moduleGraph.invalidateAll()
environment.hot.send({ type: "full-reload" })
})
return true
}
yield* Effect.promise(() =>
environment.runner.import(path.join(import.meta.dirname, "entry.ts")).catch(console.error),
)
const exit = yield* Effect.promise(() => finished.promise)
if (Exit.isFailure(exit)) return yield* Effect.failCause(exit.cause)
}, Effect.scoped)
+15
View File
@@ -0,0 +1,15 @@
import { plugin } from "bun"
import { ensureSolidTransformPlugin } from "@opentui/solid/bun-plugin"
ensureSolidTransformPlugin()
if (process.argv[2] !== "serve") {
// Vite must initialize before the CLI installs its process/error handling on Bun.
const { run } = await import("./tui")
plugin({
name: "vite-tui-entry",
setup(build) {
build.module("@opencode/tui", () => ({ loader: "object", exports: { run } }))
},
})
}
await import("../src/index")
+2
View File
@@ -11,6 +11,7 @@
"bin"
],
"exports": {
"./vite-host": "./dev/host.js",
"./run": "./src/run/index.ts",
"./server-process": "./src/server-process.ts"
},
@@ -74,6 +75,7 @@
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1",
"solid-refresh": "0.6.3",
"vite": "catalog:",
"vite-plugin-solid": "catalog:"
}
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from "bun:test"
import { createComponent, createContext, createRoot, useContext } from "solid-js"
import { $$context, $$registry } from "solid-refresh/dist/solid-refresh.mjs"
import { $$refresh } from "../dev/refresh"
test("re-evaluated dependencies retain their mounted context before any HMR accept callback", () => {
const previous = $$registry()
const mounted = $$context(previous, "Context", createContext("old default"))
const next = $$registry()
const updated = $$context(next, "Context", createContext("new default"))
const unrelated = $$context($$registry(), "Context", createContext("unrelated"))
let accepted = false
$$refresh(
"vite",
{
data: { "solid-refresh": previous, "solid-refresh-prev": previous },
accept() {
accepted = true
},
invalidate() {
throw new Error("Unexpected invalidation")
},
decline() {
throw new Error("Unexpected decline")
},
},
next,
)
// Only register acceptance: Vite re-evaluates cyclic dependencies without
// necessarily sending those modules their own accepted update.
expect(accepted).toBe(true)
createRoot((dispose) => {
createComponent(mounted.Provider, {
value: "mounted provider",
get children() {
expect(useContext(updated)).toBe("mounted provider")
expect(useContext(unrelated)).toBe("unrelated")
return undefined
},
})
expect(useContext(mounted)).toBe("new default")
dispose()
})
})
+65
View File
@@ -0,0 +1,65 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { RouteProvider, useRoute, type Route } from "../dev/route"
import { host } from "../dev/host.js"
import { TuiStartupProvider } from "../../tui/src/context/runtime"
test("the dev route wrapper restores the current route without replaying its prompt", async () => {
const saved = () => host.route
let route!: ReturnType<typeof useRoute>
function Probe() {
route = useRoute()
return null
}
async function render() {
return testRender(
() => (
<TuiStartupProvider value={{ skipInitialLoading: true }}>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_launch" }}>
<Probe />
</RouteProvider>
</TuiStartupProvider>
),
{ width: 80, height: 24 },
)
}
const routes: Route[] = [
{ type: "home", location: { directory: "/selected/worktree", workspaceID: "wrk_test" } },
{ type: "home", location: { directory: "/another/worktree", workspaceID: "wrk_other" } },
{ type: "session", sessionID: "ses_selected" },
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 1 } } },
{ type: "plugin", id: "test", name: "page", data: { nested: { selected: 2 } } },
]
host.route = undefined
const app = await render()
try {
await app.waitFor(() => host.route !== undefined)
for (const value of routes) {
route.navigate(
value.type === "plugin"
? value
: {
...value,
prompt: { text: "one-shot handoff", files: [], agents: [], pasted: [] },
},
)
await app.waitFor(() => JSON.stringify(host.route) === JSON.stringify(value))
expect(saved()).toEqual(value)
// Saved routes contain plain data, not a proxy tied to the old Solid tree.
expect(structuredClone(saved())).toEqual(value)
}
} finally {
app.renderer.destroy()
}
for (const value of routes) {
host.route = value
const restored = await render()
try {
expect(route.data).toEqual(value)
} finally {
restored.renderer.destroy()
}
}
host.route = undefined
})
+6
View File
@@ -91,6 +91,12 @@ runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
### `Values`
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
### OpenAPI tools
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values
+1
View File
@@ -2,5 +2,6 @@ export * as CodeMode from "./codemode.js"
export * as Namespace from "./namespace.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { Values } from "./values.js"
export { searchSignature, toolExpression } from "./codemode.js"
export { ToolError, toolError } from "./tool-error.js"
+28 -37
View File
@@ -16,16 +16,7 @@ import {
} from "./model.js"
import { containsOpaqueReference, isRuntimeReference, rejectCircularInsertion, typeofValue } from "./references.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import {
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
isCodeModeValue,
} from "../values.js"
import { Values } from "../values.js"
import { dateSetterArgumentCount, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
import { invokeMathMethod } from "../stdlib/math.js"
import { invokeNumberMethod, invokeNumberStatic } from "../stdlib/number.js"
@@ -43,7 +34,7 @@ export type CallbackRunner<R> = {
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
readonly settlePromise: (promise: CodeModePromise) => Effect.Effect<unknown, unknown, never>
readonly settlePromise: (promise: Values.Promise) => Effect.Effect<unknown, unknown, never>
}
// The single acceptance list for callbacks: collections, sort, string replacers,
@@ -100,7 +91,7 @@ export const invokeIntrinsic = <R>(
if (Array.isArray(ref.receiver)) {
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof CodeModeDate) {
if (ref.receiver instanceof Values.Date) {
const target = ref.receiver
const argumentCount = dateSetterArgumentCount(ref.name)
if (argumentCount === undefined) return Effect.succeed(invokeDateMethod(target, ref.name, [], node))
@@ -113,19 +104,19 @@ export const invokeIntrinsic = <R>(
(values) => invokeDateMethod(target, ref.name, values, node, initialTime),
)
}
if (ref.receiver instanceof CodeModeRegExp) {
if (ref.receiver instanceof Values.RegExp) {
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
}
if (ref.receiver instanceof CodeModeMap) {
if (ref.receiver instanceof Values.Map) {
return invokeMapMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof CodeModeSet) {
if (ref.receiver instanceof Values.Set) {
return invokeSetMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof CodeModeURL) {
if (ref.receiver instanceof Values.URL) {
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
}
if (ref.receiver instanceof CodeModeURLSearchParams) {
if (ref.receiver instanceof Values.URLSearchParams) {
return invokeURLSearchParamsMethod(runner, ref.receiver, ref.name, args, node)
}
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available.`, node)
@@ -136,7 +127,7 @@ const coerceNumericArgument = <R>(
value: unknown,
node: AstNode,
): Effect.Effect<number, unknown, R> => {
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
if (value === null || typeof value !== "object" || Array.isArray(value) || Values.isValue(value)) {
return Effect.succeed(coerceToNumber(value))
}
const object = value as Record<string, unknown>
@@ -192,7 +183,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
const rejectRegex = (): void => {
if (args[0] instanceof CodeModeRegExp) {
if (args[0] instanceof Values.RegExp) {
throw new InterpreterRuntimeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
node,
@@ -241,7 +232,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
result = requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value]
break
}
if (args[0] instanceof CodeModeRegExp) {
if (args[0] instanceof Values.RegExp) {
result = value.split(args[0].regex, optNum(1))
break
}
@@ -272,7 +263,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
break
case "replace":
case "replaceAll": {
if (args[0] instanceof CodeModeRegExp) {
if (args[0] instanceof Values.RegExp) {
const pattern = args[0].regex
const replacement = str(1)
if (name === "replaceAll" && !pattern.global) {
@@ -368,7 +359,7 @@ const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): u
}
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
if (source instanceof CodeModePromise) {
if (source instanceof Values.Promise) {
throw new InterpreterRuntimeError(
"Array.from received an un-awaited Promise; await it before creating the array.",
node,
@@ -445,7 +436,7 @@ export const invokeGroupBy = <R>(
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node).as("TypeError")
}
if (namespace === "Map") {
const result = new CodeModeMap()
const result = new Values.Map()
let index = 0
while (true) {
const step = yield* cursor.next
@@ -488,10 +479,10 @@ const coerceGroupByPropertyKey = <R>(
value: unknown,
node: AstNode,
): Effect.Effect<string, unknown, R> => {
if (value === null || typeof value !== "object" || Array.isArray(value) || isCodeModeValue(value)) {
if (value === null || typeof value !== "object" || Array.isArray(value) || Values.isValue(value)) {
return Effect.succeed(coerceToString(value))
}
if (value instanceof CodeModePromise) return Effect.succeed("[object Promise]")
if (value instanceof Values.Promise) return Effect.succeed("[object Promise]")
if (isRuntimeReference(value)) {
throw new InterpreterRuntimeError("Object.groupBy callback must return a data value.", node, "InvalidDataValue")
}
@@ -543,7 +534,7 @@ const invokeStringReplacer = <R>(
}
const pattern = args[0]
if (pattern instanceof CodeModeRegExp) {
if (pattern instanceof Values.RegExp) {
if (name === "replaceAll" && !pattern.regex.global) {
throw new InterpreterRuntimeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
@@ -566,7 +557,7 @@ const invokeStringReplacer = <R>(
// Error values are branded plain objects; boundedData would strip the brand before coercion.
output.push(
value.slice(end, match.offset),
replacement instanceof CodeModePromise
replacement instanceof Values.Promise
? "[object Promise]"
: errorBrandName(replacement)
? coerceToString(replacement)
@@ -599,7 +590,7 @@ export const applyCollectionCallback = <R>(
const invokeMapMethod = <R>(
runner: CallbackRunner<R>,
target: CodeModeMap,
target: Values.Map,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -641,7 +632,7 @@ const invokeMapMethod = <R>(
const invokeSetMethod = <R>(
runner: CallbackRunner<R>,
target: CodeModeSet,
target: Values.Set,
name: string,
args: Array<unknown>,
node: AstNode,
@@ -688,7 +679,7 @@ const invokeSetMethod = <R>(
const invokeSetOperation = <R>(
runner: CallbackRunner<R>,
target: CodeModeSet,
target: Values.Set,
name: string,
source: unknown,
node: AstNode,
@@ -701,7 +692,7 @@ const invokeSetOperation = <R>(
return result
}
if (name === "intersection") {
const result = new CodeModeSet()
const result = new Values.Set()
if (target.set.size <= other.size) {
for (const item of target.set.values()) {
if (yield* other.has(item)) result.set.add(item)
@@ -758,28 +749,28 @@ const invokeSetOperation = <R>(
return true
})
const copySet = (source: CodeModeSet): CodeModeSet => {
const result = new CodeModeSet()
const copySet = (source: Values.Set): Values.Set => {
const result = new Values.Set()
for (const item of source.set.values()) result.set.add(item)
return result
}
const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: string, node: AstNode) => {
if (source instanceof CodeModeSet) {
if (source instanceof Values.Set) {
return Effect.succeed({
size: source.set.size,
has: (item: unknown) => Effect.succeed(source.set.has(item)),
keys: () => Effect.succeed(source.set.values()),
})
}
if (source instanceof CodeModeMap) {
if (source instanceof Values.Map) {
return Effect.succeed({
size: source.map.size,
has: (item: unknown) => Effect.succeed(source.map.has(item)),
keys: () => Effect.succeed(source.map.keys()),
})
}
if (source === null || typeof source !== "object" || isCodeModeValue(source)) {
if (source === null || typeof source !== "object" || Values.isValue(source)) {
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node).as("TypeError")
}
const object = source as Record<string, unknown>
@@ -809,7 +800,7 @@ const loadSetRecord = <R>(runner: CallbackRunner<R>, source: unknown, name: stri
const invokeURLSearchParamsMethod = <R>(
runner: CallbackRunner<R>,
target: CodeModeURLSearchParams,
target: Values.URLSearchParams,
name: string,
args: Array<unknown>,
node: AstNode,
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Effect } from "effect"
import type { SafeObject } from "../tool-runtime.js"
import type { CodeModePromise, CodeModeRegExp, CodeModeURL } from "../values.js"
import type { Values } from "../values.js"
export type SourcePosition = {
line: number
@@ -36,7 +36,7 @@ export type StatementResult =
| { kind: "continue"; label?: string }
export type MemberReference = {
target: SafeObject | Array<unknown> | CodeModeRegExp | CodeModeURL
target: SafeObject | Array<unknown> | Values.RegExp | Values.URL
key: PropertyKey
}
@@ -99,7 +99,7 @@ export type PromiseInstanceMethodName = "then" | "catch" | "finally"
export class PromiseInstanceMethodReference {
constructor(
readonly promise: CodeModePromise,
readonly promise: Values.Promise,
readonly name: PromiseInstanceMethodName,
) {}
}
+23 -23
View File
@@ -14,25 +14,25 @@ import { caughtErrorValue, normalizeError } from "./errors.js"
import { applyCollectionCallback, isSupportedCallback, type CallbackRunner, type SupportedCallback } from "./methods.js"
import { typeofValue } from "./references.js"
import { createAggregateErrorValue } from "../stdlib/value.js"
import { CodeModePromise } from "../values.js"
import { Values } from "../values.js"
import type { SyncIteratorRunner } from "./iterator.js"
// Observation only controls rejection reporting; program completion interrupts all promise work.
export class PromiseRuntime<R> {
private readonly active = new Set<CodeModePromise>()
private readonly ids = new WeakMap<CodeModePromise, number>()
private readonly observed = new WeakSet<CodeModePromise>()
private readonly active = new Set<Values.Promise>()
private readonly ids = new WeakMap<Values.Promise, number>()
private readonly observed = new WeakSet<Values.Promise>()
private readonly failures = new Map<number, Diagnostic>()
private nextID = 0
constructor(private readonly scope: Scope.Scope) {}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
return Effect.suspend(() => {
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
const id = this.nextID++
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
const promise = new CodeModePromise(fiber)
const promise = new Values.Promise(fiber)
this.active.add(promise)
this.ids.set(promise, id)
fiber.addObserver((exit) => {
@@ -53,14 +53,14 @@ export class PromiseRuntime<R> {
}
// Observation must be recorded when responsibility transfers, before the consumer fiber runs.
markObserved(promise: CodeModePromise): void {
markObserved(promise: Values.Promise): void {
this.observed.add(promise)
const id = this.ids.get(promise)
this.ids.delete(promise)
if (id !== undefined) this.failures.delete(id)
}
await(promise: CodeModePromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
await(promise: Values.Promise): Effect.Effect<Exit.Exit<unknown, unknown>> {
return Fiber.await(promise.fiber)
}
@@ -91,10 +91,10 @@ export const resolvePromiseValue = <R>(
runner: CallbackRunner<R>,
value: unknown,
node: AstNode,
own?: { promise?: CodeModePromise },
own?: { promise?: Values.Promise },
): Effect.Effect<unknown, unknown, R> => {
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
if (value instanceof CodeModePromise) return runner.settlePromise(value)
if (value instanceof Values.Promise) return runner.settlePromise(value)
if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value)
const then = (value as SafeObject).then
if (typeofValue(then) !== "function") return Effect.succeed(value)
@@ -123,9 +123,9 @@ export const resolvePromise = <R>(
promises: PromiseRuntime<R>,
value: unknown,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> => {
if (value instanceof CodeModePromise) return Effect.succeed(value)
const box: { promise?: CodeModePromise } = {}
): Effect.Effect<Values.Promise, never, R> => {
if (value instanceof Values.Promise) return Effect.succeed(value)
const box: { promise?: Values.Promise } = {}
return Effect.map(promises.create(resolvePromiseValue(runner, value, node, box)), (promise) => {
box.promise = promise
return promise
@@ -155,7 +155,7 @@ export const invokePromiseMethod = <R>(
node,
).as("TypeError")
}
const items: Array<CodeModePromise> = []
const items: Array<Values.Promise> = []
while (true) {
const step = yield* cursor.next
if (step.done) break
@@ -227,7 +227,7 @@ export const invokePromiseInstanceMethod = <R>(
ref: PromiseInstanceMethodReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> => {
): Effect.Effect<Values.Promise, never, R> => {
const method = `Promise.prototype.${ref.name}`
promises.markObserved(ref.promise)
if (ref.name === "finally") {
@@ -243,7 +243,7 @@ export const constructPromise = <R>(
promises: PromiseRuntime<R>,
executor: unknown,
node: AstNode,
): Effect.Effect<CodeModePromise, unknown, R> => {
): Effect.Effect<Values.Promise, unknown, R> => {
if (!(executor instanceof CodeModeFunction)) {
throw new InterpreterRuntimeError(
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
@@ -252,7 +252,7 @@ export const constructPromise = <R>(
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const box: { promise?: CodeModePromise } = {}
const box: { promise?: Values.Promise } = {}
const promise = yield* promises.create(
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, box)),
)
@@ -294,7 +294,7 @@ const reactionHandler = (value: unknown, method: string, node: AstNode): Support
// Teardown bypasses handlers; settled reactions yield once so handlers never run inline.
const reactionExit = <R>(
promises: PromiseRuntime<R>,
source: CodeModePromise,
source: Values.Promise,
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
Effect.gen(function* () {
const exit = yield* promises.await(source)
@@ -306,13 +306,13 @@ const reactionExit = <R>(
const chainReaction = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: CodeModePromise,
source: Values.Promise,
onFulfilled: SupportedCallback | undefined,
onRejected: SupportedCallback | undefined,
method: string,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> => {
const box: { promise?: CodeModePromise } = {}
): Effect.Effect<Values.Promise, never, R> => {
const box: { promise?: Values.Promise } = {}
const body = Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
@@ -330,11 +330,11 @@ const chainReaction = <R>(
const chainFinally = <R>(
runner: CallbackRunner<R>,
promises: PromiseRuntime<R>,
source: CodeModePromise,
source: Values.Promise,
cleanup: SupportedCallback | undefined,
method: string,
node: AstNode,
): Effect.Effect<CodeModePromise, never, R> =>
): Effect.Effect<Values.Promise, never, R> =>
promises.create(
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
@@ -21,7 +21,7 @@ import {
UriFunction,
} from "./model.js"
import { ToolReference } from "../tool-runtime.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
import { Values } from "../values.js"
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof CodeModeFunction ||
@@ -35,14 +35,14 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof PromiseNamespace ||
value instanceof PromiseMethodReference ||
value instanceof PromiseInstanceMethodReference ||
value instanceof CodeModePromise ||
value instanceof Values.Promise ||
value instanceof CoercionFunction ||
value instanceof UriFunction ||
value instanceof SearchFunction ||
value instanceof PromiseCapabilityFunction ||
value instanceof ErrorConstructorReference ||
value instanceof SymbolNamespace ||
isCodeModeValue(value)
Values.isValue(value)
function* childValues(value: object): Generator {
for (const key of Reflect.ownKeys(value)) {
@@ -81,7 +81,7 @@ export const containsOpaqueReference = (value: unknown): boolean => {
continue
}
const current = next.value
if (isCodeModeValue(current)) continue
if (Values.isValue(current)) continue
if (isRuntimeReference(current)) return true
if (current === null || typeof current !== "object" || seen.has(current)) continue
seen.add(current)
+61 -70
View File
@@ -98,16 +98,7 @@ import {
invokeCoercion,
valueConstructors,
} from "../stdlib/value.js"
import {
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
import { Values } from "../values.js"
const globalStaticMembers: Partial<Record<GlobalNamespaceName, Set<string>>> = {
Object: objectStatics,
@@ -153,24 +144,24 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean =>
if (rhs instanceof GlobalNamespace) {
switch (rhs.name) {
case "Date":
return lhs instanceof CodeModeDate
return lhs instanceof Values.Date
case "RegExp":
return lhs instanceof CodeModeRegExp
return lhs instanceof Values.RegExp
case "Map":
return lhs instanceof CodeModeMap
return lhs instanceof Values.Map
case "Set":
return lhs instanceof CodeModeSet
return lhs instanceof Values.Set
case "URL":
return lhs instanceof CodeModeURL
return lhs instanceof Values.URL
case "URLSearchParams":
return lhs instanceof CodeModeURLSearchParams
return lhs instanceof Values.URLSearchParams
case "Array":
return Array.isArray(lhs)
case "Object":
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
}
}
if (rhs instanceof PromiseNamespace) return lhs instanceof CodeModePromise
if (rhs instanceof PromiseNamespace) return lhs instanceof Values.Promise
if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
return false
}
@@ -371,16 +362,16 @@ export class Interpreter<R> {
private createToolCallPromise(
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<CodeModePromise, never, R> {
): Effect.Effect<Values.Promise, never, R> {
return this.createPromise(Effect.suspend(() => this.executeTool(path, args)))
}
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<CodeModePromise, never, R> {
private createPromise(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<Values.Promise, never, R> {
return this.promises.create(effect)
}
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
private settlePromise(promise: CodeModePromise): Effect.Effect<unknown, unknown, never> {
private settlePromise(promise: Values.Promise): Effect.Effect<unknown, unknown, never> {
const promises = this.promises
return Effect.suspend(() => {
promises.markObserved(promise)
@@ -812,11 +803,11 @@ export class Interpreter<R> {
? value[Symbol.iterator]()
: typeof value === "string"
? value[Symbol.iterator]()
: value instanceof CodeModeMap
: value instanceof Values.Map
? value.map.entries()
: value instanceof CodeModeSet
: value instanceof Values.Set
? value.set.values()
: value instanceof CodeModeURLSearchParams
: value instanceof Values.URLSearchParams
? value.params.entries()
: undefined
if (iterator !== undefined) {
@@ -1478,19 +1469,19 @@ export class Interpreter<R> {
)
}
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<CodeModeDate, unknown, R> {
if (args.length === 0) return Effect.succeed(new CodeModeDate(Date.now()))
private constructDate(args: Array<unknown>, node: AstNode): Effect.Effect<Values.Date, unknown, R> {
if (args.length === 0) return Effect.succeed(new Values.Date(Date.now()))
if (args.length === 1) {
const arg = args[0]
if (arg instanceof CodeModeDate) return Effect.succeed(new CodeModeDate(arg.time))
if (arg instanceof Values.Date) return Effect.succeed(new Values.Date(arg.time))
return Effect.map(this.toDatePrimitive(arg, node), (value) =>
typeof value === "string"
? new CodeModeDate(Date.parse(value))
: new CodeModeDate(new Date(coerceToNumber(value)).getTime()),
? new Values.Date(Date.parse(value))
: new Values.Date(new Date(coerceToNumber(value)).getTime()),
)
}
const parts = args.map((arg) => coerceToNumber(arg))
return Effect.succeed(new CodeModeDate(new Date(...(parts as [number, number])).getTime()))
return Effect.succeed(new Values.Date(new Date(...(parts as [number, number])).getTime()))
}
private toDatePrimitive(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
@@ -1511,10 +1502,10 @@ export class Interpreter<R> {
})
}
private constructRegExp(args: Array<unknown>, node: AstNode): CodeModeRegExp {
private constructRegExp(args: Array<unknown>, node: AstNode): Values.RegExp {
const first = args[0]
const pattern =
first instanceof CodeModeRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
first instanceof Values.RegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
const flagsArg = args[1]
if (flagsArg !== undefined && typeof flagsArg !== "string") {
throw new InterpreterRuntimeError(
@@ -1522,9 +1513,9 @@ export class Interpreter<R> {
node,
).as("SyntaxError")
}
const flags = flagsArg ?? (first instanceof CodeModeRegExp ? first.regex.flags : "")
const flags = flagsArg ?? (first instanceof Values.RegExp ? first.regex.flags : "")
try {
return new CodeModeRegExp(pattern, flags)
return new Values.RegExp(pattern, flags)
} catch (error) {
const reason = regexFailureReason(error)
throw new InterpreterRuntimeError(
@@ -1536,8 +1527,8 @@ export class Interpreter<R> {
}
}
private constructMap(init: unknown, node: AstNode): Effect.Effect<CodeModeMap, unknown, R> {
const target = new CodeModeMap()
private constructMap(init: unknown, node: AstNode): Effect.Effect<Values.Map, unknown, R> {
const target = new Values.Map()
if (init === undefined || init === null) return Effect.succeed(target)
const self = this
return Effect.gen(function* () {
@@ -1566,8 +1557,8 @@ export class Interpreter<R> {
})
}
private constructSet(init: unknown, node: AstNode): Effect.Effect<CodeModeSet, unknown, R> {
const target = new CodeModeSet()
private constructSet(init: unknown, node: AstNode): Effect.Effect<Values.Set, unknown, R> {
const target = new Values.Set()
if (init === undefined || init === null) return Effect.succeed(target)
const self = this
return Effect.gen(function* () {
@@ -1585,7 +1576,7 @@ export class Interpreter<R> {
})
}
private constructURL(args: Array<unknown>, node: AstNode): CodeModeURL {
private constructURL(args: Array<unknown>, node: AstNode): Values.URL {
if (args.length === 0) {
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
"TypeError",
@@ -1594,7 +1585,7 @@ export class Interpreter<R> {
const input = urlArgument(args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
try {
return new CodeModeURL(new URL(input, base))
return new Values.URL(new URL(input, base))
} catch {
throw new InterpreterRuntimeError(
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
@@ -1603,14 +1594,14 @@ export class Interpreter<R> {
}
}
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<CodeModeURLSearchParams, unknown, R> {
if (init === undefined) return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams()))
if (init instanceof CodeModeURLSearchParams) {
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init.params)))
private constructURLSearchParams(init: unknown, node: AstNode): Effect.Effect<Values.URLSearchParams, unknown, R> {
if (init === undefined) return Effect.succeed(new Values.URLSearchParams(new URLSearchParams()))
if (init instanceof Values.URLSearchParams) {
return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(init.params)))
}
if (typeof init === "string") return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(init)))
if (typeof init === "string") return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(init)))
if (init === null || typeof init === "number" || typeof init === "boolean") {
return Effect.succeed(new CodeModeURLSearchParams(new URLSearchParams(coerceToString(init))))
return Effect.succeed(new Values.URLSearchParams(new URLSearchParams(coerceToString(init))))
}
const self = this
return Effect.gen(function* () {
@@ -1626,7 +1617,7 @@ export class Interpreter<R> {
node,
).as("TypeError")
}
return new CodeModeURLSearchParams(
return new Values.URLSearchParams(
new URLSearchParams(entries.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])),
)
}
@@ -1639,7 +1630,7 @@ export class Interpreter<R> {
node,
).as("TypeError")
}
if (isCodeModeValue(init)) return new CodeModeURLSearchParams(new URLSearchParams())
if (Values.isValue(init)) return new Values.URLSearchParams(new URLSearchParams())
const data = boundedData(init, "new URLSearchParams input")
if (data === null || typeof data !== "object") {
throw new InterpreterRuntimeError(
@@ -1647,7 +1638,7 @@ export class Interpreter<R> {
node,
).as("TypeError")
}
return new CodeModeURLSearchParams(
return new Values.URLSearchParams(
new URLSearchParams(
Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)])),
),
@@ -1698,7 +1689,7 @@ export class Interpreter<R> {
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
const coerceOperand = (operand: unknown): unknown => {
if (operand instanceof CodeModeDate) {
if (operand instanceof Values.Date) {
return operator === "+" || operator === "==" || operator === "!=" ? coerceToString(operand) : operand.time
}
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
@@ -1783,7 +1774,7 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
}
const operand =
value instanceof CodeModeDate
value instanceof Values.Date
? value.time
: value !== null && typeof value === "object"
? coerceToString(value)
@@ -2110,7 +2101,7 @@ export class Interpreter<R> {
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
// The initial yield assigns the promise before the body can self-resolve.
const box: { promise?: CodeModePromise } = {}
const box: { promise?: Values.Promise } = {}
return Effect.map(
this.createPromise(Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runner, value, fn.body, box))),
(promise) => {
@@ -2281,9 +2272,9 @@ export class Interpreter<R> {
if (
Array.isArray(value) ||
typeof value === "string" ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURLSearchParams
value instanceof Values.Map ||
value instanceof Values.Set ||
value instanceof Values.URLSearchParams
) {
const cursor = yield* self.syncIterator(value, node)
if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node)
@@ -2374,7 +2365,7 @@ export class Interpreter<R> {
if (property.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
if (spread === null || spread === undefined || isCodeModeValue(spread)) continue
if (spread === null || spread === undefined || Values.isValue(spread)) continue
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
throw new InterpreterRuntimeError("Object spread requires a data object.", property, "InvalidDataValue")
}
@@ -2598,11 +2589,11 @@ export class Interpreter<R> {
return new ComputedValue(undefined)
}
if (objectValue instanceof CodeModeDate) {
if (objectValue instanceof Values.Date) {
if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof CodeModeRegExp) {
if (objectValue instanceof Values.RegExp) {
if (key === "lastIndex") return { target: objectValue, key }
if (typeof key === "string" && regexpProperties.has(key)) {
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
@@ -2610,17 +2601,17 @@ export class Interpreter<R> {
if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof CodeModeMap) {
if (objectValue instanceof Values.Map) {
if (key === "size") return new ComputedValue(objectValue.map.size)
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof CodeModeSet) {
if (objectValue instanceof Values.Set) {
if (key === "size") return new ComputedValue(objectValue.set.size)
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof CodeModeURL) {
if (objectValue instanceof Values.URL) {
if (key === "searchParams") {
return new ComputedValue(objectValue.searchParams)
}
@@ -2628,7 +2619,7 @@ export class Interpreter<R> {
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
return new ComputedValue(undefined)
}
if (objectValue instanceof CodeModeURLSearchParams) {
if (objectValue instanceof Values.URLSearchParams) {
if (key === "size") return new ComputedValue(objectValue.params.size)
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
return new IntrinsicReference(objectValue, key)
@@ -2637,7 +2628,7 @@ export class Interpreter<R> {
}
// Reject unknown promise properties so a missing await cannot hide.
if (objectValue instanceof CodeModePromise) {
if (objectValue instanceof Values.Promise) {
if (key === "then" || key === "catch" || key === "finally") {
return new PromiseInstanceMethodReference(objectValue, key)
}
@@ -2703,8 +2694,8 @@ export class Interpreter<R> {
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return Reflect.get(reference.target, reference.key)
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof CodeModeURL) {
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
if (reference.target instanceof Values.URL) {
return Reflect.get(reference.target.url, reference.key)
}
return Reflect.get(reference.target, reference.key)
@@ -2726,11 +2717,11 @@ export class Interpreter<R> {
reference instanceof ComputedValue ||
reference === undefined ||
isOpaqueMemberReference(reference) ||
reference.target instanceof CodeModeURL
reference.target instanceof Values.URL
) {
throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
}
if (reference.target instanceof CodeModeRegExp) {
if (reference.target instanceof Values.RegExp) {
return Reflect.deleteProperty(reference.target.regex, reference.key)
}
return Reflect.deleteProperty(reference.target, reference.key)
@@ -2767,10 +2758,10 @@ export class Interpreter<R> {
}
private readReferenceValue(reference: MemberReference, key: PropertyKey): unknown {
if (reference.target instanceof CodeModeURL) {
if (reference.target instanceof Values.URL) {
return Reflect.get(reference.target.url, key)
}
if (reference.target instanceof CodeModeRegExp) return reference.target.lastIndex
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
return Reflect.get(reference.target, key)
}
@@ -2788,7 +2779,7 @@ export class Interpreter<R> {
target[key] = next
return
}
if (reference.target instanceof CodeModeURL) {
if (reference.target instanceof Values.URL) {
const property = key as string
if (!urlWritableProperties.has(property)) {
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
@@ -2802,7 +2793,7 @@ export class Interpreter<R> {
throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
}
}
if (reference.target instanceof CodeModeRegExp) {
if (reference.target instanceof Values.RegExp) {
reference.target.lastIndex = next
return
}
+10 -19
View File
@@ -1,15 +1,6 @@
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
import { copyIn, copyOut } from "../tool-runtime.js"
import {
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
import { Values } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
@@ -34,14 +25,14 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (typeof value === "string") return JSON.stringify(value)
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (typeof value !== "object") return String(value)
if (value instanceof CodeModePromise) return "[Promise (await it to get its value)]"
if (value instanceof CodeModeDate) return coerceToString(value)
if (value instanceof CodeModeRegExp) return coerceToString(value)
if (value instanceof CodeModeURL) return coerceToString(value)
if (value instanceof CodeModeURLSearchParams) return coerceToString(value)
if (value instanceof Values.Promise) return "[Promise (await it to get its value)]"
if (value instanceof Values.Date) return coerceToString(value)
if (value instanceof Values.RegExp) return coerceToString(value)
if (value instanceof Values.URL) return coerceToString(value)
if (value instanceof Values.URLSearchParams) return coerceToString(value)
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
if (value instanceof CodeModeMap) {
if (value instanceof Values.Map) {
seen.add(value)
try {
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
@@ -50,7 +41,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
seen.delete(value)
}
}
if (value instanceof CodeModeSet) {
if (value instanceof Values.Set) {
seen.add(value)
try {
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
@@ -100,14 +91,14 @@ const consoleTableRows = (
if (Array.isArray(data)) {
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
}
if (data !== null && typeof data === "object" && !isCodeModeValue(data)) {
if (data !== null && typeof data === "object" && !Values.isValue(data)) {
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
}
return [{ index: "0", values: { Value: data } }]
}
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isCodeModeValue(value)) {
if (value !== null && typeof value === "object" && !Array.isArray(value) && !Values.isValue(value)) {
const source = value as Record<string, unknown>
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
return Object.fromEntries(Object.entries(source))
+3 -3
View File
@@ -1,5 +1,5 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { CodeModeDate } from "../values.js"
import { Values } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
const dateSetterArguments = new Map<string, number>([
@@ -66,7 +66,7 @@ export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNo
export const dateSetterArgumentCount = (name: string): number | undefined => dateSetterArguments.get(name)
export const invokeDateMethod = (
value: CodeModeDate,
value: Values.Date,
name: string,
args: Array<number>,
node: AstNode,
@@ -174,7 +174,7 @@ export const invokeDateMethod = (
}
}
const updateDate = (value: CodeModeDate, time: number): number => {
const updateDate = (value: Values.Date, time: number): number => {
value.time = time
return time
}
+9 -16
View File
@@ -4,14 +4,7 @@ import { applyCollectionCallback } from "../interpreter/methods.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { typeofValue } from "../interpreter/references.js"
import { copyIn, copyOut, type SafeObject } from "../tool-runtime.js"
import {
CodeModeDate,
CodeModeMap,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
import { Values } from "../values.js"
export const jsonStatics = new Set(["parse", "stringify"])
export type JsonMethodName = "parse" | "stringify"
@@ -131,19 +124,19 @@ const stringify = <R>(
}
const toJSONValue = (value: unknown): unknown => {
if (value instanceof CodeModeDate) {
if (value instanceof Values.Date) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof CodeModeURL) return value.url.href
if (value instanceof Values.URL) return value.url.href
return value
}
const isPlainObject = (value: unknown): value is SafeObject =>
value !== null &&
typeof value === "object" &&
!(value instanceof CodeModeDate) &&
!(value instanceof CodeModeRegExp) &&
!(value instanceof CodeModeMap) &&
!(value instanceof CodeModeSet) &&
!(value instanceof CodeModeURL) &&
!(value instanceof CodeModeURLSearchParams)
!(value instanceof Values.Date) &&
!(value instanceof Values.RegExp) &&
!(value instanceof Values.Map) &&
!(value instanceof Values.Set) &&
!(value instanceof Values.URL) &&
!(value instanceof Values.URLSearchParams)
+6 -6
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
import { Values } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
import { preserveConsumerError, type SyncIteratorRunner } from "../interpreter/iterator.js"
@@ -14,8 +14,8 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
const requireObject = (): Record<string, unknown> => {
const input = args[0]
if (Array.isArray(input)) return input as unknown as Record<string, unknown>
if (isCodeModeValue(input)) return {}
if (input instanceof CodeModePromise) {
if (Values.isValue(input)) return {}
if (input instanceof Values.Promise) {
throw new InterpreterRuntimeError(
`Object.${name} received an un-awaited Promise; await it before inspecting the result.`,
node,
@@ -50,7 +50,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
return Object.is(args[0], args[1])
case "assign": {
const target = args[0]
if (target === null || typeof target !== "object" || Array.isArray(target) || isCodeModeValue(target)) {
if (target === null || typeof target !== "object" || Array.isArray(target) || Values.isValue(target)) {
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
}
const out = target as Record<string, unknown>
@@ -65,7 +65,7 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
)
}
for (const source of args.slice(1)) {
if (source === null || source === undefined || isCodeModeValue(source)) continue
if (source === null || source === undefined || Values.isValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
@@ -107,7 +107,7 @@ export const invokeObjectFromEntries = <R>(
if (
step.value === null ||
typeof step.value !== "object" ||
isCodeModeValue(step.value) ||
Values.isValue(step.value) ||
containsOpaqueReference(step.value)
) {
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node).as(
+3 -3
View File
@@ -1,6 +1,6 @@
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
import { CodeModeRegExp } from "../values.js"
import { Values } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
type MatchValue = Array<unknown> & {
@@ -40,7 +40,7 @@ export const escapeRegexHint =
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
// Native parity: an undefined pattern behaves as an empty pattern.
if (arg === undefined) return new RegExp("", extraFlags)
if (arg instanceof CodeModeRegExp) return arg.regex
if (arg instanceof Values.RegExp) return arg.regex
if (typeof arg === "string") {
try {
return new RegExp(arg, extraFlags)
@@ -80,7 +80,7 @@ export const invokeRegExpStatic = (name: string, args: Array<unknown>, node: Ast
}
export const invokeRegExpMethod = (
value: CodeModeRegExp,
value: Values.RegExp,
name: string,
args: Array<unknown>,
node: AstNode,
+4 -4
View File
@@ -66,7 +66,7 @@ export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node:
}
export const urlArgument = (value: unknown, label: string): string =>
value instanceof CodeModeURL ? value.url.href : uriArgument(value, label)
value instanceof Values.URL ? value.url.href : uriArgument(value, label)
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available.`, node)
@@ -75,16 +75,16 @@ export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNod
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
try {
const url = new URL(input, base)
return name === "canParse" ? true : new CodeModeURL(url)
return name === "canParse" ? true : new Values.URL(url)
} catch {
return name === "canParse" ? false : null
}
}
export const invokeURLMethod = (value: CodeModeURL, name: string, node: AstNode): string => {
export const invokeURLMethod = (value: Values.URL, name: string, node: AstNode): string => {
if (name === "toString" || name === "toJSON") return value.url.href
throw new InterpreterRuntimeError(`URL method '${name}' is not available.`, node)
}
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
import { CodeModeURL } from "../values.js"
import { Values } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
+10 -18
View File
@@ -34,13 +34,13 @@ export const boundedData = (value: unknown, label: string): unknown => copyIn(va
export const coerceToString = (value: unknown): string => {
if (value === null) return "null"
if (value === undefined) return "undefined"
if (value instanceof CodeModeDate)
if (value instanceof Values.Date)
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
if (value instanceof CodeModeRegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof CodeModeMap) return "[object Map]"
if (value instanceof CodeModeSet) return "[object Set]"
if (value instanceof CodeModeURL) return value.url.href
if (value instanceof CodeModeURLSearchParams) return value.params.toString()
if (value instanceof Values.RegExp) return `/${value.regex.source}/${value.regex.flags}`
if (value instanceof Values.Map) return "[object Map]"
if (value instanceof Values.Set) return "[object Set]"
if (value instanceof Values.URL) return value.url.href
if (value instanceof Values.URLSearchParams) return value.params.toString()
if (errorBrandName(value) !== undefined) {
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
const error = value as { name?: unknown; message?: unknown }
@@ -59,8 +59,8 @@ export const coerceToString = (value: unknown): string => {
}
export const coerceToNumber = (value: unknown): number => {
if (value instanceof CodeModeDate) return value.time
if (isCodeModeValue(value)) return Number.NaN
if (value instanceof Values.Date) return value.time
if (Values.isValue(value)) return Number.NaN
// Arrays coerce through our own string coercion: host Number(array) joins with host
// ToPrimitive, which throws on the null-prototype objects the interpreter produces.
if (Array.isArray(value)) return Number(coerceToString(value))
@@ -77,7 +77,7 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
const raw = args[0]
// Error values are plain SafeObjects; the boundedData path below would strip their brand.
if (ref.name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
if (isCodeModeValue(raw)) {
if (Values.isValue(raw)) {
if (ref.name === "Boolean") return true
if (ref.name === "Number") return coerceToNumber(raw)
if (ref.name === "String") return coerceToString(raw)
@@ -103,12 +103,4 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
}
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
import { copyIn, type SafeObject } from "../tool-runtime.js"
import {
isCodeModeValue,
CodeModeDate,
CodeModeMap,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "../values.js"
import { Values } from "../values.js"
+20 -28
View File
@@ -12,15 +12,7 @@ import {
import { isNamespace, type Namespace } from "./namespace.js"
import { isTool, type Tool } from "./tool.js"
import type { Tools } from "./tools.js"
import {
CodeModeDate,
CodeModeMap,
CodeModePromise,
CodeModeRegExp,
CodeModeSet,
CodeModeURL,
CodeModeURLSearchParams,
} from "./values.js"
import { Values } from "./values.js"
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0)
@@ -151,7 +143,7 @@ const copyBounded = (
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
}
if (value instanceof CodeModePromise) {
if (value instanceof Values.Promise) {
throw new ToolRuntimeError(
"InvalidDataValue",
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
@@ -160,46 +152,46 @@ const copyBounded = (
if (preserveCodeModeValues) {
if (
value instanceof CodeModeDate ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURL ||
value instanceof CodeModeURLSearchParams
value instanceof Values.Date ||
value instanceof Values.RegExp ||
value instanceof Values.Map ||
value instanceof Values.Set ||
value instanceof Values.URL ||
value instanceof Values.URLSearchParams
) {
return value
}
if (value instanceof Date) return new CodeModeDate(value.getTime())
if (value instanceof RegExp) return new CodeModeRegExp(value.source, value.flags)
if (value instanceof Date) return new Values.Date(value.getTime())
if (value instanceof RegExp) return new Values.RegExp(value.source, value.flags)
if (value instanceof Map) {
const wrapped = new CodeModeMap()
const wrapped = new Values.Map()
for (const [key, item] of value.entries()) {
wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true))
}
return wrapped
}
if (value instanceof Set) {
const wrapped = new CodeModeSet()
const wrapped = new Values.Set()
for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
return wrapped
}
if (value instanceof URL) return new CodeModeURL(new URL(value.href))
if (value instanceof URLSearchParams) return new CodeModeURLSearchParams(new URLSearchParams(value))
if (value instanceof URL) return new Values.URL(new URL(value.href))
if (value instanceof URLSearchParams) return new Values.URLSearchParams(new URLSearchParams(value))
}
if (value instanceof CodeModeDate) {
if (value instanceof Values.Date) {
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
}
if (value instanceof Date) {
return Number.isFinite(value.getTime()) ? value.toISOString() : null
}
if (value instanceof CodeModeURL) return value.url.href
if (value instanceof Values.URL) return value.url.href
if (value instanceof URL) return value.href
if (
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURLSearchParams ||
value instanceof Values.RegExp ||
value instanceof Values.Map ||
value instanceof Values.Set ||
value instanceof Values.URLSearchParams ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set ||
+30 -24
View File
@@ -1,17 +1,24 @@
export * as Values from "./values.js"
import type { Fiber } from "effect"
export class CodeModePromise {
/**
* Runtime values the interpreter recognizes by class. Each wraps the host value it stands for,
* so hosts construct these to hand a value to a program and receive them back unchanged.
*/
export class Promise {
constructor(readonly fiber: Fiber.Fiber<unknown, unknown>) {}
}
export class CodeModeDate {
export class Date {
constructor(public time: number) {}
}
export class CodeModeRegExp {
readonly regex: RegExp
export class RegExp {
readonly regex: globalThis.RegExp
constructor(pattern: string, flags: string) {
this.regex = new RegExp(pattern, flags)
this.regex = new globalThis.RegExp(pattern, flags)
}
get lastIndex(): unknown {
@@ -23,31 +30,30 @@ export class CodeModeRegExp {
}
}
export class CodeModeMap {
readonly map = new Map<unknown, unknown>()
export class Map {
readonly map = new globalThis.Map<unknown, unknown>()
}
export class CodeModeSet {
readonly set = new Set<unknown>()
export class Set {
readonly set = new globalThis.Set<unknown>()
}
export class CodeModeURLSearchParams {
constructor(readonly params: URLSearchParams) {}
export class URLSearchParams {
constructor(readonly params: globalThis.URLSearchParams) {}
}
export class CodeModeURL {
readonly searchParams: CodeModeURLSearchParams
constructor(readonly url: URL) {
this.searchParams = new CodeModeURLSearchParams(url.searchParams)
export class URL {
readonly searchParams: URLSearchParams
constructor(readonly url: globalThis.URL) {
this.searchParams = new URLSearchParams(url.searchParams)
}
}
export const isCodeModeValue = (
value: unknown,
): value is CodeModeDate | CodeModeRegExp | CodeModeMap | CodeModeSet | CodeModeURL | CodeModeURLSearchParams =>
value instanceof CodeModeDate ||
value instanceof CodeModeRegExp ||
value instanceof CodeModeMap ||
value instanceof CodeModeSet ||
value instanceof CodeModeURL ||
value instanceof CodeModeURLSearchParams
/** Data-like runtime values; excludes Promise, which never crosses a boundary. */
export const isValue = (value: unknown): value is Date | RegExp | Map | Set | URL | URLSearchParams =>
value instanceof Date ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set ||
value instanceof URL ||
value instanceof URLSearchParams
+1 -1
View File
@@ -26,7 +26,7 @@ export const Plugin = define({
directory: AbsolutePath.make(
directory.startsWith("~/")
? path.join(global.home, directory.slice(2))
: path.resolve(entry.path ? path.dirname(entry.path) : location.directory, directory),
: path.resolve(location.project.canonical, directory),
),
})
}
+2
View File
@@ -25,6 +25,7 @@ import { OpenAICompatiblePlugin } from "./provider/openai-compatible.js"
import { OpencodePlugin } from "./provider/opencode.js"
import { OpenRouterPlugin } from "./provider/openrouter.js"
import { PerplexityPlugin } from "./provider/perplexity.js"
import { PoePlugin } from "./provider/poe.js"
import { SapAICorePlugin } from "./provider/sap-ai-core.js"
import { VercelPlugin } from "./provider/vercel.js"
import { VenicePlugin } from "./provider/venice.js"
@@ -60,6 +61,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
OpenAIPlugin,
OpenRouterPlugin,
PerplexityPlugin,
PoePlugin,
SapAICorePlugin,
VercelPlugin,
VenicePlugin,
+101 -18
View File
@@ -1,19 +1,25 @@
import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import { Duration, Effect, Equal, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import { define } from "@opencode/plugin/effect/plugin"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { Money } from "@opencode/schema/money"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ providers: Schema.Record(Schema.String, ConfigProvider.Info) })
const RemoteResponse = Schema.Struct({
providers: Schema.Record(Schema.String, ConfigProvider.Info),
websearch: Schema.Struct({
providerID: WebSearch.ID,
}).pipe(Schema.optional),
})
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -61,10 +67,9 @@ function oauth(http: HttpClient.HttpClient) {
}),
refresh: (credential) =>
Effect.gen(function* () {
const server = typeof credential.metadata?.server === "string" ? credential.metadata.server : defaultServer
const token = yield* post(
http,
`${server}/auth/device/token`,
`${serverUrl(credential)}/auth/device/token`,
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
Token,
)
@@ -85,22 +90,25 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const bus = yield* Bus.Service
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
let providers: typeof RemoteResponse.Type.providers | undefined
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
let snapshot: {
config: typeof RemoteResponse.Type | undefined
connection: ActiveConnection
} = { config: undefined, connection: undefined }
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
connected = connection !== undefined
providers = credential
? yield* fetchProviders(http, credential).pipe(
const config = credential
? yield* fetchConfig(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
return { config, connection }
})
yield* ctx.integration.transform((editor) => {
@@ -111,9 +119,9 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
editor.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
})
yield* load()
snapshot = yield* load()
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
const source = catalog.provider.get(item.canonical ?? providerID)
catalog.provider.update(providerID, (provider) => {
if (source && source.provider !== provider)
@@ -183,7 +191,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const item = catalog.provider.get(Provider.ID.opencode)
if (!item) return
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
const hasKey = Boolean(process.env.OPENCODE_API_KEY || snapshot.connection || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) {
provider.activation = "enabled"
@@ -199,23 +207,95 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* ctx.websearch.transform((editor) => {
const descriptor = snapshot.config?.websearch
const connection = snapshot.connection
if (!descriptor || !connection) return
editor.add({
id: descriptor.providerID,
name: "OpenCode Web Search",
execute: (input) =>
Effect.gen(function* () {
const active = yield* ctx.integration.connection.active("opencode")
if (
!active ||
(connection.type === "credential"
? active.type !== "credential" || active.id !== connection.id
: active.type !== "env" || active.name !== connection.name)
) {
return yield* Effect.fail(new Error("OpenCode Console connection changed"))
}
const credential = yield* ctx.integration.connection.resolve(active)
if (!credential) return yield* Effect.fail(new Error("OpenCode Console is not connected"))
const metadata = credential.metadata
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
const token = credential.type === "oauth" ? credential.access : credential.key
const server = yield* normalizeServer(serverUrl(credential))
const request = yield* HttpClientRequest.post(`${server}/api/websearch`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
HttpClientRequest.schemaBodyJson(WebSearch.Input)({
query: input.query,
providerID: descriptor.providerID,
}),
)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
.execute(request)
.pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.flatMap(HttpClientResponse.schemaBodyJson(WebSearch.Response)),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("OpenCode web search request timed out")),
}),
)
if (response.providerID !== descriptor.providerID) {
return yield* Effect.fail(
new Error(
`OpenCode web search returned provider ${response.providerID} instead of ${descriptor.providerID}`,
),
)
}
return response.results
}),
})
editor.default.set(descriptor.providerID)
})
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
snapshot = next
yield* Effect.all([ctx.catalog.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
// Console config can change independently of local credential activity, so re-fetch
// periodically and only rebuild the catalog and search providers when the snapshot differs.
yield* Effect.sleep(Duration.minutes(10)).pipe(
Effect.andThen(
loading.withPermit(
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
),
),
Effect.forever,
Effect.forkScoped,
)
}),
})
function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
const metadata = value.metadata
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
const token = value.type === "oauth" ? value.access : value.key
return http
.execute(
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
@@ -226,12 +306,15 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.providers),
)
}),
)
}
function serverUrl(value: Credential.Value) {
return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer
}
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
return (
body &&
+163
View File
@@ -0,0 +1,163 @@
import { define } from "@opencode/plugin/effect/plugin"
import { Clock, Deferred, Effect, Option, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import type { ServerResponse } from "node:http"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { OauthCallbackPage } from "../../oauth/page.js"
const integrationID = Integration.ID.make("poe")
const methodID = Integration.MethodID.make("browser")
const clientID = "client_728290227fc048cc9262091a1ea197ea"
const issuer = "https://poe.com"
const maxExpiry = 8_640_000_000_000_000
const Token = Schema.Struct({
api_key: Schema.Trim.check(Schema.isNonEmpty(), Schema.isPattern(/^\S+$/)),
api_key_expires_in: Schema.optional(Schema.NullOr(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)))),
})
const decodeError = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({ error: Schema.optional(Schema.String), error_description: Schema.optional(Schema.String) }),
),
)
export const PoePlugin = define({
id: "opencode.provider.poe",
effect: Effect.fn(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Login with Poe (browser)" },
// Poe-issued API keys remain usable until expiry, then require another login.
refresh: (value) =>
Clock.currentTimeMillis.pipe(
Effect.flatMap((now) =>
value.expires > now
? Effect.succeed(value)
: Effect.fail(new Error("Poe API key expired. Log in with Poe again.")),
),
),
authorize: () =>
Effect.gen(function* () {
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const challenge = Buffer.from(
yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
).toString("base64url")
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const callback = yield* Deferred.make<{ code: string; response: ServerResponse }, Error>()
const { createServer } = yield* Effect.promise(() => import("node:http"))
const { EventEmitter } = yield* Effect.promise(() => import("node:events"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (request.method !== "GET" || url.pathname !== "/callback") {
response.writeHead(404).end()
return
}
const error = callbackError(url.searchParams, state)
if (error) {
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "Poe" }))
Effect.runSync(Deferred.fail(callback, new Error(error)))
return
}
if (!Effect.runSync(Deferred.succeed(callback, { code: url.searchParams.get("code") ?? "", response })))
response.writeHead(409).end("OAuth callback already received")
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
server.close()
server.closeAllConnections()
}),
)
yield* Effect.tryPromise(() => EventEmitter.once(server.listen(0, "127.0.0.1"), "listening"))
const address = server.address()
if (!address || typeof address === "string")
return yield* Effect.fail(new Error("Missing OAuth callback port"))
const redirect = `http://127.0.0.1:${address.port}/callback`
return {
mode: "auto" as const,
url: `${issuer}/oauth/authorize?${new URLSearchParams({
response_type: "code",
client_id: clientID,
redirect_uri: redirect,
scope: "apikey:create",
code_challenge: challenge,
code_challenge_method: "S256",
state,
}).toString()}`,
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Effect.gen(function* () {
const request = yield* Deferred.await(callback)
const respond = (error?: string) =>
Effect.sync(() =>
request.response
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
.end(
error
? OauthCallbackPage.error(error, { provider: "Poe" })
: OauthCallbackPage.success({ provider: "Poe" }),
),
)
return yield* exchangeCode(http, { code: request.code, redirect, verifier }).pipe(
Effect.tap(() => respond()),
Effect.tapError((error) => respond(error.message)),
// Bun's server.closeAllConnections() leaves an unanswered callback response pending.
Effect.onInterrupt(() => Effect.sync(() => request.response.destroy())),
)
}),
}
}),
})
})
}),
})
function callbackError(params: URLSearchParams, state: string) {
if (params.get("state") !== state) return "Invalid OAuth state"
// Poe's client pins this issuer but does not require iss; its documented callbacks may omit it.
if (params.has("iss") && params.get("iss") !== issuer) return "Invalid OAuth issuer"
if (params.has("error")) {
const detail = params.get("error_description") || params.get("error") || "Authorization denied"
return detail.includes(state) ? "Poe authorization failed" : detail
}
return params.get("code")?.trim() ? undefined : "Missing authorization code"
}
function exchangeCode(http: HttpClient.HttpClient, input: { code: string; redirect: string; verifier: string }) {
return Effect.gen(function* () {
const response = yield* http
.execute(
HttpClientRequest.post("https://api.poe.com/token").pipe(
HttpClientRequest.bodyUrlParams({
grant_type: "authorization_code",
client_id: clientID,
code: input.code,
redirect_uri: input.redirect,
code_verifier: input.verifier,
}),
),
)
.pipe(Effect.mapError(() => new Error("Poe token exchange request failed")))
if (response.status < 200 || response.status >= 300) {
const error = Option.getOrUndefined(decodeError(yield* response.text.pipe(Effect.orElseSucceed(() => ""))))
const detail = error?.error_description || error?.error
return yield* Effect.fail(
new Error(
detail && ![input.code, input.verifier].some((secret) => detail.includes(secret))
? `Poe token exchange failed: ${detail}`
: `Poe token exchange failed (${response.status})`,
),
)
}
const token = yield* HttpClientResponse.schemaBodyJson(Token)(response).pipe(
Effect.mapError(() => new Error("Invalid Poe token response")),
)
const expires =
token.api_key_expires_in == null ? maxExpiry : (yield* Clock.currentTimeMillis) + token.api_key_expires_in * 1000
if (!Number.isSafeInteger(expires) || expires > maxExpiry)
return yield* Effect.fail(new Error("Invalid Poe API key expiry"))
return Credential.OAuth.make({ type: "oauth", methodID, access: token.api_key, refresh: "", expires })
})
}
-2
View File
@@ -254,8 +254,6 @@ const layer = Layer.effect(
})
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
const result = { directory: yield* canonical(fs, created.directory) }
if (result.directory !== (yield* canonical(fs, worktreeDirectory)))
return yield* new InvalidDirectoryError({ directory: result.directory })
yield* changed(
yield* ops.create({
directory: result.directory,
@@ -3,7 +3,8 @@ import { LLM } from "@opencode/ai"
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
import { Money } from "@opencode/schema/money"
import { Effect, Layer, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { TestClock } from "effect/testing"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Catalog } from "@opencode/core/catalog"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
@@ -13,7 +14,9 @@ import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { Provider } from "@opencode/core/provider"
import { WebSearch } from "@opencode/core/websearch"
import { withEnv } from "../fixture/env"
import { drain } from "../lib/clock"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -380,6 +383,409 @@ describe("OpencodePlugin", () => {
),
)
it.effect("refreshes hosted search with Console config and skips unchanged snapshots", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { advertised: false, requests: 0 }
const server = Bun.serve({
port: 0,
fetch: () => {
state.requests++
return Response.json({
providers: {},
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
})
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const websearch = yield* WebSearch.Service
const rebuilds = { catalog: 0, websearch: 0 }
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: server.url.origin } }),
})
yield* catalog.transform(() => {
rebuilds.catalog++
})
yield* websearch.transform(() => {
rebuilds.websearch++
})
yield* addPlugin()
yield* drain
const initial = { ...rebuilds }
expect(state.requests).toBe(1)
expect(yield* websearch.default()).toBeUndefined()
state.advertised = true
yield* TestClock.adjust("9 minutes")
yield* drain
expect(state.requests).toBe(1)
expect(rebuilds).toEqual(initial)
expect(yield* websearch.default()).toBeUndefined()
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(2)
expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
yield* TestClock.adjust("10 minutes")
yield* drain
expect(state.requests).toBe(3)
expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
state.advertised = false
yield* TestClock.adjust("10 minutes")
yield* drain
expect(state.requests).toBe(4)
expect(rebuilds).toEqual({ catalog: initial.catalog + 2, websearch: initial.websearch + 2 })
expect(yield* websearch.providers()).toEqual([])
expect(yield* websearch.default()).toBeUndefined()
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("loads and executes hosted web search from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: Array<{
method: string
path: string
authorization: string | null
orgID: string | null
body?: unknown
}> = []
const gate = Promise.withResolvers<void>()
const state = { advertised: true, providerID: "opencode", waitForConfig: false }
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const path = new URL(request.url).pathname
const body = request.method === "POST" ? await request.json() : undefined
requests.push({
method: request.method,
path,
authorization: request.headers.get("authorization"),
orgID: request.headers.get("x-org-id"),
...(body === undefined ? {} : { body }),
})
if (path === "/api/v2/config") {
if (state.waitForConfig) await gate.promise
return Response.json({
providers: {},
...(state.advertised
? {
websearch: {
providerID: "opencode",
},
}
: {}),
})
}
if (path === "/api/websearch" || path === "/other/api/websearch") {
return Response.json({
providerID: state.providerID,
results: [
{
url: "https://github.com/anomalyco/opencode",
title: "OpenCode",
content: "Open source AI coding agent.",
time: { published: 1_700_000_000_000 },
},
],
})
}
return new Response("Not found", { status: 404 })
},
})
return { gate, requests, server, state }
}),
({ gate, requests, server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
const account = (access: string, serverURL = server.url.origin, orgID = "org_test") =>
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access,
refresh: "refresh",
expires: Date.now() + 600_000,
metadata: { server: serverURL, orgID },
})
const initial = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: account("secret"),
})
yield* addPlugin()
expect(yield* websearch.providers()).toContainEqual({
id: WebSearch.ID.make("opencode"),
name: "OpenCode Web Search",
})
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
expect(yield* websearch.query({ query: "effect web search" })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("opencode"),
results: [
{
url: "https://github.com/anomalyco/opencode",
title: "OpenCode",
content: "Open source AI coding agent.",
time: { published: 1_700_000_000_000 },
},
],
}),
)
expect(requests).toEqual([
{
method: "GET",
path: "/api/v2/config",
authorization: "Bearer secret",
orgID: "org_test",
},
{
method: "POST",
path: "/api/websearch",
authorization: "Bearer secret",
orgID: "org_test",
body: { query: "effect web search", providerID: "opencode" },
},
])
yield* credentials.update(initial.id, {
value: account("replacement"),
})
yield* websearch.query({ query: "fresh credential" })
expect(requests.at(-1)).toMatchObject({
method: "POST",
authorization: "Bearer replacement",
body: { query: "fresh credential", providerID: "opencode" },
})
yield* credentials.update(initial.id, {
value: account("moved", `${server.url.origin}/other///?ignored=true#ignored`),
})
yield* websearch.query({ query: "updated server" })
expect(requests.at(-1)).toMatchObject({
method: "POST",
path: "/other/api/websearch",
authorization: "Bearer moved",
orgID: "org_test",
body: { query: "updated server", providerID: "opencode" },
})
yield* credentials.update(initial.id, {
value: account("replacement"),
})
state.providerID = "unexpected"
expect((yield* websearch.query({ query: "wrong provider" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
state.advertised = false
state.waitForConfig = true
const searchCount = requests.filter((request) => request.path === "/api/websearch").length
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: account("switched", server.url.origin, "org_switched"),
})
yield* eventually(
Effect.sync(() => requests),
(requests) => requests.some((request) => request.authorization === "Bearer switched"),
)
expect((yield* websearch.query({ query: "switch race" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
expect(requests.filter((request) => request.path === "/api/websearch")).toHaveLength(searchCount)
gate.resolve()
yield* eventually(websearch.providers(), (providers) =>
providers.every((provider) => provider.id !== WebSearch.ID.make("opencode")),
)
expect(yield* websearch.default()).toBeUndefined()
expect(requests.at(-1)).toMatchObject({
method: "GET",
path: "/api/v2/config",
authorization: "Bearer switched",
orgID: "org_switched",
})
}),
({ gate, server }) =>
Effect.sync(() => gate.resolve()).pipe(Effect.andThen(Effect.promise(() => server.stop(true)))),
),
)
it.live("derives hosted search identity and the default Console endpoint locally", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) => {
if (new URL(request.url).pathname === "/console/api/v2/config") {
return Response.json({
providers: {},
websearch: {
providerID: "managed-search",
name: "Remote name",
url: "https://example.invalid/search",
},
})
}
return Response.json({ providerID: "managed-search", results: [] })
},
}),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
const http = yield* HttpClient.HttpClient
const requests: string[] = []
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret" }),
})
yield* addPlugin().pipe(
Effect.provideService(
HttpClient.HttpClient,
http.pipe(
HttpClient.mapRequest((request) => {
requests.push(request.url)
return HttpClientRequest.setUrl(request, `${server.url.origin}${new URL(request.url).pathname}`)
}),
),
),
)
expect(yield* websearch.default()).toEqual({
id: WebSearch.ID.make("managed-search"),
name: "OpenCode Web Search",
})
expect(yield* websearch.query({ query: "default Console" })).toEqual(
new WebSearch.Response({ providerID: WebSearch.ID.make("managed-search"), results: [] }),
)
expect(requests).toEqual([
"https://opencode.ai/console/api/v2/config",
"https://opencode.ai/console/api/websearch",
])
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.live("does not forward hosted search credentials through redirects", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const requests: string[] = []
const state = { crossOrigin: false }
const server = Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/console/api/v2/config") {
return Response.json({
providers: {},
websearch: {
providerID: "opencode",
},
})
}
if (url.pathname === "/console/api/websearch") {
if (state.crossOrigin) url.hostname = "127.0.0.1"
return Response.redirect(`${url.origin}/outside-console`, 307)
}
return Response.json({ providerID: "opencode", results: [] })
},
})
return { requests, server, state }
}),
({ requests, server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: `${server.url.origin}/console`, orgID: "org_test" },
}),
})
yield* addPlugin()
expect((yield* websearch.query({ query: "private search" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
expect(requests).toEqual(["/console/api/v2/config", "/console/api/websearch"])
state.crossOrigin = true
expect((yield* websearch.query({ query: "private search" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
expect(requests).toEqual(["/console/api/v2/config", "/console/api/websearch", "/console/api/websearch"])
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("closes a rejected hosted search response without waiting for its body", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { cancelled: false }
const server = Bun.serve({
port: 0,
fetch: (request) => {
const url = new URL(request.url)
if (url.pathname === "/api/v2/config") {
return Response.json({
providers: {},
websearch: {
providerID: "opencode",
},
})
}
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("temporarily unavailable"))
},
cancel() {
state.cancelled = true
},
}),
{ status: 503 },
)
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const websearch = yield* WebSearch.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: server.url.origin, orgID: "org_test" },
}),
})
yield* addPlugin()
const error = yield* websearch.query({ query: "rejected search" }).pipe(Effect.flip)
expect(error._tag).toBe("WebSearch.Request")
yield* eventually(
Effect.sync(() => state.cancelled),
(cancelled) => cancelled,
)
// Callers can retain errors, so response cleanup must not depend on garbage collection.
expect(error).toBeInstanceOf(WebSearch.RequestError)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("preserves native Console OpenAI variant bodies in inference requests", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
@@ -0,0 +1,334 @@
import { LLM } from "@opencode/ai"
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
import { Catalog } from "@opencode/core/catalog"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { Model } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { ProviderPlugins } from "@opencode/core/plugin/provider"
import { PoePlugin } from "@opencode/core/plugin/provider/poe"
import { Provider } from "@opencode/core/provider"
import { expect } from "bun:test"
import { Clock, Deferred, Effect, Fiber, Layer, Schedule, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const integrationID = Integration.ID.make("poe")
const providerID = Provider.ID.make("poe")
const methodID = Integration.MethodID.make("browser")
const modelID = Model.ID.make("test-model")
const fixture = Effect.gen(function* () {
const requests: Request[] = []
const replies: (Response | Effect.Effect<Response>)[] = []
const http = HttpClient.make((request) =>
Effect.gen(function* () {
requests.push(yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie))
const response = replies.shift()
if (!response) throw new Error(`Unexpected request: ${request.url}`)
return HttpClientResponse.fromWeb(request, yield* Effect.isEffect(response) ? response : Effect.succeed(response))
}),
)
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
yield* integrations.transform((editor) => {
editor.method.update({ integrationID, method: { type: "key" } })
editor.method.update({ integrationID, method: { type: "env", names: ["POE_API_KEY"] } })
})
yield* catalog.transform((editor) => {
editor.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://api.poe.com/v1" }
})
editor.model.update(providerID, modelID, () => {})
})
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* PoePlugin.effect(host).pipe(Effect.provideService(HttpClient.HttpClient, http))
const status = (attemptID: Integration.AttemptID) =>
integrations.oauth.status({ integrationID, attemptID }).pipe(
Effect.repeat({
until: (value) => value.status !== "pending",
schedule: Schedule.spaced("1 millis"),
times: 100,
}),
)
const connect = Effect.gen(function* () {
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Poe browser" })
const url = new URL(attempt.url)
const callback = new URL(url.searchParams.get("redirect_uri") ?? "")
callback.searchParams.set("state", url.searchParams.get("state") ?? "")
callback.searchParams.set("code", "auth-code")
return { attempt, url, callback }
})
const send = Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const resolved = yield* resolver.resolve(Model.Ref.make({ providerID, id: modelID }))
if (!resolved) throw new Error("Expected Poe model")
expect(resolved.model.route.id).toBe("openai-compatible-chat")
return yield* LLMClient.stream(LLM.request({ model: resolved.model, prompt: "Hello" })).pipe(
Stream.runCollect,
Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer), Layer.fresh)),
Effect.provideService(HttpClient.HttpClient, http),
)
}).pipe(Effect.provide(ModelResolver.layer))
return { requests, replies, integrations, credentials, status, connect, send }
})
it.effect("registers Poe browser OAuth alongside generic key and environment methods without fetching", () =>
Effect.gen(function* () {
const test = yield* fixture
expect(ProviderPlugins).toContain(PoePlugin)
expect((yield* test.integrations.get(integrationID))?.methods).toEqual([
{ type: "key" },
{ type: "env", names: ["POE_API_KEY"] },
{ id: methodID, type: "oauth", label: "Login with Poe (browser)" },
])
expect(test.requests).toHaveLength(0)
}),
)
for (const expiry of [3600, null, undefined]) {
it.live(`exchanges a PKCE code for a native Poe credential (expiry: ${expiry})`, () =>
Effect.gen(function* () {
const test = yield* fixture
yield* test.integrations.connection.key({ integrationID, key: "previous-key", label: "Previous account" })
const previous = yield* test.integrations.connection.active(integrationID)
const login = yield* test.connect
expect(yield* test.integrations.connection.active(integrationID)).toEqual(previous)
const url = login.url
expect(url.origin + url.pathname).toBe("https://poe.com/oauth/authorize")
expect(Object.fromEntries(url.searchParams)).toMatchObject({
response_type: "code",
client_id: "client_728290227fc048cc9262091a1ea197ea",
scope: "apikey:create",
code_challenge_method: "S256",
})
const callback = login.callback
expect(callback.hostname).toBe("127.0.0.1")
expect(callback.pathname).toBe("/callback")
expect(url.searchParams.get("state")).toBeTruthy()
// Both issuer-bearing and documented issuer-less callbacks must work.
if (expiry != null) callback.searchParams.set("iss", "https://poe.com")
test.replies.push(Response.json({ api_key: " poe-key ", api_key_expires_in: expiry }))
const now = Date.now()
expect((yield* Effect.promise(() => fetch(callback, { headers: { Connection: "close" } }))).status).toBe(200)
expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete")
const exchange = test.requests[0]
expect(exchange.url).toBe("https://api.poe.com/token")
expect(exchange.headers.get("content-type")).toContain("application/x-www-form-urlencoded")
const form = new URLSearchParams(yield* Effect.promise(() => exchange.text()))
expect(Object.fromEntries(form)).toMatchObject({
grant_type: "authorization_code",
client_id: "client_728290227fc048cc9262091a1ea197ea",
code: "auth-code",
redirect_uri: url.searchParams.get("redirect_uri"),
})
expect(url.searchParams.get("code_challenge")).toBe(
Buffer.from(
yield* Effect.promise(() =>
crypto.subtle.digest("SHA-256", new TextEncoder().encode(form.get("code_verifier") ?? "")),
),
).toString("base64url"),
)
const records = yield* test.credentials.list(integrationID)
const active = records.find((credential) => credential.label === "Poe browser")
expect(records).toHaveLength(2)
expect(yield* test.integrations.connection.active(integrationID)).toMatchObject({
type: "credential",
id: active?.id,
label: "Poe browser",
})
const saved = active?.value
if (saved?.type !== "oauth") throw new Error("Expected OAuth credential")
expect(saved.access).toBe("poe-key")
expect(saved.refresh).toBe("")
if (expiry == null) expect(saved.expires).toBe(8_640_000_000_000_000)
if (expiry != null) {
expect(saved.expires).toBeGreaterThanOrEqual(now + expiry * 1000)
expect(saved.expires).toBeLessThanOrEqual(Date.now() + expiry * 1000)
}
test.replies.push(
new Response(
'data: {"choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
{
headers: { "Content-Type": "text/event-stream" },
},
),
)
expect(yield* test.send).toContainEqual(expect.objectContaining({ type: "text-delta", text: "Hello" }))
expect(test.requests[1].url).toBe("https://api.poe.com/v1/chat/completions")
expect(test.requests[1].headers.get("authorization")).toBe("Bearer poe-key")
yield* test.integrations.oauth.complete({ integrationID, attemptID: login.attempt.attemptID })
expect(yield* test.credentials.list(integrationID)).toEqual(records)
expect(test.requests).toHaveLength(2)
}),
)
}
it.live("isolates overlapping login attempts and closes cancelled listeners", () =>
Effect.gen(function* () {
const test = yield* fixture
const first = yield* test.connect
const next = yield* test.connect
expect(first.callback.origin).not.toBe(next.callback.origin)
expect(first.url.searchParams.get("code_challenge")).not.toBe(next.url.searchParams.get("code_challenge"))
expect(first.url.searchParams.get("state")).not.toBe(next.url.searchParams.get("state"))
first.callback.searchParams.set("state", next.url.searchParams.get("state") ?? "")
expect((yield* Effect.promise(() => fetch(first.callback, { headers: { Connection: "close" } }))).status).toBe(400)
expect(yield* test.status(first.attempt.attemptID)).toMatchObject({
status: "failed",
message: "Invalid OAuth state",
})
expect((yield* test.integrations.oauth.status({ integrationID, attemptID: next.attempt.attemptID })).status).toBe(
"pending",
)
yield* test.integrations.oauth.cancel({ integrationID, attemptID: next.attempt.attemptID })
expect((yield* Effect.tryPromise(() => fetch(next.callback)).pipe(Effect.exit))._tag).toBe("Failure")
expect(test.requests).toHaveLength(0)
expect(yield* test.credentials.list(integrationID)).toEqual([])
}),
)
for (const invalid of [
{ params: { state: "" }, message: "Invalid OAuth state" },
{ params: { iss: "https://other.example", error: "access_denied" }, message: "Invalid OAuth issuer" },
{ params: { iss: "https://poe.com/" }, message: "Invalid OAuth issuer" },
{ params: { iss: "" }, message: "Invalid OAuth issuer" },
{ params: { code: "" }, message: "Missing authorization code" },
{ params: { error: "access_denied", error_description: "User declined access" }, message: "User declined access" },
]) {
it.live(`rejects invalid or denied callbacks (${JSON.stringify(invalid.params)})`, () =>
Effect.gen(function* () {
const test = yield* fixture
const login = yield* test.connect
Object.entries(invalid.params).forEach(([key, value]) => login.callback.searchParams.set(key, value))
const response = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } }))
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.text())).toContain("Authorization failed")
expect(yield* test.status(login.attempt.attemptID)).toMatchObject({ status: "failed", message: invalid.message })
expect(test.requests).toHaveLength(0)
expect(yield* test.credentials.list(integrationID)).toEqual([])
}),
)
}
for (const response of [
{
status: 400,
body: JSON.stringify({ error: "invalid_grant", error_description: "Code expired" }),
message: "Poe token exchange failed: Code expired",
},
{
status: 400,
body: JSON.stringify({ error: "invalid_grant" }),
message: "Poe token exchange failed: invalid_grant",
},
{ status: 502, body: "Bad gateway", message: "Poe token exchange failed (502)" },
{
status: 400,
body: JSON.stringify({ error_description: "Rejected auth-code" }),
message: "Poe token exchange failed (400)",
},
...[
"{}",
"{",
'{"api_key":" "}',
'{"api_key":"poe-key","api_key_expires_in":-1}',
'{"api_key":"poe-key","api_key_expires_in":1.5}',
'{"api_key":"poe-key","api_key_expires_in":1e309}',
].map((body) => ({ status: 200, body, message: "Invalid Poe token response" })),
{
status: 200,
body: '{"api_key":"poe-key","api_key_expires_in":8640000000000}',
message: "Invalid Poe API key expiry",
},
]) {
it.live(`preserves the active connection after a failed token exchange (${response.status}: ${response.body})`, () =>
Effect.gen(function* () {
const test = yield* fixture
yield* test.integrations.connection.key({ integrationID, key: "previous-key" })
const previous = yield* test.integrations.connection.active(integrationID)
const saved = yield* test.credentials.list(integrationID)
const login = yield* test.connect
test.replies.push(new Response(response.body, { status: response.status }))
const page = yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } }))
expect(page.status).toBe(400)
expect(yield* Effect.promise(() => page.text())).toContain(response.message)
expect(yield* test.status(login.attempt.attemptID)).toMatchObject({ status: "failed", message: response.message })
expect(yield* test.credentials.list(integrationID)).toEqual(saved)
expect(yield* test.integrations.connection.active(integrationID)).toEqual(previous)
}),
)
}
for (const cancel of [false, true]) {
it.live(`waits for the token exchange and consumes the callback once (cancel: ${cancel})`, () =>
Effect.gen(function* () {
const test = yield* fixture
const login = yield* test.connect
const started = yield* Deferred.make<void>()
const token = yield* Deferred.make<Response>()
test.replies.push(Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(token))))
const page = yield* Effect.tryPromise(() => fetch(login.callback, { headers: { Connection: "close" } })).pipe(
Effect.exit,
Effect.forkScoped,
)
yield* Deferred.await(started)
expect(
(yield* test.integrations.oauth.status({ integrationID, attemptID: login.attempt.attemptID })).status,
).toBe("pending")
expect(yield* test.credentials.list(integrationID)).toEqual([])
expect((yield* Effect.promise(() => fetch(login.callback, { headers: { Connection: "close" } }))).status).toBe(
409,
)
expect(test.requests).toHaveLength(1)
if (cancel) {
yield* test.integrations.oauth.cancel({ integrationID, attemptID: login.attempt.attemptID })
yield* Deferred.succeed(token, Response.json({ api_key: "cancelled-key" }))
expect((yield* Fiber.join(page))._tag).toBe("Failure")
expect(yield* test.credentials.list(integrationID)).toEqual([])
return
}
yield* Deferred.succeed(token, Response.json({ api_key: "poe-key" }))
const result = yield* Fiber.join(page)
const response = yield* result
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.text())).toContain("Authorization successful")
expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete")
expect(yield* test.credentials.list(integrationID)).toHaveLength(1)
}),
)
}
it.effect("keeps near-expiry keys usable and requires a new login after expiry", () =>
Effect.gen(function* () {
const test = yield* fixture
const saved = yield* test.credentials.create({
integrationID,
value: Credential.OAuth.make({
type: "oauth",
methodID,
access: "poe-key",
refresh: "",
expires: (yield* Clock.currentTimeMillis) + 120_000,
}),
})
const connection = { type: "credential" as const, id: saved.id, label: saved.label }
expect(yield* test.integrations.connection.resolve(connection)).toEqual(saved.value)
yield* TestClock.adjust("2 minutes")
const error = yield* test.integrations.connection.resolve(connection).pipe(Effect.flip)
expect(error.cause).toEqual(new Error("Poe API key expired. Log in with Poe again."))
yield* test.integrations.connection.key({ integrationID, key: "manual-key" })
const active = yield* test.integrations.connection.active(integrationID)
if (!active) throw new Error("Expected key connection")
expect(yield* test.integrations.connection.resolve(active)).toEqual({ type: "key", key: "manual-key" })
expect(test.requests).toHaveLength(0)
}),
)
+55 -2
View File
@@ -315,6 +315,15 @@ describe("Worktree", () => {
const bus = yield* Bus.Service
const context = yield* Layer.build(worktreeLayer(selected.directory, selected.id, database, bus, root.path))
const worktrees = Context.get(context, Worktree.Service)
const config = yield* Config.Test
yield* config.setEntries([
new Document({
type: "document",
path: abs(path.join(root.path, "global/opencode.json")),
info: new Info({ worktree: { directory: ".lane/trees" } }),
}),
])
yield* ConfigWorktreePlugin.Plugin.effect(host()).pipe(Effect.provide(context))
yield* projects.update({
projectID: initial.id,
commands: {
@@ -326,11 +335,11 @@ describe("Worktree", () => {
const created = yield* worktrees.create({
strategy: gitWorktree,
from: selected.canonical,
directory: abs(path.join(root.path, "worktrees")),
name: "selected-clone",
})
expect(selected.id).toBe(initial.id)
expect(created.directory).toBe(abs(path.join(clone, ".lane/trees/selected-clone")))
expect((yield* projects.list()).find((project) => project.id === initial.id)?.canonical).toBe(main)
expect(yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(created.directory).text())).toBe(
yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(clone).text()),
@@ -911,7 +920,7 @@ describe("Worktree", () => {
}),
)
const first = yield* worktrees.create({ name: "one" })
expect(first.directory).toBe(abs(path.join(input.root.path, "nested/copies/one")))
expect(first.directory).toBe(abs(path.join(input.root.path, "copies/one")))
expect(yield* stored(input.projectID)).toContainEqual({ directory: first.directory, strategy: "custom" })
yield* config.setEntries(documents.slice(0, 1))
yield* bus.publish(Event.Updated, {})
@@ -925,6 +934,50 @@ describe("Worktree", () => {
expect(third.directory).toBe(abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "three")))
}),
)
;["relative", "absolute", "home"].forEach((mode) => {
it.live(`resolves ${mode} global directory config from a linked checkout's subdirectory`, () =>
Effect.gen(function* () {
const input = yield* setup()
const config = yield* Config.Test
const projects = yield* Project.Service
const global = yield* Global.Service
const worktrees = yield* Worktree.Service
const linked = abs(path.join(input.root.path, "linked"))
const nested = abs(path.join(linked, "src"))
const home = abs(path.join(input.root.path, "home"))
yield* Effect.promise(async () => {
await $`git worktree add ${linked} -b linked`.cwd(input.sourceDirectory).quiet()
await fs.mkdir(nested)
})
const project = yield* projects.resolve(nested)
const directory =
mode === "relative" ? ".lane/trees" : mode === "home" ? "~/copies" : path.join(home, "absolute")
yield* config.setEntries([
new Document({
type: "document",
path: abs(path.join(home, ".config/opencode/opencode.json")),
info: new Info({ worktree: { directory } }),
}),
])
yield* ConfigWorktreePlugin.Plugin.effect(host()).pipe(
Effect.provideService(Location.Service, { directory: nested, project }),
Effect.provideService(Global.Service, { ...global, home }),
)
const created = yield* worktrees.create({ name: "task" })
expect(project.directory).toBe(linked)
expect(project.canonical).toBe(input.sourceDirectory)
expect(created.directory).toBe(
abs(
mode === "relative"
? path.join(input.sourceDirectory, ".lane/trees/task")
: path.join(home, mode === "home" ? "copies/task" : "absolute/task"),
),
)
}),
)
})
it.effect("normalization retains worktree directory and rejects invalid configuration", () =>
Effect.sync(() => {
+2
View File
@@ -1,5 +1,6 @@
export interface WorktreeCreateInput {
readonly sourceDirectory: string
/** Suggested destination after naming and collision handling. Strategies may return a different directory. */
readonly directory: string
/** Starting ref, not the name of a new branch. Reject unsupported refs rather than ignoring them. */
readonly branch?: string
@@ -11,6 +12,7 @@ export interface WorktreeRemoveInput {
}
export interface WorktreeResult {
/** Actual directory created by the strategy, used for inventory and startup commands. */
readonly directory: string
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { Schema } from "effect"
export const Info = Schema.Struct({
directory: Schema.Trim.pipe(Schema.check(Schema.isNonEmpty())).annotate({
description: "Parent directory for new worktrees, relative to the declaring config file when not absolute",
description: "Parent directory for new worktrees, relative to the project's primary checkout when not absolute",
}),
}).annotate({ identifier: "Config.Worktree" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
@@ -0,0 +1,128 @@
import { describe, expect, test, vi } from "bun:test"
import type { ShellOutputInput, ShellOutputOutput } from "@opencode/client/promise"
import { followShellOutput, SHELL_OUTPUT_TAIL_BYTES } from "./shell-output"
const location = { directory: "/repo", project: { id: "project", directory: "/repo", canonical: "/repo" } }
// Serves the current text from the requested cursor, at most one page per request.
function server(text: () => string, page = Infinity) {
const cursors: number[] = []
return {
cursors,
load: (input: ShellOutputInput): Promise<ShellOutputOutput> => {
const cursor = input.cursor ?? 0
cursors.push(cursor)
const full = text()
const output = full.slice(cursor, cursor + page)
return Promise.resolve({
location,
data: { output, cursor: cursor + output.length, size: full.length, truncated: false },
})
},
}
}
// Reads settle within a few microtasks; timers are faked so this never waits on the clock.
async function flush() {
for (let i = 0; i < 20; i++) await Promise.resolve()
}
describe("followShellOutput", () => {
test("stops polling a missing shell and never requests it again", async () => {
vi.useFakeTimers()
try {
const cursors: number[] = []
const load = (input: ShellOutputInput): Promise<ShellOutputOutput> => {
cursors.push(input.cursor ?? 0)
return Promise.reject({ _tag: "ShellNotFoundError", id: input.id, message: "Shell command not found" })
}
const follow = () =>
followShellOutput({ id: "shell_missing", directory: "/repo", running: true, load, onOutput() {} })
const stop = follow()
await flush()
expect(cursors).toEqual([0])
vi.advanceTimersByTime(10_000)
await flush()
expect(cursors).toEqual([0])
stop()
const remounted = follow()
await flush()
vi.advanceTimersByTime(10_000)
remounted()
expect(cursors).toEqual([0])
} finally {
vi.useRealTimers()
}
})
test("polls a running shell once per second and resumes from the cached cursor after a remount", async () => {
vi.useFakeTimers()
try {
let text = "hello\n"
const shell = server(() => text)
const outputs: string[] = []
const follow = (running: boolean) =>
followShellOutput({
id: "shell_live",
directory: "/repo",
running,
load: shell.load,
onOutput: (output) => outputs.push(output),
})
const first = follow(true)
await flush()
expect(outputs.at(-1)).toBe("hello\n")
text += "world\n"
vi.advanceTimersByTime(1_000)
await flush()
expect(shell.cursors).toEqual([0, 6])
expect(outputs.at(-1)).toBe("hello\nworld\n")
first()
text += "done\n"
const second = follow(true)
await flush()
expect(shell.cursors).toEqual([0, 6, 12])
expect(outputs.at(-1)).toBe("hello\nworld\ndone\n")
second()
const exited = follow(false)
await flush()
exited()
expect(shell.cursors).toEqual([0, 6, 12, 17])
outputs.length = 0
follow(false)()
await flush()
expect(shell.cursors).toEqual([0, 6, 12, 17])
expect(outputs).toEqual(["hello\nworld\ndone\n"])
// The running-shell inventory can arrive late; a complete shell believed to be live is re-read from its cursor.
follow(true)()
expect(shell.cursors).toEqual([0, 6, 12, 17, 17])
} finally {
vi.useRealTimers()
}
})
test("keeps only the most recent tail of a large output", async () => {
const shell = server(() => "a".repeat(SHELL_OUTPUT_TAIL_BYTES) + "tail", SHELL_OUTPUT_TAIL_BYTES)
const outputs: string[] = []
followShellOutput({
id: "shell_large",
directory: "/repo",
running: false,
load: shell.load,
onOutput: (output) => outputs.push(output),
})
await flush()
expect(shell.cursors).toEqual([0, SHELL_OUTPUT_TAIL_BYTES])
expect(outputs.at(-1)?.length).toBe(SHELL_OUTPUT_TAIL_BYTES)
expect(outputs.at(-1)?.endsWith("a".repeat(8) + "tail")).toBe(true)
})
})
@@ -0,0 +1,74 @@
import { isShellNotFoundError, type ShellOutputInput, type ShellOutputOutput } from "@opencode/client/promise"
// Same page size as the TUI shell output viewer; only this much recent output is retained and rendered.
export const SHELL_OUTPUT_TAIL_BYTES = 64 * 1024
const PROGRESS_LIMIT = 32
type Progress = { cursor: number; output: string; state: "partial" | "complete" | "missing" }
// Virtualized timelines remount shell tools frequently. Progress is remembered per shell so a
// remount resumes from its cursor instead of re-reading from zero, and so a shell the server no
// longer knows is never requested again.
const progress = new Map<string, Progress>()
function remember(id: string, entry: Progress) {
progress.delete(id)
progress.set(id, entry)
if (progress.size <= PROGRESS_LIMIT) return
const oldest = progress.keys().next().value
if (oldest !== undefined) progress.delete(oldest)
}
export function followShellOutput(input: {
id: string
directory: string
running: boolean
load: (input: ShellOutputInput) => Promise<ShellOutputOutput>
onOutput: (output: string) => void
}) {
const cached = progress.get(input.id)
if (cached) {
remember(input.id, cached)
input.onOutput(cached.output)
}
// A missing shell never comes back. A complete one is only re-read while the shell is believed to be
// running, because the running-shell inventory can arrive after history has already rendered.
if (cached?.state === "missing" || (cached?.state === "complete" && !input.running)) return () => {}
let cursor = cached?.cursor ?? 0
let text = cached?.output ?? ""
let loading = false
let disposed = false
const read = async () => {
if (loading) return
loading = true
while (true) {
const page = await input.load({ id: input.id, location: { directory: input.directory }, cursor }).then(
(response) => response.data,
(cause: unknown) => (isShellNotFoundError(cause) ? "missing" : undefined),
)
if (disposed) break
if (page === "missing") {
// The server drops exited shells past its retention limit; stop asking for this one.
remember(input.id, { cursor, output: text, state: "missing" })
clearInterval(interval)
break
}
if (!page) break
const advanced = page.cursor > cursor
cursor = Math.max(cursor, page.cursor)
text = (text + page.output).slice(-SHELL_OUTPUT_TAIL_BYTES)
const complete = !input.running && cursor >= page.size
remember(input.id, { cursor, output: text, state: complete ? "complete" : "partial" })
input.onOutput(text)
if (input.running || complete || !advanced) break
}
loading = false
}
void read()
// Refresh the final snapshot on exit, but poll only while the shell is live.
const interval = input.running ? setInterval(() => void read(), 1_000) : undefined
return () => {
disposed = true
clearInterval(interval)
}
}
@@ -51,6 +51,7 @@ import {
executeToolFailed,
} from "../message/current-tool-state"
import { AssistantReasoningContent, writeClipboard } from "../message/message-content"
import { followShellOutput } from "./shell-output"
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
@@ -1629,33 +1630,9 @@ ToolRegistry.register({
createEffect(() => {
if (saved() !== undefined) return
const id = props.metadata.shellID
const shellOutput = data.shellOutput
if (typeof id !== "string" || !shellOutput) return
const directory = data.directory
const running = pending()
let cursor = 0
let loading = false
let disposed = false
const load = async () => {
if (loading) return
loading = true
do {
const response = await shellOutput({ id, location: { directory }, cursor }).catch(() => undefined)
if (disposed || !response) break
setStreamed((output) => (cursor === 0 ? response.data.output : output + response.data.output))
if (response.data.cursor <= cursor) break
cursor = response.data.cursor
if (running || cursor >= response.data.size) break
} while (!disposed)
loading = false
}
void load()
// Refresh the final snapshot on exit, but poll only while the shell is live.
const interval = running ? setInterval(() => void load(), 1_000) : undefined
onCleanup(() => {
disposed = true
clearInterval(interval)
})
const load = data.shellOutput
if (typeof id !== "string" || !load) return
onCleanup(followShellOutput({ id, directory: data.directory, running: pending(), load, onOutput: setStreamed }))
})
const command = () => {
if (typeof props.input.command === "string") return props.input.command
+1 -2
View File
@@ -579,7 +579,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () =>
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.size())
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
const fullscreenPanel = () =>
route.data.type === "session" &&
@@ -821,7 +821,6 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "Switch model",
suggested: true,
category: "Agent",
// Bias /mo toward /models over /move without changing global fuzzy scoring.
slash: { name: "models", aliases: ["mo"] },
run: () => {
dialog.replace(() => <DialogModel />)
@@ -458,8 +458,6 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
directory: data.project.get(id)!.canonical,
workspace: workspaceID(),
},
strategy: "git",
directory: path.join(paths.worktree, id.slice(0, 6)),
...(value.trim() ? { name: value.trim() } : {}),
})
.then((created) => {
@@ -20,23 +20,23 @@ import type { WorktreeListOutput } from "@opencode/client"
import { useRoute } from "../context/route"
import { DialogWorktreeName } from "./dialog-worktree-name"
export type MoveSessionSelection =
export type WorkspaceSelection =
| { type: "directory"; directory: string; subdirectory: boolean }
| { type: "new"; name: string }
type ProjectDirectory = WorktreeListOutput[number]
type DialogMoveSessionProps = {
type DialogWorkspacesProps = {
projectID: string
location?: { directory: string; workspaceID?: string }
current?: MoveSessionSelection
onSelect: (selection: MoveSessionSelection) => void
onCurrentChange?: (selection: MoveSessionSelection) => void
current?: WorkspaceSelection
onSelect: (selection: WorkspaceSelection) => void
onCurrentChange?: (selection: WorkspaceSelection) => void
initialDirectories?: ReadonlyArray<ProjectDirectory>
fixture?: boolean
initialRemoving?: string
}
export function DialogMoveSession(props: DialogMoveSessionProps) {
export function DialogWorkspaces(props: DialogWorkspacesProps) {
const dialog = useDialog()
const client = useClient()
const dimensions = useTerminalDimensions()
@@ -60,7 +60,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
function reopen(initialRemoving?: string) {
dialog.replace(() => (
<DialogMoveSession {...props} initialDirectories={directoryData()} initialRemoving={initialRemoving} />
<DialogWorkspaces {...props} initialDirectories={directoryData()} initialRemoving={initialRemoving} />
))
}
@@ -113,7 +113,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
.toSorted((a, b) => b.directory.length - a.directory.length)[0]
})
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
const options = createMemo<DialogSelectOption<WorkspaceSelection | undefined>[]>(() => {
if (showError()) return []
const data = directoryData()
const current = currentRoot()?.directory
@@ -213,7 +213,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return true
}
async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
async function remove(option: DialogSelectOption<WorkspaceSelection | undefined>) {
if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
const data = directoryData()
const selected = option.value
@@ -299,6 +299,14 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
props.onSelect({ type: "new", name })
}
async function move(option: DialogSelectOption<WorkspaceSelection | undefined>) {
if (route.data.type !== "session" || option.value?.type !== "directory") return
const sessionID = route.data.sessionID
const directory = option.value.directory
dialog.clear()
await client.api.session.move({ sessionID, directory }).catch(toast.error)
}
const fullHeight = createMemo(() =>
Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)),
)
@@ -306,11 +314,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
return (
<box minHeight={showError() ? 5 : fullHeight()}>
<DialogSelect
title="Move session"
title="Worktrees"
titleView={
<box flexDirection="row" gap={1}>
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Move session
Worktrees
</text>
<Show when={working() || directories.loading || loadedProject.loading}>
<Spinner />
@@ -327,7 +335,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
Could not load worktrees
</text>
<text fg={theme.text.subdued}>{errorMessage(loadError())}</text>
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
<text fg={theme.text.subdued}>Close and reopen Worktrees to try again.</text>
</box>
) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4}>
@@ -354,6 +362,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
showError() || props.fixture
? []
: [
...(route.data.type === "session"
? [{ command: "dialog.move_session.move", title: "move", onTrigger: move }]
: []),
{
command: "dialog.move_session.new",
title: "new",
+3 -3
View File
@@ -615,11 +615,11 @@ export function Prompt(props: PromptProps) {
},
},
{
title: "Move session",
desc: "Move to another project dir",
title: "Manage workspaces",
desc: "Manage workspaces",
name: "session.move",
category: "Session",
slash: { name: "move" },
slash: { name: "worktrees", aliases: ["move", "mov"] },
run: () => {
move.open()
},
+10 -18
View File
@@ -4,9 +4,10 @@ import { errorMessage } from "../../util/error"
import { useDialog } from "../../ui/dialog"
import { useClient } from "../../context/client"
import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaces, type WorkspaceSelection } from "../dialog-workspaces"
import { useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { useRoute } from "../../context/route"
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
const dialog = useDialog()
@@ -14,11 +15,12 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const toast = useToast()
const data = useData()
const currentLocation = useLocation()
const route = useRoute()
const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false)
const [creatingDots, setCreatingDots] = createSignal(3)
const [progress, setProgress] = createSignal<string>()
const [destination, setDestination] = createSignal<MoveSessionSelection>()
const [destination, setDestination] = createSignal<WorkspaceSelection>()
function homeLocation() {
const location = currentLocation.ref ?? data.location.default()
@@ -68,7 +70,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const sessionID = input.sessionID()
const session = sessionID ? await resolveSession(sessionID) : undefined
dialog.replace(() => (
<DialogMoveSession
<DialogWorkspaces
projectID={projectID}
location={session?.location ?? homeLocation()}
current={
@@ -87,19 +89,18 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
}
onCurrentChange={setDestination}
onSelect={(selection) => {
const sessionID = input.sessionID()
if (!sessionID) {
if (!input.sessionID() && selection.type === "new") {
setDestination(selection)
dialog.clear()
return
}
void moveExistingSession(sessionID, selection)
void selectWorkspace(selection)
}}
/>
))
}
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
async function selectWorkspace(selection: WorkspaceSelection) {
dialog.clear()
const directory = selection.type === "new" ? await create(selection.name) : selection.directory
if (!directory) {
@@ -107,17 +108,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
dialog.clear()
return
}
setProgress("Moving session")
try {
await client.api.session.move({ sessionID, directory })
dialog.clear()
} catch (error) {
toast.error(error)
dialog.clear()
} finally {
setProgress(undefined)
setCreating(false)
}
finishSubmit()
route.navigate({ type: "home", location: { directory } })
}
async function resolveProjectID() {
@@ -0,0 +1,94 @@
import { createMemo, createSignal } from "solid-js"
import type { RGBA } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import type { SessionTabsController } from "./session-tabs"
export function SessionTabsRailControls(props: {
width: number
tabs: SessionTabsController
belowHighlighted: boolean
}) {
const theme = useTheme("elevated")
const keymap = Keymap.use()
const [hovered, setHovered] = createSignal(false)
const hoverColor = createMemo(() =>
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
)
let pressed = false
const search = () => (props.tabs.search ? props.tabs.search() : keymap.dispatch("session.list"))
return (
<box height={1} position="relative" flexShrink={0} backgroundColor={theme.background.default}>
<SessionTabHalfRow
top={-1}
edge="top"
width={props.width}
color={hovered() ? hoverColor() : theme.background.default}
background={theme.background.default}
/>
<box
height={1}
position="relative"
flexDirection="row"
justifyContent="center"
backgroundColor={hovered() ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseDown={(event) => {
pressed = event.button === 0
event.stopPropagation()
}}
onMouseUp={(event) => {
event.stopPropagation()
if (event.button !== 0 || !pressed) return
pressed = false
search()
}}
onMouseDragEnd={() => (pressed = false)}
>
<text width={1} height={1} fg={theme.text.action.secondary.default} selectable={false} wrapMode="none">
</text>
</box>
<text
position="absolute"
top={1}
left={0}
width={props.width}
height={1}
zIndex={2}
fg={hoverColor()}
bg={hovered() && props.belowHighlighted ? hoverColor() : theme.background.default}
selectable={false}
>
{(hovered() ? "▀" : props.belowHighlighted ? "▄" : " ").repeat(props.width)}
</text>
</box>
)
}
export function SessionTabHalfRow(props: {
top: number
edge: "top" | "bottom"
width: number
color: RGBA
background: RGBA
}) {
return (
<text
position="absolute"
top={props.top}
left={0}
width={props.width}
height={1}
zIndex={1}
fg={props.color}
bg={props.background}
selectable={false}
wrapMode="none"
>
{(props.edge === "top" ? "▄" : "▀").repeat(props.width)}
</text>
)
}
+394 -200
View File
@@ -1,4 +1,11 @@
import { BoxRenderable, RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import {
BoxRenderable,
CliRenderEvents,
RGBA,
ScrollBoxRenderable,
TextAttributes,
type MouseEvent,
} from "@opentui/core"
import {
For,
Index,
@@ -12,7 +19,7 @@ import {
onCleanup,
untrack,
} from "solid-js"
import { Portal, useTerminalDimensions } from "@opentui/solid"
import { Portal, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
import { useData } from "../context/data"
@@ -33,7 +40,7 @@ import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { SESSION_SIDEBAR_WIDTH, SESSION_TABS_COMPACT_BREAKPOINT } from "../ui/layout"
import { projectName } from "../util/project"
import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/marquee"
import { useDialog } from "../ui/dialog"
@@ -41,6 +48,7 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { registerOpencodeSpinner } from "./register-spinner"
import { SPINNER_FRAMES } from "./spinner-frames"
import { SessionTabsRailControls, SessionTabHalfRow } from "./session-tabs-rail"
import "./title-shimmer"
registerOpencodeSpinner()
@@ -105,6 +113,8 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
detail?: (sessionID: string) => string | undefined
isPreview?: (sessionID: string) => boolean
promote?: (sessionID: string) => void
rename?: (sessionID: string) => void
search?: () => void
status(sessionID: string): SessionTabsStatus
}
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
@@ -120,13 +130,16 @@ function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof us
function TabIndicator(props: {
status: SessionTabsStatus
label: string
idleLabel?: string
width: number
centered?: boolean
color: RGBA
unreadColor: RGBA
backgroundColor: RGBA
flashColor: RGBA
animations: boolean
numbers: boolean
selected?: boolean
spinner?: TabSpinner
unreadMarker?: TabUnreadMarker
attributes?: number
@@ -146,7 +159,7 @@ function TabIndicator(props: {
})
const fading = () => !props.status.unread && fade.value().opacity > 0
const color = () => {
if (props.numbers) return props.color
if (props.numbers || props.selected) return props.color
if (unread()) return props.unreadColor
if (!fading()) return props.color
const opacity = fade.value().opacity
@@ -162,10 +175,17 @@ function TabIndicator(props: {
if (runs()) return spinner().frames[0]
if (props.label === "+") return "+"
if (props.status.unread || fading()) return TAB_UNREAD_MARKERS[props.unreadMarker ?? "small-dot"]
return ""
return props.idleLabel ?? ""
}
return (
<box width={props.width + 1} flexShrink={0} flexDirection="row" justifyContent="flex-end" paddingRight={1}>
<box
width={props.width + (props.centered ? 0 : 1)}
height={props.centered ? 1 : undefined}
flexShrink={0}
flexDirection="row"
justifyContent={props.centered ? "center" : "flex-end"}
paddingRight={props.centered ? 0 : 1}
>
<Show
when={runs() && props.animations && !props.numbers}
fallback={
@@ -384,7 +404,8 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
: []),
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, title),
run: () =>
props.tabs.rename ? props.tabs.rename(sessionID) : DialogSessionRename.show(dialog, sessionID, title),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
]
@@ -472,6 +493,7 @@ export function SessionTabs(
animations?: boolean
spinner?: TabSpinner
unreadMarker?: TabUnreadMarker
indicators?: "status" | "numbers"
orientation?: "horizontal" | "vertical"
width?: number
} = {},
@@ -486,7 +508,7 @@ export function SessionTabs(
animations={props.animations}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
numbers={config.tabs.indicators === "numbers"}
numbers={(props.indicators ?? config.tabs.indicators) === "numbers"}
width={props.width}
/>
</Match>
@@ -496,7 +518,7 @@ export function SessionTabs(
animations={props.animations}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
numbers={config.tabs.indicators === "numbers"}
numbers={(props.indicators ?? config.tabs.indicators) === "numbers"}
/>
</Match>
</Switch>
@@ -511,13 +533,18 @@ function VerticalSessionTabs(props: {
unreadMarker?: TabUnreadMarker
width?: number
}) {
const contextTabs = useSessionTabs()
const tabs: SessionTabsController = props.controller ?? contextTabs
const data = useData()
const tabs: SessionTabsController = props.controller ?? useSessionTabs()
const data = props.controller ? undefined : useData()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const theme = useTheme("elevated")
const base = useTheme()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const width = () => props.width ?? SESSION_SIDEBAR_WIDTH
const compact = createMemo(() => width() < SESSION_TABS_COMPACT_BREAKPOINT)
const tooltipWidth = () => Math.min(54, dimensions().width - width())
const stride = () => (compact() ? 2 : 3)
const unreadColor = () => theme.text.status.unread
const activeNumber = () => theme.text.status.running
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
@@ -526,6 +553,26 @@ function VerticalSessionTabs(props: {
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
createEffect(() => {
compact()
untrack(marquee.reset)
})
const [hoverY, setHoverY] = createSignal(0)
const [scrollTop, setScrollTop] = createSignal(0)
const detail = (sessionID: string) => {
const fixture = tabs.detail?.(sessionID)
if (fixture !== undefined) return fixture
const session = data?.session.get(sessionID)
const project = session ? data?.project.get(session.projectID) : undefined
const vcs = session ? data?.location.vcs.info(session.location) : undefined
const location = session ? data?.location.info(session.location) : undefined
return sessionTabDetail(
projectName(project, session?.location.directory) ?? "",
vcs?.branch.current,
vcs?.branch.default,
!!location && location.project.directory !== location.project.canonical,
)
}
const handleClick = createPreviewDoubleClick(tabs)
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
const [dragging, setDragging] = createSignal<string>()
@@ -539,6 +586,16 @@ function VerticalSessionTabs(props: {
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = ordered
const highlightColor = createMemo(() =>
tint(theme.background.default, theme.background.action.primary.hovered, theme.background.action.primary.hovered.a),
)
const highlighted = (sessionID: string | undefined) =>
sessionID !== undefined && (activeID() === sessionID || hovered() === sessionID || dragging() === sessionID)
const addHighlighted = () => newTab() || addHovered()
const belowHighlighted = createMemo(() => {
const tab = items()[Math.floor(scrollTop() / stride())]
return tab ? highlighted(tab.sessionID) : addHighlighted()
})
createEffect(() => {
const active = marquee.active()
if (active && !items().some((tab) => tab.sessionID === active)) marquee.reset()
@@ -565,6 +622,8 @@ function VerticalSessionTabs(props: {
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
let rail: { screenX: number; screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
const updateScroll = () => setScrollTop(scroll?.scrollTop ?? 0)
onCleanup(() => scroll?.verticalScrollBar.off("change", updateScroll))
let didDrag = false
let addPressed = false
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
@@ -579,15 +638,22 @@ function VerticalSessionTabs(props: {
createEffect(() => {
if (!scroll) return
// The promoted new-session slot sits below the list, so bring the rail's bottom into view.
if (newTab()) return scroll.scrollTo(Math.max(0, items().length * 3 + 1 - scroll.viewport.height))
const index = items().findIndex((tab) => tab.sessionID === activeID())
dimensions()
const index = newTab() ? items().length : items().findIndex((tab) => tab.sessionID === activeID())
if (index === -1) return
const top = index * 3
if (top < scroll.scrollTop) return scroll.scrollTo(top)
if (top + 2 > scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(top + 2 - scroll.viewport.height)
const top = index * stride()
const height = compact() ? 3 : newTab() ? 1 : 2
// Scroll after layout: newly opened tabs do not contribute to the scroll range yet.
const reveal = () => {
if (!scroll) return
if (top < scroll.scrollTop) return scroll.scrollTo(top)
if (top + height > scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(top + height - scroll.viewport.height)
}
}
renderer.once(CliRenderEvents.FRAME, reveal)
renderer.requestRender()
onCleanup(() => renderer.off(CliRenderEvents.FRAME, reveal))
})
const release = () => {
@@ -607,7 +673,13 @@ function VerticalSessionTabs(props: {
didDrag = true
const target = Math.max(
0,
Math.min(tabs.tabs().length - 1, Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3)),
Math.min(
tabs.tabs().length - 1,
Math.floor(
(event.y - (scroll?.viewport.screenY ?? rail.screenY + 1) - (compact() ? 1 : 0) + (scroll?.scrollTop ?? 0)) /
stride(),
),
),
)
const sourceIndex = items().findIndex((item) => item.sessionID === source)
if (target !== sourceIndex && preview()?.index !== target) setPreview({ sessionID: source, index: target })
@@ -634,21 +706,31 @@ function VerticalSessionTabs(props: {
onMouseDrag={drag}
onMouseDragEnd={release}
>
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
<box flexShrink={0} flexDirection="column" gap={1}>
<Show when={compact()}>
<SessionTabsRailControls width={width()} tabs={tabs} belowHighlighted={belowHighlighted()} />
</Show>
<scrollbox
ref={(element) => {
scroll = element
scroll.verticalScrollBar.on("change", updateScroll)
updateScroll()
}}
flexGrow={1}
minHeight={0}
backgroundColor={theme.background.default}
scrollbarOptions={{ visible: false }}
>
<box flexShrink={0} flexDirection="column" gap={1} paddingY={compact() ? 1 : 0}>
<For each={items()}>
{(tab, index) => {
const selected = () => activeID() === tab.sessionID
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
const [closeHovered, setCloseHovered] = createSignal(false)
const session = createMemo(() => data.session.get(tab.sessionID))
const project = createMemo(() => {
const value = session()
return value ? data.project.get(value.projectID) : undefined
})
const session = createMemo(() => data?.session.get(tab.sessionID))
const numberWidth = () => Math.max(2, String(items().length).length)
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const prefixWidth = () => numberWidth() + 1
const restingTitleWidth = () => Math.max(1, width() - prefixWidth() - 1)
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const title = () => (props.controller ? undefined : session()?.title) ?? tab.title ?? "Untitled session"
@@ -667,25 +749,15 @@ function VerticalSessionTabs(props: {
.join(""),
)
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
const fixture = tabs.detail?.(tab.sessionID)
if (fixture !== undefined) return fixture
const value = session()
const currentProject = project()
const projectLabel = projectName(currentProject, value?.location.directory) ?? ""
const vcs = value ? data.location.vcs.info(value.location) : undefined
const location = value ? data.location.info(value.location) : undefined
const worktree = !!location && location.project.directory !== location.project.canonical
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default, worktree)
})
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
const tabDetail = createMemo(() => detail(tab.sessionID))
const visibleDetail = createMemo(() => Locale.takeWidth(tabDetail(), titleWidth()))
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
const detailFades = createMemo(
() => marqueeOverflows(detail(), titleWidth()) && titleWidth() > FADE_WIDTH,
() => marqueeOverflows(tabDetail(), titleWidth()) && titleWidth() > FADE_WIDTH,
)
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
if (selected() && !compact()) return theme.background.action.primary.selected
if ((compact() && selected()) || hovered() === tab.sessionID || dragging() === tab.sessionID)
return theme.background.action.primary.hovered
return theme.background.default
})
@@ -790,12 +862,15 @@ function VerticalSessionTabs(props: {
}
return (
<box
height={2}
height={compact() ? 1 : 2}
width="100%"
position="relative"
flexDirection="column"
backgroundColor={background()}
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
onMouseOver={(event) => {
setHoverY(event.y)
marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth())
}}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === MIDDLE_MOUSE_BUTTON) {
@@ -822,85 +897,52 @@ function VerticalSessionTabs(props: {
}
didDrag = false
handleClick(tab.sessionID)
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
marquee.enter(tab.sessionID, title(), compact() ? Infinity : hoveredTitleWidth())
setDragging(tab.sessionID)
}}
>
<TabPulse
top={-1}
edge="above"
enabled={animations()}
active={runs()}
outerActive={previousRuns()}
promptPulse={status().promptPulse}
outerPromptPulse={previousStatus().promptPulse}
complete={complete() && !status().attention}
outerComplete={previousStatus().complete && !previousStatus().attention}
glow={glows()}
outerGlow={previousGlows()}
color={separatorLowerPulseColor()}
width={indicatorWidth}
outerColor={separatorUpperPulseColor()}
flashColor={tint(theme.background.default, theme.text.default, 0.22)}
outerFlashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
glowColor={separatorLowerColor()}
outerGlowColor={separatorUpperColor()}
glowTail={8}
outerGlowTail={5}
completionColor={separatorLowerColor()}
outerCompletionColor={separatorUpperColor()}
backgroundColor={theme.background.default}
/>
<Show when={index() === items().length - 1}>
<TabPulse
top={2}
edge="below"
enabled={animations()}
active={runs()}
outerActive={false}
promptPulse={status().promptPulse}
outerPromptPulse={0}
complete={complete() && !status().attention}
outerComplete={false}
glow={glows()}
outerGlow={false}
color={tint(theme.background.default, theme.text.default, 0.04)}
width={indicatorWidth}
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerGlowColor={theme.background.default}
glowTail={8}
outerGlowTail={5}
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerCompletionColor={theme.background.default}
backgroundColor={theme.background.default}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
<TabPulse
enabled={animations()}
active={runs()}
promptPulse={status().promptPulse}
complete={complete() && !status().attention}
glow={glows()}
color={pulseColor()}
width={indicatorWidth}
glowColor={glowColor()}
flashColor={flashColor()}
flashTail={8}
completionColor={glowColor()}
backgroundColor={pulseBackground()}
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
<Show when={compact()}>
<Show when={highlighted(tab.sessionID)}>
<SessionTabHalfRow
top={-1}
edge="top"
width={width()}
color={pulseBackground()}
background={
highlighted(items()[index() - 1]?.sessionID) ? highlightColor() : theme.background.default
}
/>
<SessionTabHalfRow
top={1}
edge="bottom"
width={width()}
color={pulseBackground()}
background={
(
index() === items().length - 1
? addHighlighted()
: highlighted(items()[index() + 1]?.sessionID)
)
? highlightColor()
: theme.background.default
}
/>
</Show>
<box height={1} flexDirection="row" justifyContent="center">
<TabIndicator
centered
selected={selected()}
width={width()}
status={status()}
label={sessionTabNumberLabel(index())}
width={numberWidth()}
color={numberColor()}
idleLabel={Locale.graphemes(title().trimStart())[0] ?? "U"}
color={
selected()
? theme.text.default
: props.numbers
? numberColor()
: (tabFeedbackColor(status(), theme) ?? (runs() ? activeNumber() : foreground()))
}
unreadColor={tabFeedbackColor(status(), theme) ?? unreadColor()}
backgroundColor={pulseBackground()}
flashColor={theme.text.default}
@@ -908,88 +950,179 @@ function VerticalSessionTabs(props: {
numbers={props.numbers}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
attributes={selected() ? TextAttributes.BOLD : undefined}
/>
<title_shimmer
width={titleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={pulseBackground()}
wrapMode="none"
selectable={false}
attributes={
(status().renaming && !animations()
? TextAttributes.DIM
: selected()
? TextAttributes.BOLD
: 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
(selected() ? TextAttributes.BOLD : 0) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0)
}
>
<Show
when={scrolling() || titleGlow.value().level > 0 || titleFades()}
fallback={visibleTitle()}
>
<Index each={visibleTitleParts()}>
{(part, index) => (
<span style={{ fg: titleColor(index, part().separator) }}>{part().value}</span>
)}
</Index>
</Show>
</title_shimmer>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={closeHovered() ? theme.text.default : theme.text.subdued}
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
didDrag = false
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "✕" : ""}
</text>
/>
</box>
</box>
<box height={1} width="100%" position="relative" flexDirection="row">
</Show>
<Show when={!compact()}>
<TabPulse
top={-1}
edge="above"
enabled={animations()}
active={runs()}
outerActive={previousRuns()}
promptPulse={status().promptPulse}
outerPromptPulse={previousStatus().promptPulse}
complete={complete() && !status().attention}
outerComplete={previousStatus().complete && !previousStatus().attention}
glow={glows()}
color={detailPulseColor()}
outerGlow={previousGlows()}
color={separatorLowerPulseColor()}
width={indicatorWidth}
glowColor={detailGlowColor()}
glowTail={10}
flashColor={detailFlashColor()}
outerColor={separatorUpperPulseColor()}
flashColor={tint(theme.background.default, theme.text.default, 0.22)}
outerFlashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
completionColor={detailGlowColor()}
backgroundColor={pulseBackground()}
glowColor={separatorLowerColor()}
outerGlowColor={separatorUpperColor()}
glowTail={8}
outerGlowTail={5}
completionColor={separatorLowerColor()}
outerCompletionColor={separatorUpperColor()}
backgroundColor={theme.background.default}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
</text>
<Show when={index() === items().length - 1}>
<TabPulse
top={2}
edge="below"
enabled={animations()}
active={runs()}
outerActive={false}
promptPulse={status().promptPulse}
outerPromptPulse={0}
complete={complete() && !status().attention}
outerComplete={false}
glow={glows()}
outerGlow={false}
color={tint(theme.background.default, theme.text.default, 0.04)}
width={indicatorWidth}
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerGlowColor={theme.background.default}
glowTail={8}
outerGlowTail={5}
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerCompletionColor={theme.background.default}
backgroundColor={theme.background.default}
/>
</Show>
<box height={1} width="100%" flexDirection="row" position="relative">
<TabPulse
enabled={animations()}
active={runs()}
promptPulse={status().promptPulse}
complete={complete() && !status().attention}
glow={glows()}
color={pulseColor()}
width={indicatorWidth}
glowColor={glowColor()}
flashColor={flashColor()}
flashTail={8}
completionColor={glowColor()}
backgroundColor={pulseBackground()}
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
<TabIndicator
status={status()}
label={sessionTabNumberLabel(index())}
width={numberWidth()}
color={numberColor()}
unreadColor={tabFeedbackColor(status(), theme) ?? unreadColor()}
backgroundColor={pulseBackground()}
flashColor={theme.text.default}
animations={animations()}
numbers={props.numbers}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
attributes={selected() ? TextAttributes.BOLD : undefined}
/>
<title_shimmer
width={titleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={pulseBackground()}
wrapMode="none"
selectable={false}
attributes={
(status().renaming && !animations()
? TextAttributes.DIM
: selected()
? TextAttributes.BOLD
: 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show
when={scrolling() || titleGlow.value().level > 0 || titleFades()}
fallback={visibleTitle()}
>
<Index each={visibleTitleParts()}>
{(part, index) => (
<span style={{ fg: titleColor(index, part().separator) }}>{part().value}</span>
)}
</Index>
</Show>
</title_shimmer>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={closeHovered() ? theme.text.default : theme.text.subdued}
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
didDrag = false
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "✕" : ""}
</text>
</box>
</box>
</box>
<box height={1} width="100%" position="relative" flexDirection="row">
<TabPulse
enabled={animations()}
active={runs()}
promptPulse={status().promptPulse}
complete={complete() && !status().attention}
glow={glows()}
color={detailPulseColor()}
width={indicatorWidth}
glowColor={detailGlowColor()}
glowTail={10}
flashColor={detailFlashColor()}
flashTail={8}
completionColor={detailGlowColor()}
backgroundColor={pulseBackground()}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={prefixWidth()} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
</text>
</box>
</box>
</Show>
</box>
)
}}
@@ -1002,11 +1135,13 @@ function VerticalSessionTabs(props: {
width="100%"
position="relative"
flexDirection="row"
paddingLeft={1}
paddingLeft={compact() ? 0 : 1}
justifyContent={compact() ? "center" : "flex-start"}
alignItems="center"
backgroundColor={
newTab()
newTab() && !compact()
? theme.background.action.primary.selected
: addHovered()
: addHovered() || (compact() && newTab())
? theme.background.action.primary.hovered
: theme.background.default
}
@@ -1031,23 +1166,41 @@ function VerticalSessionTabs(props: {
}}
onMouseDragEnd={() => (addPressed = false)}
>
<Show when={compact() && addHighlighted()}>
<SessionTabHalfRow
top={-1}
edge="top"
width={width()}
color={highlightColor()}
background={highlighted(items().at(-1)?.sessionID) ? highlightColor() : theme.background.default}
/>
<SessionTabHalfRow
top={1}
edge="bottom"
width={width()}
color={highlightColor()}
background={theme.background.default}
/>
</Show>
<text
width={2}
width={compact() ? 1 : 2}
fg={newTab() || addHovered() ? theme.text.default : idleNumber()}
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
+
</text>
<text
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
wrapMode="none"
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
{NEW_SESSION_TAB_TITLE}
</text>
<Show when={newTab()}>
<Show when={!compact()}>
<text
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
wrapMode="none"
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
{NEW_SESSION_TAB_TITLE}
</text>
</Show>
<Show when={newTab() && !compact()}>
<text
position="absolute"
right={1}
@@ -1070,6 +1223,47 @@ function VerticalSessionTabs(props: {
</Show>
</box>
</scrollbox>
<Show when={compact() && !dragging() && !contextMenu() && hovered()}>
{(sessionID) => (
<box
position="absolute"
left={width()}
top={Math.max(0, Math.min(hoverY() - 1, dimensions().height - 4) - (rail?.screenY ?? 0))}
width={tooltipWidth()}
height={4}
paddingY={1}
zIndex={2000}
>
<SessionTabHalfRow
top={0}
edge="top"
width={tooltipWidth()}
color={theme.background.default}
background={base.background.default}
/>
<box height={2} paddingX={1} backgroundColor={theme.background.default}>
<text fg={theme.text.default} wrapMode="none" selectable={false}>
{Locale.truncateWidth(
data?.session.get(sessionID())?.title ??
items().find((tab) => tab.sessionID === sessionID())?.title ??
"Untitled session",
tooltipWidth() - 2,
)}
</text>
<text fg={theme.text.subdued} wrapMode="none" selectable={false}>
{Locale.takeWidth(detail(sessionID()), tooltipWidth() - 2)}
</text>
</box>
<SessionTabHalfRow
top={3}
edge="bottom"
width={tooltipWidth()}
color={theme.background.default}
background={base.background.default}
/>
</box>
)}
</Show>
<Show when={contextMenu()}>
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
</Show>
+3
View File
@@ -191,6 +191,9 @@ export const Info = Schema.Struct({
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide model reasoning",
}),
tools: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide tool calls and the assistant text that precedes them",
}),
shell_output: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide raw shell tool output",
}),
+4 -2
View File
@@ -46,6 +46,7 @@ export const Definitions = {
leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
"app.exit": keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
"app.clear": keybind("ctrl+l", "Clear the screen in mini"),
"app.debug": keybind("none", "Toggle debug panel"),
"app.console": keybind("none", "Toggle console"),
"app.scrap": keybind("none", "Open scrap screen"),
@@ -104,7 +105,7 @@ export const Definitions = {
"session.export": keybind("<leader>x", "Export session to editor"),
"session.copy": keybind("none", "Copy session transcript"),
"session.copy.id": keybind("none", "Copy session ID"),
"session.move": keybind("none", "Move session"),
"session.move": keybind("none", "Manage workspaces"),
"session.new": keybind("<leader>n", "Create a new session"),
"session.list": keybind("<leader>l", "List all sessions"),
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
@@ -261,7 +262,8 @@ export const Definitions = {
"dialog.integration.rename": keybind("ctrl+r", "Rename integration account"),
"dialog.integration.delete": keybind("ctrl+d", "Delete integration account"),
"dialog.worktree.generate": keybind("tab", "Generate worktree name"),
"dialog.move_session.new": keybind("ctrl+m", "New worktree"),
"dialog.move_session.new": keybind("ctrl+a", "New worktree"),
"dialog.move_session.move": keybind("ctrl+m", "Move session to worktree"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete worktree"),
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh worktrees"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
@@ -2,7 +2,7 @@ import type { Plugin } from "@opencode/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { createSignal } from "solid-js"
import { DialogMoveSession } from "../../../component/dialog-move-session"
import { DialogWorkspaces } from "../../../component/dialog-workspaces"
import { SessionLocationUnavailable } from "../../../routes/session/location-missing"
import type { Story } from "./index"
import { StoryFooter } from "./footer"
@@ -15,7 +15,7 @@ function SessionLocationMissingStory(props: { context: Plugin.Context }) {
const [message, setMessage] = createSignal("Choose another directory to continue")
const open = () =>
props.context.ui.dialog.show(() => (
<DialogMoveSession
<DialogWorkspaces
projectID="fixture-project"
initialDirectories={[
{ directory: "/Users/kit/code/open-source/opencode" },
@@ -1,6 +1,6 @@
import { Plugin } from "@opencode/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal, For } from "solid-js"
import { batch, createSignal, For, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import {
EMPTY_SESSION_TAB_STATUS,
@@ -13,6 +13,18 @@ import {
} from "../../../component/session-tabs"
import { closeSessionTab, cycleSessionTab, moveSessionTab } from "../../../context/session-tabs-model"
import { StoryFooter } from "./footer"
import { DialogPrompt } from "../../../ui/dialog-prompt"
import { useDialog } from "../../../ui/dialog"
import { DialogSelect } from "../../../ui/dialog-select"
import {
clampSessionTabsWidth,
sessionTabsFitVertically,
SESSION_SIDEBAR_WIDTH,
SESSION_TABS_COMPACT_BREAKPOINT,
SESSION_TABS_COMPACT_WIDTH,
} from "../../../ui/layout"
import { createPaneResize } from "../../../ui/pane-resize"
import { PaneResizeHandle } from "../../../ui/pane-resize-handle"
import type { Story } from "./index"
type FixtureStatus = ReturnType<SessionTabsController["status"]>
@@ -54,6 +66,7 @@ const TRANSCRIPT_FILES = [
function SessionTabsStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme
const dialog = useDialog()
// A keyed store mirrors production: retitles mutate rows in place instead of remounting them.
const [tabStore, setTabStore] = createStore<{ items: { sessionID: string; title?: string }[] }>({
items: FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })),
@@ -65,6 +78,18 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const [lastEvent, setLastEvent] = createSignal("idle / working / question / permission / complete / error")
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>(FIXTURE_STATUSES)
const [orientation, setOrientation] = createSignal<"horizontal" | "vertical">("vertical")
const [width, setWidth] = createSignal(SESSION_SIDEBAR_WIDTH)
const resize = createPaneResize({
value: width,
defaultValue: () => SESSION_SIDEBAR_WIDTH,
clamp: (width) => clampSessionTabsWidth(width, dimensions().width),
fromMouse: (event) => event.x + 1,
contains: (event, width) => event.x >= width - 1 && event.x <= width,
onCommit: setWidth,
})
const vertical = () => orientation() === "vertical" && sessionTabsFitVertically(dimensions().width, resize.size())
const [indicators, setIndicators] = createSignal<"status" | "numbers">("status")
const railCompact = () => resize.size() < SESSION_TABS_COMPACT_BREAKPOINT
const spinners = Object.keys(TAB_SPINNERS) as TabSpinner[]
const [spinner, setSpinner] = createSignal<TabSpinner>("dots")
const markers = Object.keys(TAB_UNREAD_MARKERS) as TabUnreadMarker[]
@@ -116,6 +141,22 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
tabs,
current: active,
add: addTab,
search() {
dialog.replace(() => (
<DialogSelect
title="Sessions (fixture)"
current={active()}
options={FIXTURE_TABS.map((tab) => ({ title: tab.title, value: tab.sessionID, description: tab.project }))}
onSelect={(option) => {
if (!tabs().some((tab) => tab.sessionID === option.value)) {
setItems([...tabs(), { ...FIXTURE_TABS.find((tab) => tab.sessionID === option.value)! }])
}
select(option.value)
dialog.clear()
}}
/>
))
},
detail(sessionID) {
return FIXTURE_TABS.find((tab) => tab.sessionID === sessionID)?.project
},
@@ -123,6 +164,19 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
},
select,
rename(sessionID: string) {
dialog.replace(() => (
<DialogPrompt
title="Rename fixture tab"
value={tabs().find((tab) => tab.sessionID === sessionID)?.title}
onConfirm={(title) => {
if (!title.trim()) return
setTabStore("items", (tab) => tab.sessionID === sessionID, "title", title.trim())
dialog.clear()
}}
/>
))
},
move(sessionID: string, index: number) {
const next = moveSessionTab(tabs(), sessionID, index)
if (next === tabs()) return
@@ -267,6 +321,8 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
setMarker("small-dot")
setAnimations(true)
setOrientation("vertical")
setWidth(SESSION_SIDEBAR_WIDTH)
setIndicators("status")
})
setLastEvent(showcase ? "all six states are visible" : "reset; all tabs idle")
}
@@ -395,8 +451,30 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
run: () => setMarker((value) => markers[(markers.indexOf(value) + 1) % markers.length]),
},
{ bind: "m", title: "Toggle animations", group: "Storybook", run: () => setAnimations((value) => !value) },
{
bind: "g",
title: "Toggle status icons or numbers",
group: "Storybook",
run: () => setIndicators((value) => (value === "status" ? "numbers" : "status")),
},
{ bind: "t", title: "Add tab", group: "Storybook", run: addTab },
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
{
bind: "b",
title: "Switch minimum or default width",
group: "Storybook",
run: () => setWidth(railCompact() ? SESSION_SIDEBAR_WIDTH : SESSION_TABS_COMPACT_WIDTH),
},
{
bind: "n",
title: "Cycle tab count",
group: "Storybook",
run() {
const count = tabs().length < 6 ? 6 : tabs().length < 12 ? 12 : 3
setItems(FIXTURE_TABS.slice(0, count).map((tab) => ({ ...tab })))
if (!tabs().some((tab) => tab.sessionID === active())) setActive("fixture-1")
},
},
{
bind: "o",
title: "Toggle tab orientation",
@@ -417,15 +495,24 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
flexDirection="column"
backgroundColor={theme.background.default}
>
<box flexGrow={1} flexDirection={orientation() === "vertical" ? "row" : "column"}>
<box
flexGrow={1}
minHeight={0}
flexDirection={vertical() ? "row" : "column"}
onMouseDrag={resize.onMouseDrag}
onMouseDragEnd={resize.onMouseDragEnd}
onMouseUp={resize.onMouseUp}
>
<SessionTabs
controller={controller}
orientation={orientation()}
orientation={vertical() ? "vertical" : "horizontal"}
width={resize.size()}
spinner={spinner()}
unreadMarker={marker()}
animations={animations()}
indicators={indicators()}
/>
<box flexGrow={1} paddingLeft={2} paddingRight={2} paddingTop={1} flexDirection="column">
<box flexGrow={1} minWidth={0} paddingLeft={2} paddingRight={2} paddingTop={1} flexDirection="column">
<For each={transcript()}>
{(line) => (
<text fg={line.color} wrapMode="none" selectable={false}>
@@ -434,19 +521,25 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
)}
</For>
</box>
<Show when={vertical()}>
<PaneResizeHandle resize={resize} left={resize.size() - 1} />
</Show>
</box>
<StoryFooter
context={props.context}
title="storybook / tabs"
details={[
orientation() === "vertical" ? "left rail" : "top strip",
vertical() ? `${railCompact() ? "compact rail" : "expanded rail"} · ${resize.size()} cols` : "top strip",
spinner(),
indicators(),
`${TAB_UNREAD_MARKERS[marker()]} ${marker()}`,
animations() ? "animated" : "still",
]}
status={stateSummary()}
message={lastEvent()}
controls={[
{ shortcut: "b", label: "min/default width" },
{ shortcut: "n", label: "3/6/12 tabs" },
{ shortcut: "s", label: "work" },
{ shortcut: "space/e", label: "random work" },
{ shortcut: "p", label: "prompt" },
@@ -456,10 +549,12 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
{ shortcut: "f/x", label: "complete/fail" },
{ shortcut: "t/d", label: "add/close" },
{ shortcut: "c", label: "spinner" },
{ shortcut: "g", label: "status/numbers" },
{ shortcut: "u", label: "unread marker" },
{ shortcut: "m", label: "motion" },
{ shortcut: "↑/↓", label: "select" },
{ shortcut: "o", label: "layout" },
{ shortcut: "drag edge", label: "resize / double-click reset" },
{ shortcut: "r", label: "reset idle" },
{ shortcut: "v", label: "all states" },
{ shortcut: "esc", label: "back" },
+49 -14
View File
@@ -27,6 +27,7 @@ import type {
RunInput,
RunProvider,
} from "./types"
import { matchMiniVerbosity, verbosityChange, verbosityLabel } from "./verbosity"
type PanelEntry = RunFooterMenuItem & {
category: string
@@ -45,6 +46,7 @@ type CommandEntry =
| (PanelEntry & { action: "variant.list" })
| (PanelEntry & { action: "settings" })
| (PanelEntry & { action: "slash"; name: string })
| (PanelEntry & { action: "clear" })
| (PanelEntry & { action: "exit" })
type ModelEntry = PanelEntry & {
@@ -78,7 +80,7 @@ type SubagentEntry = PanelEntry & {
}
type SettingEntry = PanelEntry & {
key: keyof MiniSettings
key: keyof MiniSettings | "verbosity"
}
const PANEL_PAD = 2
@@ -411,7 +413,9 @@ export function RunCommandMenuBody(props: {
onSettings: () => void
onCommand: (name: string) => void
onNew: () => void
onClear?: () => void
onExit: () => void
clearShortcut?: string
mono?: boolean
}) {
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
@@ -524,6 +528,13 @@ export function RunCommandMenuBody(props: {
...session,
...prompt,
...agent,
{
action: "clear",
category: "System",
display: "Clear screen",
footer: props.clearShortcut,
keywords: "clear screen cls redraw",
},
{
action: "settings",
category: "System",
@@ -585,6 +596,11 @@ export function RunCommandMenuBody(props: {
return
}
if (item.action === "clear") {
props.onClear?.()
return
}
if (item.action === "exit") {
props.onExit()
return
@@ -711,8 +727,16 @@ export function RunSettingsBody(props: {
mono?: boolean
animations?: boolean
}) {
const [saving, setSaving] = createSignal<keyof MiniSettings>()
const [saving, setSaving] = createSignal<SettingEntry["key"]>()
const entries = createMemo<SettingEntry[]>(() => [
{
category: "Transcript",
display: "Verbosity",
footer: saving() === "verbosity" ? "saving" : verbosityLabel(matchMiniVerbosity(props.settings())),
footerTone: saving() === "verbosity" ? "running" : "selection",
keywords: `verbosity quiet default everything custom noise ${verbosityLabel(matchMiniVerbosity(props.settings()))}`,
key: "verbosity",
},
{
category: "Transcript",
display: "Thinking",
@@ -721,6 +745,14 @@ export function RunSettingsBody(props: {
keywords: `thinking reasoning ${props.settings().thinking}`,
key: "thinking",
},
{
category: "Transcript",
display: "Tools",
footer: saving() === "tools" ? "saving" : props.settings().tools,
footerTone: saving() === "tools" ? "running" : "selection",
keywords: `tools files skills activity work steps intermediate ${props.settings().tools}`,
key: "tools",
},
{
category: "Transcript",
display: "Shell",
@@ -785,18 +817,21 @@ export function RunSettingsBody(props: {
const change = (item: SettingEntry, direction = 1) => {
if (saving()) return
const spinners = Config.MiniWorkSpinner.literals
const next: MiniSettingChange =
item.key === "work_spinner"
? {
key: "work_spinner",
value:
spinners[
(spinners.indexOf(props.settings().work_spinner) + direction + spinners.length) % spinners.length
]!,
}
: item.key === "mono"
? { key: "mono", value: !props.settings().mono }
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
const next: MiniSettingChange | undefined =
item.key === "verbosity"
? verbosityChange(props.settings(), direction < 0 ? -1 : 1)
: item.key === "work_spinner"
? {
key: "work_spinner",
value:
spinners[
(spinners.indexOf(props.settings().work_spinner) + direction + spinners.length) % spinners.length
]!,
}
: item.key === "mono"
? { key: "mono", value: !props.settings().mono }
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
if (!next) return
setSaving(item.key)
void Promise.resolve(props.onChange(next))
.catch(() => {})
+5
View File
@@ -603,6 +603,7 @@ export class RunFooter implements FooterApi {
}
this.finishStartup()
if (this.miniSettings().tools === "hide" && toolTranscript(commit)) return
const last = this.queue.at(-1)
const merged = last ? coalesceProgressCommit(last, commit) : undefined
if (merged) this.queue[this.queue.length - 1] = merged
@@ -1168,6 +1169,10 @@ export class RunFooter implements FooterApi {
}
}
function toolTranscript(commit: StreamCommit) {
return commit.kind === "tool" || commit.partID?.startsWith("skill:")
}
/** @internal Exported for queue identity regression tests. */
export function coalesceProgressCommit(previous: StreamCommit, current: StreamCommit): StreamCommit | undefined {
if (
+25
View File
@@ -546,6 +546,26 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onRequestExit?.(undefined)
})
const clearScreen = () => {
if (renderer.isDestroyed) return
if (renderer.screenMode !== "split-footer") return
if (renderer.externalOutputMode !== "capture-stdout") return
if (renderer.currentControlState === "explicit_suspended") return
// Home, erase the visible display, keep terminal scrollback, then repaint the footer.
renderer.resetSplitFooterForReplay({ clearSavedLines: false })
}
Keymap.createLayer(() => ({
commands: [
{
id: "app.clear",
title: "Clear screen",
group: "System",
run: clearScreen,
},
],
}))
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && !composer.visible(),
commands: [
@@ -831,7 +851,12 @@ export function RunFooterView(props: RunFooterViewProps) {
composer.submitText("/new")
closePanel()
}}
onClear={() => {
closePanel()
clearScreen()
}}
onExit={props.onExit}
clearShortcut={shortcut("app.clear")}
mono={props.mono}
/>
</Match>
+2 -1
View File
@@ -86,7 +86,8 @@ export async function resolveRunTuiConfig(
export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }): MiniSettings {
return {
thinking: config?.mini?.thinking ?? "hide",
thinking: config?.mini?.thinking ?? "show",
tools: config?.mini?.tools ?? "hide",
shell_output: config?.mini?.shell_output ?? "hide",
turn_summary: config?.mini?.turn_summary ?? "show",
footer: config?.mini?.footer ?? "show",
+6
View File
@@ -22,6 +22,7 @@ import {
} from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
import { verbosityPreset } from "./verbosity"
import type {
LocalReplayRow,
MiniHost,
@@ -255,6 +256,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
? async (change) => {
const info = await config.update((draft) => {
if (!draft.mini || typeof draft.mini !== "object") draft.mini = {}
if (change.key === "verbosity") {
Object.assign(draft.mini, verbosityPreset(change.value))
return
}
draft.mini[change.key] = change.value
})
configState.current = resolveMiniSettings(info)
@@ -793,6 +798,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
location: state.location,
sessionID: state.sessionID,
thinking: thinking(),
tools: configState.current.tools === "show",
replay: input.replay,
replayLimit: input.replayLimit,
footer,
+67 -5
View File
@@ -47,6 +47,7 @@ type StreamInput = {
location?: LocationRef
sessionID: string
thinking: boolean
tools?: boolean
replay?: boolean
replayLimit?: number
footer: FooterApi
@@ -141,6 +142,8 @@ type State = {
tools: Map<string, ToolState>
toolSources: Map<string, SessionMessageAssistantTool>
finishedTools: Set<string>
toolMessages: Set<string>
quietText: Map<string, Array<{ partID: string; text: string }>>
skillMessages: Set<string>
shellCommands: Map<string, string>
shellStarted: Set<string>
@@ -488,6 +491,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
tools: new Map(),
toolSources: new Map(),
finishedTools: new Set(),
toolMessages: new Set(),
quietText: new Map(),
skillMessages: new Set(),
shellCommands: new Map(),
shellStarted: new Set(),
@@ -592,7 +597,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const images = freshImages(userImageCommits(messageID, files), render)
if (!render) return
write([
...(!visible ? skillCommits(messageID, skills) : []),
...(!visible && showTools() ? skillCommits(messageID, skills) : []),
...(!visible && text.trim()
? [{ kind: "user", source: "system", text, phase: "start", messageID } as const]
: []),
@@ -662,7 +667,48 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
}
const showTools = () => input.tools !== false
const rememberToolMessage = (messageID: string) => {
state.toolMessages.add(messageID)
state.quietText.delete(messageID)
}
const bufferQuietText = (messageID: string, partID: string, text: string, replace: boolean) => {
if (!text || state.toolMessages.has(messageID)) return
const parts = state.quietText.get(messageID) ?? []
const current = parts.find((part) => part.partID === partID)
if (current) {
current.text = replace ? text : current.text + text
return
}
parts.push({ partID, text })
state.quietText.set(messageID, parts)
}
const flushQuietText = (messageID: string) => {
const parts = state.quietText.get(messageID)
state.quietText.delete(messageID)
if (!parts || state.toolMessages.has(messageID)) return
write(
parts
.filter((part) => part.text)
.map((part) => ({
kind: "assistant" as const,
source: "assistant" as const,
text: part.text,
phase: "progress" as const,
messageID,
partID: part.partID,
})),
)
}
const renderTool = (messageID: string, item: SessionMessageAssistantTool, render = true) => {
if (!showTools()) {
rememberToolMessage(messageID)
render = false
}
const part = normalizeTool(item)
const key = permissionSourceKey(messageID, part.id)
if (state.finishedTools.has(key)) {
@@ -721,7 +767,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
if (message.type === "skill") {
if (state.wait?.messageID === message.id) promoteWait(state.wait, false)
if (!render || state.skillMessages.has(message.id)) {
if (!render || !showTools() || state.skillMessages.has(message.id)) {
state.skillMessages.add(message.id)
return
}
@@ -784,13 +830,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
if (message.type !== "assistant") return
state.messageIDs.add(message.id)
const hasTools = message.content.some((item) => item.type === "tool")
if (hasTools) rememberToolMessage(message.id)
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) {
if (item.type === "text") {
const fragment = fragmentRef(message.id, "text", textOrdinal++)
const update = state.fragments.project(fragment, item.text, render)
if (render && item.text.length > update.previous.length)
const visible = render && (showTools() || !hasTools)
const update = state.fragments.project(fragment, item.text, visible)
if (visible && item.text.length > update.previous.length)
write([
{
kind: "assistant",
@@ -1024,7 +1073,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (state.wait?.messageID === messageID) promoteWait(state.wait, true)
if (state.skillMessages.has(messageID)) return
state.skillMessages.add(messageID)
write([skillCommit(messageID, event.data.name)])
if (showTools()) write([skillCommit(messageID, event.data.name)])
return
}
if (event.type === "session.compaction.started") {
@@ -1113,6 +1162,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (event.type === "session.text.delta") {
const fragment = fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal)
if (!state.fragments.delta(fragment, event.data.delta)) return
if (!showTools()) {
bufferQuietText(event.data.assistantMessageID, fragment.partID, event.data.delta, false)
return
}
write([
{
kind: "assistant",
@@ -1130,6 +1183,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
fragmentRef(event.data.assistantMessageID, "text", event.data.ordinal),
event.data.text,
)
if (!showTools()) {
bufferQuietText(event.data.assistantMessageID, update.partID, event.data.text, true)
return
}
if (event.data.text.length > update.previous.length)
write([
{
@@ -1303,6 +1360,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.data.tokens.cache.write
const limit = state.stepModel ? input.contextLimit?.(state.stepModel) : undefined
state.stepModel = undefined
if (!showTools()) flushQuietText(event.data.assistantMessageID)
write([], {
usage:
total > 0 || event.data.cost
@@ -1317,6 +1375,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
if (event.type === "session.step.failed") {
state.stepModel = undefined
if (!showTools()) flushQuietText(event.data.assistantMessageID)
const rendered = state.errors.has(event.data.assistantMessageID)
state.errors.add(event.data.assistantMessageID)
if (state.wait) state.wait.failureRendered = true
@@ -1344,6 +1403,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.type === "session.execution.interrupted"
) {
state.executionEpoch++
if (!showTools()) for (const messageID of [...state.quietText.keys()]) flushQuietText(messageID)
paintIdle("")
const current = state.wait
if (!current) return
@@ -1605,6 +1665,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
state.tools.clear()
state.toolSources.clear()
state.finishedTools.clear()
state.toolMessages.clear()
state.quietText.clear()
state.skillMessages.clear()
state.shellCommands.clear()
state.shellStarted.clear()
+8 -3
View File
@@ -402,6 +402,7 @@ export type RunTuiConfig = Pick<
export type MiniSettings = {
thinking: "show" | "hide"
tools: "show" | "hide"
shell_output: "show" | "hide"
turn_summary: "show" | "hide"
footer: "show" | "hide"
@@ -410,9 +411,13 @@ export type MiniSettings = {
mono: boolean
}
export type MiniSettingChange = {
[Key in keyof MiniSettings]: { key: Key; value: MiniSettings[Key] }
}[keyof MiniSettings]
export type MiniVerbosity = "quiet" | "default" | "everything"
export type MiniSettingChange =
| {
[Key in keyof MiniSettings]: { key: Key; value: MiniSettings[Key] }
}[keyof MiniSettings]
| { key: "verbosity"; value: MiniVerbosity }
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
// appends content (coalesced in the footer queue), "final" closes it.
+84
View File
@@ -0,0 +1,84 @@
import type { MiniSettingChange, MiniSettings, MiniVerbosity } from "./types"
const levels: readonly MiniVerbosity[] = ["quiet", "default", "everything"]
const knobs = ["thinking", "tools", "shell_output", "turn_summary", "footer"] as const
type VerbosityKnobs = Pick<MiniSettings, (typeof knobs)[number]>
const presets = {
quiet: {
thinking: "hide",
tools: "hide",
shell_output: "hide",
turn_summary: "hide",
footer: "hide",
},
default: {
thinking: "show",
tools: "hide",
shell_output: "hide",
turn_summary: "show",
footer: "show",
},
everything: {
thinking: "show",
tools: "show",
shell_output: "show",
turn_summary: "show",
footer: "show",
},
} as const satisfies Record<MiniVerbosity, VerbosityKnobs>
const labels = {
quiet: "Quiet",
default: "Default",
everything: "Everything",
custom: "Custom",
} as const
export function verbosityPreset(level: MiniVerbosity): VerbosityKnobs {
return { ...presets[level] }
}
export function verbosityLabel(level: MiniVerbosity | "custom") {
return labels[level]
}
export function matchMiniVerbosity(settings: MiniSettings): MiniVerbosity | "custom" {
for (const level of levels) {
if (knobs.every((key) => settings[key] === presets[level][key])) return level
}
return "custom"
}
export function cycleMiniVerbosity(settings: MiniSettings, direction: 1 | -1): MiniVerbosity {
const current = matchMiniVerbosity(settings)
const index = current === "custom" ? nearestMiniVerbosity(settings) : levels.indexOf(current)
const next = index + direction
if (next < 0) return levels[0]!
if (next >= levels.length) return levels[levels.length - 1]!
return levels[next]!
}
export function verbosityChange(settings: MiniSettings, direction: 1 | -1): MiniSettingChange | undefined {
const value = cycleMiniVerbosity(settings, direction)
if (matchMiniVerbosity(settings) === value) return
return { key: "verbosity", value }
}
export function applyMiniSettingChange(settings: MiniSettings, change: MiniSettingChange): MiniSettings {
if (change.key === "verbosity") return { ...settings, ...verbosityPreset(change.value) }
return { ...settings, [change.key]: change.value }
}
function nearestMiniVerbosity(settings: MiniSettings) {
return levels.reduce((best, level, index) => {
if (verbosityDistance(settings, level) < verbosityDistance(settings, levels[best]!)) return index
return best
}, 0)
}
function verbosityDistance(settings: MiniSettings, level: MiniVerbosity) {
return knobs.filter((key) => settings[key] !== presets[level][key]).length
}
+4 -2
View File
@@ -1153,7 +1153,7 @@ export function Session(props: {
const content =
options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking)
? formatSessionTranscript(sessionData, messages(), options.thinking, options.tools)
: JSON.stringify(
await client.api.session.export({ sessionID: sessionData.id, sanitize: options.sanitize }),
null,
@@ -3839,7 +3839,7 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined
}
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean, tools = true) {
const body = messages.flatMap((message) => {
if (message.type === "user") return [`## User\n\n${message.text}`]
if (message.type === "shell")
@@ -3848,6 +3848,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
const content = message.content.flatMap((item) => {
if (item.type === "text") return [item.text]
if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : []
if (!tools) return []
const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2)
const output =
item.state.status === "error"
@@ -3859,6 +3860,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
.join("\n")
return [`**Tool: ${item.name}**\n\n**Input:**\n\`\`\`json\n${input}\n\`\`\`\n\n${output}`]
})
if (content.length === 0) return []
return [`## Assistant\n\n${content.join("\n\n")}`]
})
return `# ${withTimestampedFallback(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
+45 -2
View File
@@ -13,12 +13,13 @@ export type DialogExportOptionsProps = {
action: "copy" | "export"
format: ExportFormat
thinking: boolean
tools: boolean
sanitize: boolean
}) => void
onCancel?: () => void
}
type Active = ExportFormat | "thinking" | "sanitize" | "copy" | "export"
type Active = ExportFormat | "thinking" | "tools" | "sanitize" | "copy" | "export"
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
@@ -27,6 +28,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
const [store, setStore] = createStore({
format: "markdown" as ExportFormat,
thinking: props.defaultThinking,
tools: true,
sanitize: false,
active: "markdown" as Active,
})
@@ -36,6 +38,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
action,
format: store.format,
thinking: store.thinking,
tools: store.tools,
sanitize: store.sanitize,
})
@@ -45,6 +48,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
return
}
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "tools") setStore("tools", !store.tools)
if (store.active === "sanitize") setStore("sanitize", !store.sanitize)
if (store.active === "copy" || store.active === "export") confirm(store.active)
}
@@ -59,7 +63,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
run: () => {
const order: Active[] =
store.format === "markdown"
? ["markdown", "json", "thinking", "copy", "export"]
? ["markdown", "json", "thinking", "tools", "copy", "export"]
: ["markdown", "json", "sanitize", "copy", "export"]
setStore("active", order[(order.indexOf(store.active) + 1) % order.length])
},
@@ -160,6 +164,44 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
Include thinking
</text>
</box>
<box
flexDirection="row"
gap={1}
backgroundColor={
store.active === "tools"
? theme.background.formfield.focused
: store.tools
? theme.background.formfield.selected
: theme.background.formfield.default
}
onMouseUp={() => {
setStore("active", "tools")
setStore("tools", !store.tools)
}}
>
<text
fg={
store.active === "tools"
? theme.text.formfield.focused
: store.tools
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
{store.tools ? "[x]" : "[ ]"}
</text>
<text
fg={
store.active === "tools"
? theme.text.formfield.focused
: store.tools
? theme.text.formfield.selected
: theme.text.formfield.default
}
>
Include tools
</text>
</box>
</Show>
<Show when={store.format === "json"}>
<box
@@ -234,6 +276,7 @@ DialogExportOptions.show = (dialog: DialogContext, defaultThinking: boolean) =>
action: "copy" | "export"
format: ExportFormat
thinking: boolean
tools: boolean
sanitize: boolean
} | null>((resolve) => {
dialog.replace(
+3 -2
View File
@@ -1,5 +1,6 @@
export const SESSION_SIDEBAR_WIDTH = 42
export const SESSION_SIDEBAR_MIN_WIDTH = 24
export const SESSION_TABS_COMPACT_WIDTH = 5
export const SESSION_TABS_COMPACT_BREAKPOINT = 12
export const SESSION_SIDEBAR_MAX_WIDTH = 72
const SESSION_CONTENT_MIN_WIDTH = 44
@@ -9,7 +10,7 @@ export function sessionTabsFitVertically(total: number, width = SESSION_SIDEBAR_
export function clampSessionTabsWidth(width: number, total: number) {
return Math.max(
SESSION_SIDEBAR_MIN_WIDTH,
SESSION_TABS_COMPACT_WIDTH,
Math.min(width, SESSION_SIDEBAR_MAX_WIDTH, total - SESSION_CONTENT_MIN_WIDTH),
)
}
@@ -45,7 +45,7 @@ export function useWorkingDirectoryActions(input: { directory: () => string | un
...(input.onMove
? [
{
title: "Move session",
title: "Workspaces",
value: "session.move",
description: "to another working directory",
onSelect: () => void input.onMove?.(),
+40
View File
@@ -430,6 +430,46 @@ test("session title generated while an untitled session is loading remains visib
}
})
test("vertical session tabs collapse to a compact rail with the terminal", async () => {
await using state = await tmpdir()
await Bun.write(path.join(state.path, "test", "tui", "layout.json"), JSON.stringify({ verticalTabsWidth: 42 }))
const session = {
id: "ses_resize",
title: "Resize fixture",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
await using setup = await createAppFixture({
width: 100,
state: state.path,
config: {
animations: false,
tabs: { enabled: true, layout: "vertical", indicators: "status" },
session: { sidebar: "hide" },
},
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (/^\/api\/session\/ses_resize\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.split("\n")[1].slice(0, 42).includes(session.title))
setup.resize(54, 30)
await setup.waitForFrame((frame) => frame.split("\n")[1].slice(0, 10).trim() === "⌕")
setup.resize(48, 30)
await setup.waitForFrame((frame) => frame.split("\n")[0].includes(session.title))
expect(setup.captureCharFrame()).not.toContain("⌕")
setup.resize(100, 30)
await setup.waitForFrame((frame) => frame.split("\n")[1].slice(0, 42).includes(session.title))
})
test("automatic rename refreshes the displayed title before settling, even without a renamed event", async () => {
await using state = await tmpdir()
const response = Promise.withResolvers<Response>()
@@ -509,11 +509,7 @@ test.each(["", "search-ui"])("creates a worktree named '%s' and opens it in the
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(payload).toEqual({
strategy: "git",
directory: path.join("/tmp/opencode", projectID.slice(0, 6)),
...(name ? { name } : {}),
})
expect(payload).toEqual(name ? { name } : {})
expect(fixture.route.data).toEqual({ type: "home", location: { directory: created, workspaceID } })
expect(fixture.location.ref).toEqual({ directory: created, workspaceID })
} finally {
@@ -362,7 +362,7 @@ test("dialog actions run without options while row actions still require a selec
)
try {
app.mockInput.pressKey("m", { ctrl: true })
app.mockInput.pressKey("a", { ctrl: true })
app.mockInput.pressKey("d", { ctrl: true })
expect(global).toBe(1)
+56 -7
View File
@@ -8,7 +8,7 @@ import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { RouteProvider, useRoute } from "../../../src/context/route"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider, useToast } from "../../../src/ui/toast"
@@ -49,7 +49,8 @@ test.each([
expect(fixture.data.location.info({ directory: created })?.project.canonical).toBe(clone)
expect(fixture.reads.locations.filter((directory) => directory === input.directory)).toHaveLength(1)
expect(fixture.reads.session).toBe(input.home ? 0 : 1)
expect(fixture.moves).toEqual(input.home ? [] : [{ directory: created }])
expect(fixture.moves).toEqual([])
if (!input.home) expect(fixture.route.data).toEqual({ type: "home", location: { directory: created } })
} finally {
fixture.app.renderer.destroy()
}
@@ -83,11 +84,30 @@ test.each([
}
})
test.each([false, true])("selecting a workspace opens Home without moving a session (home=%s)", async (home) => {
const fixture = await renderMove({ directory: clone, home })
try {
await fixture.move.open()
await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
await fixture.app.mockInput.typeText("linked")
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home" && fixture.route.data.location?.directory === linked)
expect(fixture.route.data).toEqual({ type: "home", location: { directory: linked } })
expect(fixture.moves).toEqual([])
expect(fixture.requests).toEqual([])
expect(fixture.move.pending()).toBe(false)
if (!home) expect(fixture.data.session.get("ses_clone")?.location.directory).toBe(clone)
} finally {
fixture.app.renderer.destroy()
}
})
test("removal uses the current configuration location, not the destination directory", async () => {
const fixture = await renderMove({ directory: clone, home: true })
try {
await fixture.move.open()
await fixture.app.waitForFrame((frame) => frame.includes("Move session") && frame.includes(linked))
await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
await fixture.app.mockInput.typeText("linked")
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
fixture.app.mockInput.pressKey("d", { ctrl: true })
@@ -100,6 +120,32 @@ test("removal uses the current configuration location, not the destination direc
}
})
test.each([false, true])("Ctrl+M moves only an existing session (home=%s)", async (home) => {
const fixture = await renderMove({ directory: clone, home })
try {
await fixture.move.open()
const frame = await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
expect(frame).toContain("new ctrl+a")
expect(frame.includes("move ctrl+m")).toBe(!home)
await fixture.app.mockInput.typeText("linked")
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
fixture.app.mockInput.pressKey("m", { ctrl: true })
if (home) {
await fixture.app.renderOnce()
expect(fixture.moves).toEqual([])
expect(fixture.route.data).toEqual({ type: "home" })
expect(fixture.requests).toEqual([])
return
}
await fixture.app.waitFor(() => fixture.moves.length === 1)
expect(fixture.moves).toEqual([{ directory: linked }])
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_clone" })
expect(fixture.requests).toEqual([])
} finally {
fixture.app.renderer.destroy()
}
})
test.each([
{ name: "session", unavailable: "session" as const },
{ name: "location", unavailable: "location" as const },
@@ -205,11 +251,13 @@ async function renderMove(input: {
let move!: ReturnType<typeof usePromptMove>
let toast!: ReturnType<typeof useToast>
let location!: ReturnType<typeof useLocation>
let route!: ReturnType<typeof useRoute>
function Probe() {
data = useData()
toast = useToast()
location = useLocation()
route = useRoute()
move = usePromptMove({
projectID: () => (input.home ? data.location.info()?.project.id : "proj_test"),
sessionID: () => (input.home ? undefined : "ses_clone"),
@@ -223,7 +271,7 @@ async function renderMove(input: {
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ToastProvider>
<RouteProvider>
<RouteProvider initialRoute={input.home ? { type: "home" } : { type: "session", sessionID: "ses_clone" }}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={launch}>
<LocationProvider>
@@ -252,6 +300,7 @@ async function renderMove(input: {
move,
toast,
location,
route,
requests,
removals,
moves,
@@ -259,9 +308,9 @@ async function renderMove(input: {
async create() {
await move.open()
const frame = await app.waitForFrame(
(frame) => frame.includes("Move session") && (frame.includes(clone) || frame.includes(launch)),
(frame) => frame.includes("Worktrees") && (frame.includes(clone) || frame.includes(launch)),
)
app.mockInput.pressKey("m", { ctrl: true })
app.mockInput.pressKey("a", { ctrl: true })
await app.waitForFrame((frame) => frame.includes("Name worktree"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
await app.mockInput.typeText("fresh")
@@ -271,7 +320,7 @@ async function renderMove(input: {
await move.getDirectory()
return frame
}
await app.waitFor(() => moves.length > 0 || toast.currentToast !== null)
await app.waitFor(() => route.data.type === "home" || toast.currentToast !== null)
return frame
},
}
@@ -0,0 +1,125 @@
/** @jsxImportSource @opentui/solid */
import { TextAttributes } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
import { moveSessionTab, type SessionTab } from "../../src/context/session-tabs-model"
import { Keymap } from "../../src/context/keymap"
import { ThemeProvider } from "../../src/context/theme"
import { SPINNER_FRAMES } from "../../src/component/spinner-frames"
import { SESSION_TABS_COMPACT_WIDTH } from "../../src/ui/layout"
import { emptyThemeSource } from "../fixture/fixture"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
test("compact rail renders and controls session tabs", async () => {
const [active, setActive] = createSignal("first")
const [items, setItems] = createSignal<SessionTab[]>([
{ sessionID: "first", title: "First session" },
{ sessionID: "second", title: "Second session" },
{ sessionID: "third", title: "Third session" },
])
const [status, setStatus] = createSignal(EMPTY_SESSION_TAB_STATUS)
const [indicators, setIndicators] = createSignal<"status" | "numbers">("status")
const [searches, setSearches] = createSignal(0)
const controller = {
tabs: items,
current: active,
add() {},
search: () => setSearches((value) => value + 1),
select: setActive,
close() {},
move(sessionID: string, index: number) {
setItems((items) => moveSessionTab(items, sessionID, index))
},
detail: () => "project-alpha",
status: (sessionID: string) => (sessionID === "second" ? status() : EMPTY_SESSION_TAB_STATUS),
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { indicators: "status" } })}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box width="100%" height="100%" flexDirection="row">
<SessionTabs
controller={controller}
orientation="vertical"
animations={false}
indicators={indicators()}
width={SESSION_TABS_COMPACT_WIDTH}
/>
<text>transcript</text>
</box>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 80, height: 24 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes(" F ") && frame.includes(" S "))
expect(
app
.captureCharFrame()
.split("\n")
.slice(0, 11)
.map((line) => line.slice(0, 5)),
).toEqual([
"▄▄▄▄▄",
" ⌕ ",
"▄▄▄▄▄",
" F ",
"▀▀▀▀▀",
" S ",
" ",
" T ",
" ",
" + ",
" ",
])
expect(app.captureCharFrame().split("\n")[0].indexOf("transcript")).toBe(5)
expect(app.captureCharFrame()).not.toContain("First session")
expect(
app.captureSpans().lines[3].spans.find((span) => span.text.trim() === "F")!.attributes & TextAttributes.BOLD,
).toBe(TextAttributes.BOLD)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.waitForFrame((frame) => frame.split("\n")[5].slice(0, 5).trim() === SPINNER_FRAMES[0])
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
await app.waitForFrame((frame) => frame.split("\n")[5].slice(0, 5).trim() === "?")
setIndicators("numbers")
await app.waitForFrame((frame) => frame.split("\n")[5].slice(0, 5).trim() === "2")
expect([3, 5, 7].map((row) => app.captureCharFrame().split("\n")[row].slice(0, 5).trim())).toEqual([
"1",
"2",
"3",
])
setIndicators("status")
setStatus(EMPTY_SESSION_TAB_STATUS)
await app.mockMouse.moveTo(2, 5)
await app.waitForFrame((frame) => frame.includes("Second session") && frame.includes("project-alpha"))
expect(active()).toBe("first")
await app.mockMouse.moveTo(10, 0)
await app.waitForFrame((frame) => !frame.includes("Second session"))
await app.mockMouse.click(2, 1)
expect(searches()).toBe(1)
await app.mockMouse.drag(2, 3, 2, 7)
expect(items().map((tab) => tab.sessionID)).toEqual(["second", "third", "first"])
setItems(Array.from({ length: 40 }, (_, index) => ({ sessionID: `tab-${index + 1}`, title: `Session ${index + 1}` })))
setActive("tab-40")
setIndicators("numbers")
await app.waitForFrame((frame) => frame.split("\n").some((line) => line.slice(0, 5).trim() === "40"))
} finally {
app.renderer.destroy()
}
})
+3 -2
View File
@@ -18,9 +18,10 @@ test("validates the three explicit diff source defaults", () => {
})
test("validates mini replay and work spinner settings", () => {
expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({
mini: { replay: false, replay_limit: 50 },
expect(decodeInfo({ mini: { tools: "hide", replay: false, replay_limit: 50 } })).toEqual({
mini: { tools: "hide", replay: false, replay_limit: 50 },
})
expect(() => decodeInfo({ mini: { tools: "quiet" } })).toThrow()
expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow()
expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow()
expect(decodeInfo({ mini: { work_spinner: "quadrant-orbit" } })).toEqual({
@@ -379,6 +379,7 @@ test.each(["once", "always", "reject"] as const)(
miniSettings: {
current: {
thinking: "hide",
tools: "show",
shell_output: "hide",
turn_summary: "hide",
footer: "show",
@@ -67,6 +67,7 @@ async function renderSubagent(interrupt: "ctrl+i" | "none") {
tuiConfig={config}
miniSettings={() => ({
thinking: "hide",
tools: "show",
shell_output: "hide",
turn_summary: "show",
footer: "show",
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test"
import { expect, spyOn, test } from "bun:test"
import { RGBA, TextAttributes } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { RunFooter } from "../../src/mini/footer"
@@ -144,6 +144,35 @@ test.each([false, true])(
},
)
test("ctrl+l clears the split-footer screen without wiping scrollback", async () => {
const app = await setup()
try {
const clear = spyOn(app.renderer, "resetSplitFooterForReplay").mockImplementation(() => {})
await app.settle()
app.mockInput.pressKey("l", { ctrl: true })
expect(clear).toHaveBeenCalledWith({ clearSavedLines: false })
} finally {
app.cleanup()
}
})
test("command palette can clear the screen", async () => {
const app = await setup()
try {
const clear = spyOn(app.renderer, "resetSplitFooterForReplay").mockImplementation(() => {})
await app.settle()
app.mockInput.pressKey("p", { ctrl: true })
await app.settle()
await app.mockInput.typeText("clear screen")
await app.settle()
expect(app.selected()).toContain("Clear screen")
app.mockInput.pressEnter()
expect(clear).toHaveBeenCalledWith({ clearSavedLines: false })
} finally {
app.cleanup()
}
})
test.each([false, true])("production command menu keeps navigation visible across resizes (mono=%s)", async (mono) => {
const app = await setup(mono)
try {

Some files were not shown because too many files have changed in this diff Show More