mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 18:06:25 +00:00
Compare commits
21
Commits
browser-extension
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ead17547b | ||
|
|
0c1bf08ca6 | ||
|
|
8b09f6415a | ||
|
|
e791afdfa3 | ||
|
|
e655fed6c3 | ||
|
|
ccbc018072 | ||
|
|
4432956490 | ||
|
|
375bf4908f | ||
|
|
cc6bff39a0 | ||
|
|
8a5709324f | ||
|
|
be58ca806c | ||
|
|
9e42e5cc4c | ||
|
|
c2a1649dd4 | ||
|
|
ded9c7e505 | ||
|
|
f9bc2233dd | ||
|
|
7487999e06 | ||
|
|
2eea36e731 | ||
|
|
4fef8edbe8 | ||
|
|
50c552f763 | ||
|
|
a3d5923aca | ||
|
|
ea2c0184ce |
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {} })
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
@@ -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">>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+16
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// External to Vite's module cache: keep lifecycle and route state across reloads.
|
||||
export const host = {}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
declare module "solid-refresh/dist/solid-refresh.mjs" {
|
||||
export * from "solid-refresh"
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+23
-12
@@ -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",
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(() => {})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?.(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user