mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:48:55 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90b71cd74b |
@@ -75,7 +75,7 @@ jobs:
|
||||
build-cli:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Build legacy CLI
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: |
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
packages/opencode/dist/opencode-linux*
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist/opencode-windows*
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -184,7 +184,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -377,7 +377,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
@@ -393,7 +393,6 @@ jobs:
|
||||
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
|
||||
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
||||
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Package
|
||||
if: needs.version.outputs.release
|
||||
@@ -497,31 +496,29 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
pattern: opencode-node-cli-*
|
||||
path: packages/cli/dist/node
|
||||
|
||||
@@ -439,7 +439,6 @@
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
|
||||
@@ -76,6 +76,27 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return true
|
||||
}
|
||||
|
||||
const select = async () => {
|
||||
const session = input.session()
|
||||
if (session?.agent !== input.draft.agent) {
|
||||
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID === input.draft.model.providerID &&
|
||||
session.model.id === input.draft.model.modelID &&
|
||||
(session.model.variant ?? "default") === (input.draft.variant ?? "default")
|
||||
)
|
||||
return
|
||||
await input.api.switchModel({
|
||||
sessionID: input.draft.sessionID,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const [head, ...tail] = text.split(" ")
|
||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||
@@ -86,18 +107,13 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await select()
|
||||
const messageID = Identifier.ascending("message")
|
||||
await input.api.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
@@ -167,24 +183,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
const session = input.session()
|
||||
if (session?.agent !== input.draft.agent) {
|
||||
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID !== input.draft.model.providerID ||
|
||||
session.model.id !== input.draft.model.modelID ||
|
||||
(session.model.variant ?? "default") !== (input.draft.variant ?? "default")
|
||||
) {
|
||||
await input.api.switchModel({
|
||||
sessionID: input.draft.sessionID,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
await select()
|
||||
|
||||
await input.api.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
@@ -524,14 +523,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.api.session.command({
|
||||
void (async () => {
|
||||
if (session.agent !== agent) await sdk().api.session.switchAgent({ sessionID: session.id, agent })
|
||||
if (
|
||||
session.model?.providerID !== model.providerID ||
|
||||
session.model.id !== model.modelID ||
|
||||
(session.model.variant ?? "default") !== (variant ?? "default")
|
||||
)
|
||||
await sdk().api.session.switchModel({
|
||||
sessionID: session.id,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
})
|
||||
await sdk().api.session.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: await Promise.all(
|
||||
images.map(async (attachment) => ({
|
||||
uri: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
@@ -539,14 +546,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
})),
|
||||
),
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
})
|
||||
restoreInput()
|
||||
})().catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,8 +351,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const key = tabKey(tab)
|
||||
const next = { title: session.title, directory: session.location.directory }
|
||||
const current = info[key]
|
||||
if (current && current.title === next.title && current.directory === next.directory) return
|
||||
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
|
||||
console.log({ tab, session, current })
|
||||
if (current?.title === next.title && current.directory === next.directory) return
|
||||
setInfo(key, next)
|
||||
},
|
||||
select: navigateTab,
|
||||
|
||||
@@ -193,8 +193,6 @@ export type Endpoint5_13Input = {
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly command: string
|
||||
readonly arguments?: string | undefined
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
|
||||
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
|
||||
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
|
||||
|
||||
@@ -431,8 +431,6 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
||||
id: input["id"],
|
||||
command: input["command"],
|
||||
arguments: input["arguments"],
|
||||
agent: input["agent"],
|
||||
model: input["model"],
|
||||
files: input["files"],
|
||||
agents: input["agents"],
|
||||
skills: input["skills"],
|
||||
|
||||
@@ -635,8 +635,6 @@ export function make(options: ClientOptions) {
|
||||
id: input["id"],
|
||||
command: input["command"],
|
||||
arguments: input["arguments"],
|
||||
agent: input["agent"],
|
||||
model: input["model"],
|
||||
files: input["files"],
|
||||
agents: input["agents"],
|
||||
skills: input["skills"],
|
||||
|
||||
@@ -41,7 +41,6 @@ export type SessionMessageAgentSelected = {
|
||||
time: { created: number }
|
||||
type: "agent-switched"
|
||||
agent: string
|
||||
previous?: string
|
||||
}
|
||||
|
||||
export type PromptBase64 = string
|
||||
@@ -2536,7 +2535,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -2788,7 +2786,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -3040,7 +3037,6 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "agent-switched"
|
||||
readonly agent: string
|
||||
readonly previous?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -3489,8 +3485,6 @@ export type SessionCommandInput = {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3512,8 +3506,6 @@ export type SessionCommandInput = {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3535,8 +3527,6 @@ export type SessionCommandInput = {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3554,58 +3544,10 @@ export type SessionCommandInput = {
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["arguments"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
readonly description?: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly agents?: ReadonlyArray<{
|
||||
readonly name: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly skills?: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
|
||||
}>
|
||||
readonly delivery?: "steer" | "queue" | null
|
||||
readonly resume?: boolean | null
|
||||
}["model"]
|
||||
readonly files?: {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3627,8 +3569,6 @@ export type SessionCommandInput = {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3650,8 +3590,6 @@ export type SessionCommandInput = {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3673,8 +3611,6 @@ export type SessionCommandInput = {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
@@ -3696,8 +3632,6 @@ export type SessionCommandInput = {
|
||||
readonly id?: string | null
|
||||
readonly command: string
|
||||
readonly arguments?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly uri: string
|
||||
readonly name?: string
|
||||
|
||||
@@ -194,10 +194,11 @@ async function formatTypescript(input: string) {
|
||||
|
||||
function renderRegistry(names: string[]) {
|
||||
return `import type { DatabaseMigration } from "./migration"
|
||||
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
|
||||
|
||||
export const migrations = [
|
||||
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
|
||||
])
|
||||
).map((module) => module.default)
|
||||
`
|
||||
}
|
||||
|
||||
@@ -508,23 +508,8 @@ function toolOutput(result: ToolResultValue) {
|
||||
case "text":
|
||||
case "error":
|
||||
return { type: "text" as const, value: messageValue(result.value) }
|
||||
case "content":
|
||||
return {
|
||||
type: "content" as const,
|
||||
value: result.value.map((item) => {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text }
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1]
|
||||
const image = item.mime.toLowerCase().startsWith("image/")
|
||||
if (data !== undefined)
|
||||
return image
|
||||
? { type: "image-data" as const, data, mediaType: item.mime }
|
||||
: { type: "file-data" as const, data, mediaType: item.mime, filename: item.name }
|
||||
return image ? { type: "image-url" as const, url: item.uri } : { type: "file-url" as const, url: item.uri }
|
||||
}),
|
||||
}
|
||||
case "json":
|
||||
return { type: "json" as const, value: jsonValue(result.value) }
|
||||
}
|
||||
return { type: "json" as const, value: jsonValue(result.value) }
|
||||
}
|
||||
|
||||
function tool(input: ToolDefinition): LanguageModelV3FunctionTool {
|
||||
|
||||
@@ -153,12 +153,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
})
|
||||
})
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
@@ -170,6 +168,8 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
})
|
||||
}
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
|
||||
@@ -42,10 +42,10 @@ const databaseLayer = Layer.effect(
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
return provide(join(global.data, filename))
|
||||
}),
|
||||
)
|
||||
|
||||
+45
-84
@@ -1,86 +1,47 @@
|
||||
import type { DatabaseMigration } from "./migration"
|
||||
import m00 from "./migration/20260127222353_familiar_lady_ursula"
|
||||
import m01 from "./migration/20260211171708_add_project_commands"
|
||||
import m02 from "./migration/20260213144116_wakeful_the_professor"
|
||||
import m03 from "./migration/20260225215848_workspace"
|
||||
import m04 from "./migration/20260227213759_add_session_workspace_id"
|
||||
import m05 from "./migration/20260228203230_blue_harpoon"
|
||||
import m06 from "./migration/20260303231226_add_workspace_fields"
|
||||
import m07 from "./migration/20260309230000_move_org_to_state"
|
||||
import m08 from "./migration/20260312043431_session_message_cursor"
|
||||
import m09 from "./migration/20260323234822_events"
|
||||
import m10 from "./migration/20260410174513_workspace-name"
|
||||
import m11 from "./migration/20260413175956_chief_energizer"
|
||||
import m12 from "./migration/20260423070820_add_icon_url_override"
|
||||
import m13 from "./migration/20260427172553_slow_nightmare"
|
||||
import m14 from "./migration/20260428004200_add_session_path"
|
||||
import m15 from "./migration/20260501142318_next_venus"
|
||||
import m16 from "./migration/20260504145000_add_sync_owner"
|
||||
import m17 from "./migration/20260507164347_add_workspace_time"
|
||||
import m18 from "./migration/20260510033149_session_usage"
|
||||
import m19 from "./migration/20260511000411_data_migration_state"
|
||||
import m20 from "./migration/20260511173437_session-metadata"
|
||||
import m21 from "./migration/20260601010001_normalize_storage_paths"
|
||||
import m22 from "./migration/20260601202201_amazing_prowler"
|
||||
import m23 from "./migration/20260602002951_lowly_union_jack"
|
||||
import m24 from "./migration/20260602182828_add_project_directories"
|
||||
import m25 from "./migration/20260603001617_session_message_projection_indexes"
|
||||
import m26 from "./migration/20260603040000_session_message_projection_order"
|
||||
import m27 from "./migration/20260603141458_session_input_inbox"
|
||||
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
|
||||
import m29 from "./migration/20260604172448_event_sourced_session_input"
|
||||
import m30 from "./migration/20260605003541_add_session_context_snapshot"
|
||||
import m31 from "./migration/20260605042240_add_context_epoch_agent"
|
||||
import m32 from "./migration/20260611035744_credential"
|
||||
import m33 from "./migration/20260611192811_lush_chimera"
|
||||
import m34 from "./migration/20260612174303_project_dir_strategy"
|
||||
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
|
||||
import m36 from "./migration/20260622170816_reset_v2_session_state"
|
||||
import m37 from "./migration/20260622202450_simplify_session_input"
|
||||
import m38 from "./migration/20260804233008_loose_psylocke"
|
||||
import m39 from "./migration/20260805200742_import_legacy_credentials"
|
||||
import m40 from "./migration/20260808023530_workspace_domain"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
m01,
|
||||
m02,
|
||||
m03,
|
||||
m04,
|
||||
m05,
|
||||
m06,
|
||||
m07,
|
||||
m08,
|
||||
m09,
|
||||
m10,
|
||||
m11,
|
||||
m12,
|
||||
m13,
|
||||
m14,
|
||||
m15,
|
||||
m16,
|
||||
m17,
|
||||
m18,
|
||||
m19,
|
||||
m20,
|
||||
m21,
|
||||
m22,
|
||||
m23,
|
||||
m24,
|
||||
m25,
|
||||
m26,
|
||||
m27,
|
||||
m28,
|
||||
m29,
|
||||
m30,
|
||||
m31,
|
||||
m32,
|
||||
m33,
|
||||
m34,
|
||||
m35,
|
||||
m36,
|
||||
m37,
|
||||
m38,
|
||||
m39,
|
||||
m40,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
export const migrations: DatabaseMigration.Migration[] = (
|
||||
await Promise.all([
|
||||
import("./migration/20260127222353_familiar_lady_ursula"),
|
||||
import("./migration/20260211171708_add_project_commands"),
|
||||
import("./migration/20260213144116_wakeful_the_professor"),
|
||||
import("./migration/20260225215848_workspace"),
|
||||
import("./migration/20260227213759_add_session_workspace_id"),
|
||||
import("./migration/20260228203230_blue_harpoon"),
|
||||
import("./migration/20260303231226_add_workspace_fields"),
|
||||
import("./migration/20260309230000_move_org_to_state"),
|
||||
import("./migration/20260312043431_session_message_cursor"),
|
||||
import("./migration/20260323234822_events"),
|
||||
import("./migration/20260410174513_workspace-name"),
|
||||
import("./migration/20260413175956_chief_energizer"),
|
||||
import("./migration/20260423070820_add_icon_url_override"),
|
||||
import("./migration/20260427172553_slow_nightmare"),
|
||||
import("./migration/20260428004200_add_session_path"),
|
||||
import("./migration/20260501142318_next_venus"),
|
||||
import("./migration/20260504145000_add_sync_owner"),
|
||||
import("./migration/20260507164347_add_workspace_time"),
|
||||
import("./migration/20260510033149_session_usage"),
|
||||
import("./migration/20260511000411_data_migration_state"),
|
||||
import("./migration/20260511173437_session-metadata"),
|
||||
import("./migration/20260601010001_normalize_storage_paths"),
|
||||
import("./migration/20260601202201_amazing_prowler"),
|
||||
import("./migration/20260602002951_lowly_union_jack"),
|
||||
import("./migration/20260602182828_add_project_directories"),
|
||||
import("./migration/20260603001617_session_message_projection_indexes"),
|
||||
import("./migration/20260603040000_session_message_projection_order"),
|
||||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
import("./migration/20260604172448_event_sourced_session_input"),
|
||||
import("./migration/20260605003541_add_session_context_snapshot"),
|
||||
import("./migration/20260605042240_add_context_epoch_agent"),
|
||||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||
import("./migration/20260622170816_reset_v2_session_state"),
|
||||
import("./migration/20260622202450_simplify_session_input"),
|
||||
import("./migration/20260804233008_loose_psylocke"),
|
||||
import("./migration/20260805200742_import_legacy_credentials"),
|
||||
import("./migration/20260808023530_workspace_domain"),
|
||||
])
|
||||
).map((module) => module.default)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -612,16 +612,7 @@ export const layer = (options?: Options) =>
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
|
||||
// Best-effort: a cache-write failure must never kill catalog
|
||||
// population. The payload has outgrown some KV backends' per-value
|
||||
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||
// passed it in Aug 2026); a boot without a cache hit just refetches.
|
||||
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
),
|
||||
)
|
||||
yield* kv.set(key, { updatedAt: Date.now(), body: text })
|
||||
return catalog
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as PluginHooks from "./hooks"
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||
import type { ToolFailures, ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { State } from "../state"
|
||||
@@ -15,29 +15,19 @@ export interface Domains {
|
||||
readonly tool: ToolHooks
|
||||
}
|
||||
|
||||
type NoFailures<Spec> = { readonly [Name in keyof Spec]: never }
|
||||
|
||||
// Failure channel for each hook event. Only tool execute.before may fail: a Tool.Error rejects the call before it runs.
|
||||
interface Failures extends Record<keyof Domains, unknown> {
|
||||
readonly aisdk: NoFailures<AISDKHooks>
|
||||
readonly session: NoFailures<SessionHooks>
|
||||
readonly shell: NoFailures<ShellHooks>
|
||||
readonly tool: ToolFailures
|
||||
}
|
||||
|
||||
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
||||
type Callback<Event> = (event: Event) => Effect.Effect<void>
|
||||
|
||||
export interface Interface {
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
callback: Callback<Domains[Domain][Name], Failures[Domain][Name]>,
|
||||
callback: Callback<Domains[Domain][Name]>,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
event: Domains[Domain][Name],
|
||||
) => Effect.Effect<Domains[Domain][Name], Failures[Domain][Name]>
|
||||
) => Effect.Effect<Domains[Domain][Name]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginHooks") {}
|
||||
@@ -66,9 +56,7 @@ const layer = Layer.effect(
|
||||
|
||||
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
|
||||
for (const callback of callbacks.get(key(domain, name)) ?? []) {
|
||||
const result: Effect.Effect<void, Failures[typeof domain][typeof name]> = Reflect.apply(callback, undefined, [
|
||||
event,
|
||||
])
|
||||
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
|
||||
yield* result
|
||||
}
|
||||
return event
|
||||
|
||||
@@ -36,7 +36,6 @@ export const ModelsDevPlugin = define({
|
||||
draft.integrationID = Integration.ID.make(provider.info.id)
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
if (model.status === "deprecated") continue
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,8 +310,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
...input,
|
||||
sessionID: Session.ID.make(input.sessionID),
|
||||
id: input.id == null ? undefined : SessionMessage.ID.make(input.id),
|
||||
agent: input.agent == null ? undefined : Agent.ID.make(input.agent),
|
||||
model: input.model == null ? undefined : model(input.model),
|
||||
skills: input.skills?.map((skill) => ({ ...skill, id: Skill.ID.make(skill.id) })),
|
||||
arguments: input.arguments ?? undefined,
|
||||
delivery: input.delivery ?? undefined,
|
||||
|
||||
@@ -233,8 +233,6 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
command: string
|
||||
arguments?: string
|
||||
agent?: Agent.ID
|
||||
model?: Model.Ref
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
skills?: PromptInput.Prompt["skills"]
|
||||
@@ -625,13 +623,13 @@ const layer = Layer.effect(
|
||||
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
|
||||
|
||||
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
|
||||
const agent = command.agent ?? input.agent
|
||||
const agent = command.agent
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (!command.agent) return undefined
|
||||
const agents = yield* Agent.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
return yield* agents.get(Agent.ID.make(command.agent))
|
||||
})
|
||||
const model = command.model ?? commandAgent?.model ?? input.model
|
||||
const model = command.model ?? commandAgent?.model
|
||||
if (agent !== undefined && session.agent !== Agent.ID.make(agent))
|
||||
yield* result.switchAgent({ sessionID: input.sessionID, agent: Agent.ID.make(agent) })
|
||||
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
|
||||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
@@ -60,19 +59,15 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.created": () => Effect.void,
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const previous = yield* adapter.getAgent()
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
})
|
||||
return adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
},
|
||||
"session.model.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "../agent"
|
||||
import { Model } from "../model"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
@@ -231,17 +230,6 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
}
|
||||
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
|
||||
const adapter: SessionMessageUpdater.Adapter = {
|
||||
getAgent() {
|
||||
return db
|
||||
.select({ agent: SessionTable.agent })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
|
||||
)
|
||||
},
|
||||
getModel() {
|
||||
return db
|
||||
.select({ model: SessionTable.model })
|
||||
@@ -410,15 +398,12 @@ const layer = Layer.effectDiscard(
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.AgentSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||
)
|
||||
yield* bus.project(SessionEvent.ModelSelected, (event) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -59,25 +59,6 @@ const attachmentContent = (file: FileAttachment): ContentPart[] => {
|
||||
return []
|
||||
}
|
||||
|
||||
const userAttachmentContent = (files: readonly FileAttachment[]) => {
|
||||
const eligible = files.filter(
|
||||
(file) => imageMimes.has(file.mime) && file.source.type === "inline" && file.mention?.text,
|
||||
)
|
||||
if (eligible.length < 2) return files.flatMap(attachmentContent)
|
||||
|
||||
const seen = new Map<string, Set<string>>()
|
||||
return files.flatMap((file) => {
|
||||
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
|
||||
return attachmentContent(file)
|
||||
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
|
||||
const payloads = seen.get(metadata) ?? new Set<string>()
|
||||
if (payloads.has(file.data)) return []
|
||||
payloads.add(file.data)
|
||||
seen.set(metadata, payloads)
|
||||
return attachmentContent(file)
|
||||
})
|
||||
}
|
||||
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
const providerMetadata = (
|
||||
@@ -205,7 +186,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
const content = [
|
||||
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
|
||||
...(message.text === "" ? [] : [Message.text(message.text)]),
|
||||
...userAttachmentContent(message.files ?? []),
|
||||
...(message.files ?? []).flatMap(attachmentContent),
|
||||
]
|
||||
if (content.length === 0) return []
|
||||
return [
|
||||
|
||||
@@ -275,73 +275,6 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves tool result content in AI SDK prompts", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "attachments" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
|
||||
mime: "application/pdf",
|
||||
name: "document.pdf",
|
||||
},
|
||||
{ type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
|
||||
{ type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
|
||||
{ type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
output: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "attachments" },
|
||||
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
|
||||
{
|
||||
type: "file-data",
|
||||
data: "JVBERg==",
|
||||
mediaType: "application/pdf",
|
||||
filename: "document.pdf",
|
||||
},
|
||||
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
|
||||
{ type: "image-url", url: "https://example.com/pixel.png" },
|
||||
{ type: "file-url", url: "https://example.com/document.pdf" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed AI SDK tool input without executing it", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -185,15 +185,6 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
|
||||
]),
|
||||
)
|
||||
|
||||
// Mirrors production KV backends whose writes die as defects (e.g. Durable
|
||||
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
|
||||
const makeFailingWriteKV = (cache: MockCache) =>
|
||||
Layer.mock(KV.Service, {
|
||||
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
|
||||
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const makeCache = (): MockCache => ({ values: new Map() })
|
||||
|
||||
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
|
||||
@@ -257,25 +248,6 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() still populates the catalog when the KV cache write fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const layer = Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeFailingWriteKV(cache)],
|
||||
]),
|
||||
)
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.has(cacheKey)).toBe(false)
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses the default models URL when the configured URL is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
@@ -396,51 +395,4 @@ describe("Plugin", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects tool execution when an execute.before hook fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const executed: unknown[] = []
|
||||
|
||||
const plugin = EffectPlugin.define({
|
||||
id: "tool-hook-reject",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
draft.add({
|
||||
name: "echo",
|
||||
options: { codemode: false },
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) =>
|
||||
Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.tool
|
||||
.hook("execute.before", () => new ToolFailure({ message: "write disabled" }))
|
||||
.pipe(Effect.asVoid)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.activate([versioned(plugin)])
|
||||
|
||||
const toolSet = yield* registry.snapshot()
|
||||
const failure = yield* toolSet
|
||||
.execute({
|
||||
sessionID: Session.ID.make("ses_hook_reject"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_hook_reject"),
|
||||
call: { type: "tool-call", id: "call-hook-reject", name: "echo", input: { text: "original" } },
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({ _tag: "Tool.Error", message: "write disabled" })
|
||||
expect(executed).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -215,66 +215,6 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits deprecated models from the catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const activeID = Model.ID.make("current")
|
||||
const deprecatedID = Model.ID.make("legacy")
|
||||
const model = {
|
||||
modelID: activeID,
|
||||
providerID,
|
||||
name: "Current",
|
||||
capabilities: { tools: true, input: [], output: [] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 32_000 },
|
||||
} satisfies Omit<Model.Info, "id">
|
||||
const snapshots = [
|
||||
{
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
environment: [],
|
||||
models: [
|
||||
{ id: activeID, ...model },
|
||||
{
|
||||
id: deprecatedID,
|
||||
...model,
|
||||
modelID: deprecatedID,
|
||||
name: "Legacy",
|
||||
status: "deprecated" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]
|
||||
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
integration: integrationHost(integrations),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
ModelsDev.Service,
|
||||
ModelsDev.Service.of({
|
||||
get: () => Effect.succeed(snapshots),
|
||||
refresh: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* catalog.model.get(providerID, activeID)).toBeDefined()
|
||||
expect(yield* catalog.model.get(providerID, deprecatedID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers key methods for providers with environment variables", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -647,7 +647,7 @@ describe("Session.create", () => {
|
||||
it.effect("switches the selected agent through the durable Session event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location, agent: Agent.ID.make("build") })
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
|
||||
|
||||
@@ -655,9 +655,6 @@ describe("Session.create", () => {
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||
{ type: "agent-switched", agent: "plan", previous: "build" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -358,7 +358,6 @@ describe("SessionProjector", () => {
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
agent: "plan",
|
||||
model: previousModel,
|
||||
})
|
||||
.run()
|
||||
@@ -460,10 +459,6 @@ describe("SessionProjector", () => {
|
||||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
expect(messages.find((message) => message.type === "agent-switched")).toMatchObject({
|
||||
agent: build,
|
||||
previous: "plan",
|
||||
})
|
||||
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
|
||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||
command: "pwd",
|
||||
|
||||
@@ -373,103 +373,6 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("deduplicates provider media while preserving durable attachment references", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-duplicate-image"),
|
||||
type: "user",
|
||||
text: "[Image 1] [Image 1] [Image 2]",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 10, end: 19, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
description: "alternate use",
|
||||
mention: { start: 20, end: 29, text: "[Image 2]" },
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "[Image 1] [Image 1] [Image 2]" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
|
||||
{
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data,
|
||||
filename: "image.png",
|
||||
metadata: { description: "alternate use" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves provider media with distinct labels or URI sources", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-distinct-images"),
|
||||
type: "user",
|
||||
text: "[Image 1] [Image 2]",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
mention: { start: 10, end: 19, text: "[Image 2]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "file:///project/image.png" },
|
||||
name: "image.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content.filter((part) => part.type === "media")).toHaveLength(4)
|
||||
})
|
||||
|
||||
test("replays durable tool media into canonical tool messages without structured base64", () => {
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
|
||||
@@ -57,35 +57,35 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
|
||||
expect(desktop).toContain("NoDisplay=true")
|
||||
})
|
||||
|
||||
for (const channel of ["dev", "beta"] as const) {
|
||||
test(`bundles the CLI outside the ${channel} app archive`, async () => {
|
||||
test("bundles the CLI outside the dev app archive", async () => {
|
||||
const previous = process.env.OPENCODE_CHANNEL
|
||||
process.env.OPENCODE_CHANNEL = "dev"
|
||||
const module = await import("./electron-builder.config.ts?cli-resource")
|
||||
const config = module.default as Configuration
|
||||
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||
else process.env.OPENCODE_CHANNEL = previous
|
||||
|
||||
expect(config.files).toContain("!resources/opencode-cli*")
|
||||
expect(config.extraResources).toContainEqual({
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli*"],
|
||||
})
|
||||
})
|
||||
|
||||
for (const channel of ["beta", "prod"] as const) {
|
||||
test(`does not bundle the CLI in ${channel} builds`, async () => {
|
||||
const previous = process.env.OPENCODE_CHANNEL
|
||||
process.env.OPENCODE_CHANNEL = channel
|
||||
const module = await import(`./electron-builder.config.ts?cli-resource=${channel}`)
|
||||
const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`)
|
||||
const config = module.default as Configuration
|
||||
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||
else process.env.OPENCODE_CHANNEL = previous
|
||||
|
||||
expect(config.files).toContain("!resources/opencode-cli*")
|
||||
expect(config.extraResources).toContainEqual({
|
||||
expect(config.extraResources).not.toContainEqual({
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli*"],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test("does not bundle the CLI in prod builds", async () => {
|
||||
const previous = process.env.OPENCODE_CHANNEL
|
||||
process.env.OPENCODE_CHANNEL = "prod"
|
||||
const module = await import("./electron-builder.config.ts?no-cli-resource=prod")
|
||||
const config = module.default as Configuration
|
||||
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
|
||||
else process.env.OPENCODE_CHANNEL = previous
|
||||
|
||||
expect(config.extraResources).not.toContainEqual({
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli*"],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,7 +57,7 @@ const getBase = (appId: string): Configuration => ({
|
||||
},
|
||||
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
|
||||
extraResources: [
|
||||
...(channel !== "prod"
|
||||
...(channel === "dev"
|
||||
? [
|
||||
{
|
||||
from: "resources/",
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { $ } from "bun"
|
||||
import * as path from "node:path"
|
||||
|
||||
import { CLI_TARGET } from "./utils"
|
||||
import { RUST_TARGET } from "./utils"
|
||||
|
||||
if (!CLI_TARGET) throw new Error("OPENCODE_CLI_TARGET not defined")
|
||||
if (!RUST_TARGET) throw new Error("RUST_TARGET not defined")
|
||||
|
||||
const BUNDLE_DIR = "dist"
|
||||
const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles")
|
||||
|
||||
@@ -8,4 +8,3 @@ await $`bun ./scripts/copy-icons.ts ${channel}`
|
||||
await $`bun ./scripts/copy-metainfo.ts ${channel}`
|
||||
|
||||
if (channel === "dev") await downloadCliToResources()
|
||||
if (channel === "beta") await downloadCliToResources("next")
|
||||
|
||||
@@ -13,46 +13,46 @@ export function resolveChannel(): Channel {
|
||||
return "dev"
|
||||
}
|
||||
|
||||
export const CLI_BINARIES: Array<{ target: string; package: string; os: string; cpu: string }> = [
|
||||
export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [
|
||||
{
|
||||
target: "aarch64-apple-darwin",
|
||||
rustTarget: "aarch64-apple-darwin",
|
||||
package: "@opencode-ai/cli-darwin-arm64",
|
||||
os: "darwin",
|
||||
cpu: "arm64",
|
||||
},
|
||||
{
|
||||
target: "x86_64-apple-darwin",
|
||||
rustTarget: "x86_64-apple-darwin",
|
||||
package: "@opencode-ai/cli-darwin-x64-baseline",
|
||||
os: "darwin",
|
||||
cpu: "x64",
|
||||
},
|
||||
{
|
||||
target: "aarch64-pc-windows-msvc",
|
||||
rustTarget: "aarch64-pc-windows-msvc",
|
||||
package: "@opencode-ai/cli-windows-arm64",
|
||||
os: "win32",
|
||||
cpu: "arm64",
|
||||
},
|
||||
{
|
||||
target: "x86_64-pc-windows-msvc",
|
||||
rustTarget: "x86_64-pc-windows-msvc",
|
||||
package: "@opencode-ai/cli-windows-x64-baseline",
|
||||
os: "win32",
|
||||
cpu: "x64",
|
||||
},
|
||||
{
|
||||
target: "x86_64-unknown-linux-gnu",
|
||||
rustTarget: "x86_64-unknown-linux-gnu",
|
||||
package: "@opencode-ai/cli-linux-x64-baseline",
|
||||
os: "linux",
|
||||
cpu: "x64",
|
||||
},
|
||||
{
|
||||
target: "aarch64-unknown-linux-gnu",
|
||||
rustTarget: "aarch64-unknown-linux-gnu",
|
||||
package: "@opencode-ai/cli-linux-arm64",
|
||||
os: "linux",
|
||||
cpu: "arm64",
|
||||
},
|
||||
]
|
||||
|
||||
export const CLI_TARGET = Bun.env.OPENCODE_CLI_TARGET
|
||||
export const RUST_TARGET = Bun.env.RUST_TARGET
|
||||
|
||||
function nativeTarget() {
|
||||
const { platform, arch } = process
|
||||
@@ -62,19 +62,19 @@ function nativeTarget() {
|
||||
throw new Error(`Unsupported platform: ${platform}/${arch}`)
|
||||
}
|
||||
|
||||
export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
|
||||
const binaryConfig = CLI_BINARIES.find((item) => item.target === target)
|
||||
export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) {
|
||||
const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target)
|
||||
if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`)
|
||||
|
||||
return binaryConfig
|
||||
}
|
||||
|
||||
export async function downloadCliToResources(version = CLI_VERSION) {
|
||||
export async function downloadCliToResources() {
|
||||
const cli = getCurrentCli()
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
||||
const dest = windowsify("resources/opencode-cli")
|
||||
try {
|
||||
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
|
||||
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
|
||||
await copyFile(
|
||||
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
|
||||
dest,
|
||||
@@ -88,7 +88,7 @@ export async function downloadCliToResources(version = CLI_VERSION) {
|
||||
}
|
||||
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
|
||||
|
||||
console.log(`Copied ${cli.package}@${version} to ${dest}`)
|
||||
console.log(`Copied ${cli.package} to ${dest}`)
|
||||
}
|
||||
|
||||
export function windowsify(path: string) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Service } from "@opencode-ai/client/service"
|
||||
import { execFile } from "node:child_process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
|
||||
@@ -9,34 +8,52 @@ import { app } from "electron"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
const stateHome = process.env.XDG_STATE_HOME
|
||||
const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"]
|
||||
|
||||
type Logger = {
|
||||
log(message: string, meta?: Record<string, unknown>): void
|
||||
error(message: string, meta?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
export async function startBackgroundCli(logger: Logger) {
|
||||
export async function startBackgroundCli(logger: Logger, shellStateHome?: string) {
|
||||
const bundled = app.isPackaged
|
||||
? join(process.resourcesPath, executableName())
|
||||
: join(root, "../../resources", executableName())
|
||||
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = parseVersion(await run(bundled, ["--version"], logger))
|
||||
const version = await run(bundled, ["--version"], logger)
|
||||
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
|
||||
const service = await Service.ensure({
|
||||
version,
|
||||
command: [binary, "serve", "--service"],
|
||||
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||
|
||||
const candidates = [
|
||||
...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]),
|
||||
].filter((candidate) => candidate === undefined || existsSync(candidate))
|
||||
const discovered = await Promise.all(
|
||||
candidates.map(async (candidate) => ({
|
||||
stateHome: candidate,
|
||||
url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })),
|
||||
})),
|
||||
)
|
||||
const found = discovered.find((candidate) => candidate.url !== undefined)
|
||||
logger.log("v2 CLI background instance checked", {
|
||||
detected: Boolean(found),
|
||||
...endpoint(found?.url),
|
||||
})
|
||||
|
||||
const daemonStateHome = found?.stateHome ?? stateHome
|
||||
const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome })
|
||||
const password = await run(binary, ["service", "get", "password"], logger, {
|
||||
redact: true,
|
||||
stateHome: daemonStateHome,
|
||||
})
|
||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||
logger.log("v2 CLI background service ready", {
|
||||
username: service.auth.username,
|
||||
version,
|
||||
...endpoint(service.url),
|
||||
existing: Boolean(found),
|
||||
username: "opencode",
|
||||
...endpoint(url),
|
||||
})
|
||||
return {
|
||||
url: service.url,
|
||||
username: service.auth.username,
|
||||
password: service.auth.password,
|
||||
url,
|
||||
username: "opencode",
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,13 +77,21 @@ async function installCli(source: string, version: string, logger: Logger) {
|
||||
return destination
|
||||
}
|
||||
|
||||
async function run(binary: string, args: string[], logger: Logger) {
|
||||
async function run(
|
||||
binary: string,
|
||||
args: string[],
|
||||
logger: Logger,
|
||||
options: { redact?: boolean; stateHome?: string } = {},
|
||||
) {
|
||||
logger.log("v2 CLI command started", { binary, args })
|
||||
return execFileAsync(binary, args, { windowsHide: true }).then(
|
||||
const env = { ...process.env }
|
||||
if (options.stateHome === undefined) delete env.XDG_STATE_HOME
|
||||
else env.XDG_STATE_HOME = options.stateHome
|
||||
return execFileAsync(binary, args, { env, windowsHide: true }).then(
|
||||
(result) => {
|
||||
const stdout = result.stdout.trim()
|
||||
const stderr = result.stderr.trim()
|
||||
logger.log("v2 CLI command completed", { args, stdout, stderr })
|
||||
logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr })
|
||||
return stdout
|
||||
},
|
||||
(error: unknown) => {
|
||||
@@ -74,7 +99,7 @@ async function run(binary: string, args: string[], logger: Logger) {
|
||||
logger.error("v2 CLI command failed", {
|
||||
args,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stdout: output.stdout?.trim() ?? "",
|
||||
stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""),
|
||||
stderr: output.stderr?.trim() ?? "",
|
||||
})
|
||||
throw error
|
||||
@@ -82,11 +107,11 @@ async function run(binary: string, args: string[], logger: Logger) {
|
||||
)
|
||||
}
|
||||
|
||||
function parseVersion(output: string) {
|
||||
const marker = output.lastIndexOf(" v")
|
||||
const version = marker === -1 ? output : output.slice(marker + 2)
|
||||
if (!version) throw new Error("V2 CLI did not provide a version")
|
||||
return version
|
||||
function serviceUrl(status: string) {
|
||||
if (URL.canParse(status)) return status
|
||||
if (!status.startsWith("running ")) return
|
||||
const url = status.slice("running ".length).trim()
|
||||
return URL.canParse(url) ? url : undefined
|
||||
}
|
||||
|
||||
function endpoint(url: string | undefined) {
|
||||
|
||||
@@ -181,7 +181,7 @@ const main = Effect.gen(function* () {
|
||||
return
|
||||
}
|
||||
|
||||
preferAppEnv()
|
||||
const shellEnv = preferAppEnv(app.getPath("userData"))
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
@@ -310,7 +310,7 @@ const main = Effect.gen(function* () {
|
||||
useEnvProxy()
|
||||
|
||||
logger.log("starting v2 background service")
|
||||
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
|
||||
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME))
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url: sidecar.url,
|
||||
username: sidecar.username,
|
||||
|
||||
@@ -17,16 +17,17 @@ export function setDefaultServerUrl(url: string | null) {
|
||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||
}
|
||||
|
||||
export function preferAppEnv() {
|
||||
export function preferAppEnv(userDataPath: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
|
||||
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
|
||||
Object.assign(process.env, {
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
|
||||
})
|
||||
return shellEnv
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
|
||||
@@ -77,21 +77,20 @@ export function spatialPathSpans(points: readonly DiagramPoint[]): SpatialSpan[]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.flatMap(([y, xs]) => {
|
||||
const sorted = [...xs].sort((left, right) => left - right)
|
||||
const [first, ...rest] = sorted
|
||||
if (first === undefined) return []
|
||||
const spans: SpatialSpan[] = []
|
||||
let start = first
|
||||
let end = first
|
||||
for (const x of rest) {
|
||||
if (x === end + 1) {
|
||||
let start = sorted[0]
|
||||
let end = start
|
||||
if (start === undefined) return spans
|
||||
for (const x of sorted.slice(1)) {
|
||||
if (x === end! + 1) {
|
||||
end = x
|
||||
continue
|
||||
}
|
||||
spans.push(normalizedSpan(y, start, end))
|
||||
spans.push(normalizedSpan(y, start, end!))
|
||||
start = x
|
||||
end = x
|
||||
}
|
||||
spans.push(normalizedSpan(y, start, end))
|
||||
spans.push(normalizedSpan(y, start, end!))
|
||||
return spans
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
TextRenderable,
|
||||
BoxRenderable,
|
||||
RenderableEvents,
|
||||
createMarkdownCodeBlockRenderer,
|
||||
parseColor,
|
||||
@@ -34,7 +33,6 @@ interface PreparedDiagram {
|
||||
readonly source: string
|
||||
readonly text: StyledText
|
||||
readonly height: number
|
||||
readonly width: number
|
||||
}
|
||||
|
||||
export interface MermaidMarkdownRendererOptions {
|
||||
@@ -57,23 +55,16 @@ function color(value: ColorInput | undefined): RGBA | undefined {
|
||||
return value === undefined ? undefined : parseColor(value)
|
||||
}
|
||||
|
||||
class StaticDiagramRenderable extends BoxRenderable {
|
||||
class StaticDiagramRenderable extends TextRenderable {
|
||||
constructor(ctx: RenderContext, prepared: PreparedDiagram) {
|
||||
super(ctx, {
|
||||
width: "100%",
|
||||
alignItems: "flex-start",
|
||||
flexShrink: 0,
|
||||
marginTop: 1,
|
||||
})
|
||||
const diagram = new TextRenderable(ctx, {
|
||||
content: prepared.text,
|
||||
width: prepared.width,
|
||||
maxWidth: "100%",
|
||||
width: "100%",
|
||||
height: prepared.height,
|
||||
wrapMode: "none",
|
||||
selectable: false,
|
||||
marginTop: 1,
|
||||
})
|
||||
this.add(diagram)
|
||||
let dragX: number | undefined
|
||||
this.onMouseDown = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
@@ -88,7 +79,7 @@ class StaticDiagramRenderable extends BoxRenderable {
|
||||
if (dragX === undefined) return
|
||||
const dx = event.x - dragX
|
||||
dragX = event.x
|
||||
if (dx) diagram.scrollX -= dx
|
||||
if (dx) this.scrollX -= dx
|
||||
}
|
||||
this.onMouseDragEnd = (event: MouseEvent) => {
|
||||
dragX = undefined
|
||||
@@ -130,7 +121,6 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
case "sequence": {
|
||||
@@ -154,7 +144,6 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
case "state": {
|
||||
@@ -179,7 +168,6 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TextRenderable, TreeSitterClient } from "@opentui/core"
|
||||
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TreeSitterClient } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { createMermaidMarkdownRenderer } from "../markdown.js"
|
||||
|
||||
@@ -78,31 +78,6 @@ flowchart LR
|
||||
expect(markdown.getChildren()[0]?.marginTop).toBe(1)
|
||||
})
|
||||
|
||||
test("leaves Mermaid alignment to its containing layout", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-centered-mermaid",
|
||||
content: `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const line = testRenderer
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((value) => value.includes("Start"))
|
||||
if (!line) throw new Error("Expected the rendered diagram")
|
||||
expect(line.indexOf("Start")).toBeLessThan(10)
|
||||
})
|
||||
|
||||
test("recognizes normalized Mermaid fence info strings", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
@@ -260,19 +235,17 @@ sequenceDiagram
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const wrapper = markdown.getChildren()[0]
|
||||
if (!wrapper) throw new Error("Expected the rendered diagram wrapper")
|
||||
const diagram = wrapper.getChildren()[0] as TextRenderable
|
||||
const diagram = markdown.getChildren()[0] as CodeRenderable
|
||||
expect(diagram.scrollWidth).toBeGreaterThan(diagram.width)
|
||||
expect(diagram.scrollX).toBe(0)
|
||||
|
||||
await testRenderer.mockMouse.drag(wrapper.x + 20, wrapper.y + 2, wrapper.x + 5, wrapper.y + 2)
|
||||
await testRenderer.mockMouse.drag(diagram.x + 20, diagram.y + 2, diagram.x + 5, diagram.y + 2)
|
||||
await testRenderer.renderOnce()
|
||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||
expect(diagram.hasSelection()).toBe(false)
|
||||
|
||||
diagram.scrollX = 0
|
||||
await testRenderer.mockMouse.scroll(wrapper.x + 20, wrapper.y + 2, "right")
|
||||
await testRenderer.mockMouse.scroll(diagram.x + 20, diagram.y + 2, "right")
|
||||
await testRenderer.renderOnce()
|
||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -4,11 +4,9 @@ export interface Registration {
|
||||
readonly dispose: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type Hooks<Spec, Failures extends Record<keyof Spec, unknown> = Record<keyof Spec, never>> = <
|
||||
Name extends keyof Spec,
|
||||
>(
|
||||
export type Hooks<Spec> = <Name extends keyof Spec>(
|
||||
name: Name,
|
||||
callback: (input: Spec[Name]) => Effect.Effect<void, Failures[Name]>,
|
||||
callback: (input: Spec[Name]) => Effect.Effect<void>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
export type Transform<Input> = (callback: (input: Input) => void) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
@@ -38,13 +38,7 @@ export interface ToolHooks {
|
||||
)
|
||||
}
|
||||
|
||||
// Only execute.before may fail: a Tool.Error rejects the call before the tool runs.
|
||||
export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
|
||||
readonly "execute.before": Tool.Error
|
||||
readonly "execute.after": never
|
||||
}
|
||||
|
||||
export interface ToolDomain {
|
||||
readonly transform: Transform<ToolDraft>
|
||||
readonly hook: Hooks<ToolHooks, ToolFailures>
|
||||
readonly hook: Hooks<ToolHooks>
|
||||
}
|
||||
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
@@ -341,8 +341,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
id: SessionMessage.ID.pipe(Schema.optional),
|
||||
command: Schema.String,
|
||||
arguments: Schema.String.pipe(Schema.optional),
|
||||
agent: Agent.ID.pipe(Schema.optional),
|
||||
model: Model.Ref.pipe(Schema.optional),
|
||||
files: PromptInput.Prompt.fields.files,
|
||||
agents: PromptInput.Prompt.fields.agents,
|
||||
skills: PromptInput.Prompt.fields.skills,
|
||||
|
||||
@@ -42,7 +42,6 @@ export const AgentSelected = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.tag("agent-switched"),
|
||||
agent: Agent.ID,
|
||||
previous: Agent.ID.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.AgentSelected" })
|
||||
|
||||
export interface ModelSelected extends Schema.Schema.Type<typeof ModelSelected> {}
|
||||
|
||||
@@ -355,8 +355,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
id: ctx.payload.id,
|
||||
command: ctx.payload.command,
|
||||
arguments: ctx.payload.arguments,
|
||||
agent: ctx.payload.agent,
|
||||
model: ctx.payload.model,
|
||||
files: ctx.payload.files,
|
||||
agents: ctx.payload.agents,
|
||||
skills: ctx.payload.skills,
|
||||
|
||||
@@ -60,15 +60,6 @@ export const settings: Setting[] = [
|
||||
labels: ["off", "on"],
|
||||
keywords: ["scroll bar"],
|
||||
},
|
||||
{
|
||||
title: "Reading width",
|
||||
category: "Session",
|
||||
path: ["session", "max_width"],
|
||||
default: "auto",
|
||||
values: ["auto", 66, 72, 80],
|
||||
labels: ["auto", "66 columns", "72 columns", "80 columns"],
|
||||
keywords: ["transcript", "composer", "centered", "max width", "prose"],
|
||||
},
|
||||
{
|
||||
title: "Thinking",
|
||||
category: "Session",
|
||||
|
||||
@@ -60,11 +60,6 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import {
|
||||
deduplicatePromptImages,
|
||||
preserveMentionlessPromptAttachments,
|
||||
promptAttachmentLabel,
|
||||
} from "../../prompt/attachment"
|
||||
import { DialogImagePreview } from "../dialog-image-preview"
|
||||
|
||||
export type PromptProps = {
|
||||
@@ -336,7 +331,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
const imageAttachments = createMemo(() =>
|
||||
(deduplicatePromptImages(store.prompt.files) ?? []).filter((file) => file.uri.startsWith("data:image/")),
|
||||
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
|
||||
)
|
||||
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
|
||||
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
|
||||
@@ -741,7 +736,6 @@ export function Prompt(props: PromptProps) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const newMap = new Map<number, PromptPartRef>()
|
||||
const fileExtmarks = new Map<number, NonNullable<PromptInfo["files"]>[number]>()
|
||||
const files: NonNullable<PromptInfo["files"]> = []
|
||||
const agents: NonNullable<PromptInfo["agents"]> = []
|
||||
const skills: NonNullable<PromptInfo["skills"]> = []
|
||||
@@ -755,8 +749,9 @@ export function Prompt(props: PromptProps) {
|
||||
if (!part?.mention) continue
|
||||
part.mention.start = extmark.start
|
||||
part.mention.end = extmark.end
|
||||
const index = files.length
|
||||
files.push(part)
|
||||
fileExtmarks.set(extmark.id, part)
|
||||
newMap.set(extmark.id, { type: "file", index })
|
||||
continue
|
||||
}
|
||||
if (ref.type === "agent") {
|
||||
@@ -788,19 +783,8 @@ export function Prompt(props: PromptProps) {
|
||||
newMap.set(extmark.id, { type: "pasted", index })
|
||||
}
|
||||
|
||||
const nextFiles = preserveMentionlessPromptAttachments(draft.prompt.files, files)
|
||||
const fileIndices = new Map(nextFiles.map((file, index) => [file, index]))
|
||||
for (const [extmark, file] of fileExtmarks) {
|
||||
const index = fileIndices.get(file)
|
||||
if (index !== undefined) newMap.set(extmark, { type: "file", index })
|
||||
}
|
||||
|
||||
draft.extmarkToPart = newMap
|
||||
if (
|
||||
nextFiles.length !== draft.prompt.files?.length ||
|
||||
nextFiles.some((file, index) => file !== draft.prompt.files?.[index])
|
||||
)
|
||||
draft.prompt.files = nextFiles
|
||||
draft.prompt.files = files
|
||||
draft.prompt.agents = agents
|
||||
draft.prompt.skills = skills
|
||||
draft.prompt.pasted = pasted
|
||||
@@ -1154,6 +1138,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
|
||||
if (store.mode === "shell") {
|
||||
move.startSubmit()
|
||||
void client.api.session.shell({
|
||||
@@ -1164,15 +1149,23 @@ export function Prompt(props: PromptProps) {
|
||||
} else if (slashHead && isCommand) {
|
||||
move.startSubmit()
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
if (session?.agent !== agent.id) await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||
if (
|
||||
session?.model?.providerID !== model.providerID ||
|
||||
session.model.id !== model.id ||
|
||||
(session.model.variant ?? "default") !== (model.variant ?? "default")
|
||||
)
|
||||
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
|
||||
cancelCommit()
|
||||
throw error
|
||||
})
|
||||
|
||||
void client.api.session
|
||||
.command({
|
||||
sessionID,
|
||||
command: slashHead.name,
|
||||
arguments: slashHead.arguments,
|
||||
agent: agent.id,
|
||||
model,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
|
||||
@@ -1391,7 +1384,13 @@ export function Prompt(props: PromptProps) {
|
||||
function pasteAttachment(file: { filename?: string; uri: string }) {
|
||||
const currentOffset = input.cursorOffset
|
||||
const extmarkStart = currentOffset
|
||||
const virtualText = promptAttachmentLabel(store.prompt.files, { uri: file.uri, name: file.filename })
|
||||
const pdf = file.uri.startsWith("data:application/pdf;")
|
||||
const count = pdf
|
||||
? (store.prompt.files?.filter(
|
||||
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"),
|
||||
).length ?? 0)
|
||||
: imageAttachments().length
|
||||
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const textToInsert = virtualText + " "
|
||||
|
||||
|
||||
@@ -62,9 +62,13 @@ function createMarquee(hovered: () => string | undefined, animations: () => bool
|
||||
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
|
||||
|
||||
createEffect(() => {
|
||||
if (!hovered()) {
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
return
|
||||
}
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
if (!hovered()) return
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
const delay = setTimeout(() => {
|
||||
setOffset(1)
|
||||
|
||||
@@ -125,11 +125,6 @@ export const Info = Schema.Struct({
|
||||
description: "Session sidebar visibility; 'auto' shows it when space permits",
|
||||
}),
|
||||
scrollbar: Schema.optional(Schema.Boolean).annotate({ description: "Show the session transcript scrollbar" }),
|
||||
max_width: Schema.optional(
|
||||
Schema.Union([Schema.Int.check(Schema.isGreaterThan(4)), Schema.Literal("auto")]),
|
||||
).annotate({
|
||||
description: "Session prose and composer max width, or 'auto' to use the available width",
|
||||
}),
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide model reasoning by default",
|
||||
}),
|
||||
|
||||
@@ -386,8 +386,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
}))
|
||||
break
|
||||
case "session.agent.selected": {
|
||||
const previous = store.session.info[event.data.sessionID]?.agent
|
||||
case "session.agent.selected":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
@@ -395,12 +394,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.model.selected":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "model", event.data.model)
|
||||
|
||||
@@ -1653,8 +1653,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
)
|
||||
}
|
||||
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name, delivery })
|
||||
return client.session.command(
|
||||
{
|
||||
@@ -1662,8 +1660,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
id: messageID,
|
||||
command: command.name,
|
||||
arguments: command.arguments,
|
||||
agent: next.agent,
|
||||
model: selected,
|
||||
files: attachments.files.length ? attachments.files : undefined,
|
||||
agents: agents.length ? agents : undefined,
|
||||
skills: skills.length ? skills : undefined,
|
||||
@@ -1706,12 +1702,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
const client = sdk
|
||||
if (next.agent)
|
||||
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||
if (!next.prompt.command) {
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
}
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
mergePending(await admitPrompt(next, client, delivery))
|
||||
settlementClient = client
|
||||
},
|
||||
@@ -1750,6 +1744,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
return
|
||||
}
|
||||
if (command) {
|
||||
if (next.agent)
|
||||
await client.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
|
||||
const selected = await resolveSelectedModel(input, client, next)
|
||||
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
if (selected)
|
||||
await client.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
|
||||
await runTurnWait(
|
||||
next,
|
||||
messageID,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { PromptInput } from "@opencode-ai/schema"
|
||||
|
||||
type PromptFile = PromptInput.FileAttachment
|
||||
type PromptFileIdentity = Pick<PromptFile, "uri" | "name" | "description">
|
||||
type ProjectedFile = Readonly<{
|
||||
data: string
|
||||
mime: string
|
||||
source: { type: string }
|
||||
name?: string
|
||||
description?: string
|
||||
mention?: { text: string }
|
||||
}>
|
||||
|
||||
function attachmentKind(uri: string) {
|
||||
if (uri.startsWith("data:image/")) return "Image"
|
||||
if (uri.startsWith("data:application/pdf;")) return "PDF"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function attachmentMetadata(file: PromptFileIdentity) {
|
||||
return JSON.stringify([file.name ?? null, file.description ?? null])
|
||||
}
|
||||
|
||||
function deduplicateByIdentity<T>(
|
||||
items: readonly T[],
|
||||
identity: (item: T) => { metadata: string; payload: string } | undefined,
|
||||
) {
|
||||
const seen = new Map<string, Set<string>>()
|
||||
return items.filter((item) => {
|
||||
const key = identity(item)
|
||||
if (!key) return true
|
||||
const payloads = seen.get(key.metadata) ?? new Set<string>()
|
||||
if (payloads.has(key.payload)) return false
|
||||
payloads.add(key.payload)
|
||||
seen.set(key.metadata, payloads)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function deduplicatePromptImages(files: readonly PromptFile[] | undefined) {
|
||||
if (!files || files.length < 2) return files
|
||||
return deduplicateByIdentity(files, (file) =>
|
||||
file.uri.startsWith("data:image/") && file.mention?.text
|
||||
? {
|
||||
metadata: JSON.stringify([file.name ?? null, file.description ?? null, file.mention.text]),
|
||||
payload: file.uri,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export function preserveMentionlessPromptAttachments(
|
||||
files: readonly PromptFile[] | undefined,
|
||||
mentioned: PromptFile[],
|
||||
) {
|
||||
if (!files) return mentioned
|
||||
const tracked = mentioned.values()
|
||||
return files.flatMap((file) => {
|
||||
if (!file.mention?.text) return [file]
|
||||
const next = tracked.next()
|
||||
return next.done ? [] : [next.value]
|
||||
})
|
||||
}
|
||||
|
||||
export function deduplicateVisibleImages<T extends ProjectedFile>(files: readonly T[]) {
|
||||
return deduplicateByIdentity(files, (file) =>
|
||||
file.mime.startsWith("image/") && file.source.type === "inline" && file.mention?.text
|
||||
? {
|
||||
metadata: JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text]),
|
||||
payload: file.data,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
export function promptAttachmentLabel(files: readonly PromptFile[] | undefined, file: PromptFileIdentity) {
|
||||
const kind = attachmentKind(file.uri)
|
||||
if (!kind) throw new Error(`Unsupported inline attachment: ${file.uri}`)
|
||||
const metadata = attachmentMetadata(file)
|
||||
const existing =
|
||||
kind === "Image"
|
||||
? files?.find(
|
||||
(candidate) =>
|
||||
candidate.uri === file.uri && attachmentMetadata(candidate) === metadata && candidate.mention?.text,
|
||||
)?.mention?.text
|
||||
: undefined
|
||||
if (existing) return existing
|
||||
|
||||
const pattern = new RegExp(`^\\[${kind} (\\d+)\\]$`)
|
||||
const count =
|
||||
files?.reduce((highest, candidate) => {
|
||||
const match = candidate.mention?.text.match(pattern)
|
||||
return match ? Math.max(highest, Number(match[1])) : highest
|
||||
}, 0) ?? 0
|
||||
return `[${kind} ${count + 1}]`
|
||||
}
|
||||
@@ -68,9 +68,8 @@ import { errorMessage } from "../../util/error"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { sessionLaneLayout, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { deduplicateVisibleImages } from "../../prompt/attachment"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
@@ -108,13 +107,13 @@ import { createSingleFlight } from "../../util/single-flight"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { generateThinkingSyntax } from "./thinking-syntax"
|
||||
import { createDelayedPresence } from "../../util/delayed-presence"
|
||||
import { markdownLaneMarginTop, markdownLanes } from "./markdown-lanes"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
// Exclude temporary bottom space when measuring the real transcript height.
|
||||
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
const BACKGROUND_TOOL_HINT_DELAY = 1_000
|
||||
|
||||
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
|
||||
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
|
||||
// in a few hundred milliseconds without a perceptible pause.
|
||||
@@ -650,18 +649,8 @@ export function Session() {
|
||||
slash: {
|
||||
name: "compact",
|
||||
},
|
||||
run: async () => {
|
||||
const selection = local.model.current()
|
||||
if (selection)
|
||||
await client.api.session.switchModel({
|
||||
sessionID: route.sessionID,
|
||||
model: {
|
||||
providerID: selection.providerID,
|
||||
id: selection.modelID,
|
||||
variant: local.model.variant.current(),
|
||||
},
|
||||
})
|
||||
await client.api.session.compact({ sessionID: route.sessionID })
|
||||
run: () => {
|
||||
void client.api.session.compact({ sessionID: route.sessionID })
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -1084,57 +1073,55 @@ export function Session() {
|
||||
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<SessionContentLane width="readable">
|
||||
<box flexShrink={0}>
|
||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||
</Show>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
|
||||
onClose={() => setComposer("open", false)}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
|
||||
<Match when={promptedPermissions().length > 0}>
|
||||
<Show when={promptedPermissions()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const request = promptedPermissions()[0]
|
||||
return request ? (
|
||||
<PermissionPrompt request={request} directory={session()?.location.directory} />
|
||||
) : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={forms().length > 0}>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? <FormPrompt form={form} /> : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={!disabled()}>
|
||||
<Prompt
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
onEmptySubmit={async () => {
|
||||
const next = queuedPrompts()[0]
|
||||
if (!next) return false
|
||||
return mutatePending("steer", next.id)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</SessionContentLane>
|
||||
<box flexShrink={0}>
|
||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||
</Show>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
|
||||
onClose={() => setComposer("open", false)}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
|
||||
<Match when={promptedPermissions().length > 0}>
|
||||
<Show when={promptedPermissions()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const request = promptedPermissions()[0]
|
||||
return request ? (
|
||||
<PermissionPrompt request={request} directory={session()?.location.directory} />
|
||||
) : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={forms().length > 0}>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? <FormPrompt form={form} /> : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={!disabled()}>
|
||||
<Prompt
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
onEmptySubmit={async () => {
|
||||
const next = queuedPrompts()[0]
|
||||
if (!next) return false
|
||||
return mutatePending("steer", next.id)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={sidebarVisible()}>
|
||||
@@ -1178,30 +1165,22 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.row.type === "compaction-queued"}>
|
||||
<SessionContentLane width="readable">
|
||||
<CompactionQueued />
|
||||
</SessionContentLane>
|
||||
<CompactionQueued />
|
||||
</Match>
|
||||
<Match when={props.row.type === "part" ? props.row : undefined}>
|
||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
<SessionContentLane width="readable">
|
||||
<SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />
|
||||
</SessionContentLane>
|
||||
)}
|
||||
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
<SessionContentLane width="readable">
|
||||
<SessionGroupView
|
||||
refs={row().refs}
|
||||
pending={row().pending}
|
||||
completed={row().completed}
|
||||
message={props.message}
|
||||
/>
|
||||
</SessionContentLane>
|
||||
<SessionGroupView
|
||||
refs={row().refs}
|
||||
pending={row().pending}
|
||||
completed={row().completed}
|
||||
message={props.message}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
|
||||
@@ -1209,9 +1188,7 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
<Show when={props.message(row().messageID)}>
|
||||
{(message) => (
|
||||
<Show when={message().type === "assistant"}>
|
||||
<SessionContentLane width="readable">
|
||||
<AssistantFooter message={message() as SessionMessageAssistant} />
|
||||
</SessionContentLane>
|
||||
<AssistantFooter message={message() as SessionMessageAssistant} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
@@ -1219,13 +1196,7 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
</Match>
|
||||
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
<SessionContentLane width="technical">
|
||||
<TurnTokenUsage
|
||||
messageIDs={row().messageIDs}
|
||||
previousCache={row().previousCache}
|
||||
message={props.message}
|
||||
/>
|
||||
</SessionContentLane>
|
||||
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1233,42 +1204,6 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionContentLane(props: { children: JSX.Element; width: "readable" | "technical" }) {
|
||||
const ctx = use()
|
||||
const readable = () => ctx.config.session?.max_width ?? "auto"
|
||||
const layout = createMemo(() => {
|
||||
const width = readable()
|
||||
return width === "auto" ? undefined : sessionLaneLayout(ctx.width, width)
|
||||
})
|
||||
return (
|
||||
<Show when={layout()} fallback={props.children}>
|
||||
{(value) => (
|
||||
<box width="100%" paddingLeft={value().inset} flexShrink={0}>
|
||||
<box width={value()[props.width]} flexShrink={0}>
|
||||
{props.children}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionBreakoutLane(props: { children: JSX.Element }) {
|
||||
const ctx = use()
|
||||
const readable = () => ctx.config.session?.max_width ?? "auto"
|
||||
const inset = createMemo(() => {
|
||||
const width = readable()
|
||||
return width === "auto" ? undefined : sessionLaneLayout(ctx.width, width).inset
|
||||
})
|
||||
return (
|
||||
<Show when={inset() !== undefined} fallback={props.children}>
|
||||
<box width="100%" paddingLeft={inset()} flexShrink={0}>
|
||||
{props.children}
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function TurnTokenUsage(props: {
|
||||
messageIDs: string[]
|
||||
previousCache?: CacheUsage
|
||||
@@ -1431,35 +1366,20 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
|
||||
<UserMessage message={props.message as SessionMessageUser} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "shell"}>
|
||||
<SessionContentLane width="technical">
|
||||
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
|
||||
</SessionContentLane>
|
||||
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
|
||||
<SessionContentLane width="readable">
|
||||
<SessionSwitchMessageV2 message={props.message} />
|
||||
</SessionContentLane>
|
||||
<SessionSwitchMessageV2 message={props.message} />
|
||||
</Match>
|
||||
<Match
|
||||
when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}
|
||||
>
|
||||
<Show
|
||||
when={props.message.type === "skill"}
|
||||
fallback={
|
||||
<SessionContentLane width="readable">
|
||||
<SessionNoticeMessageV2 message={props.message} />
|
||||
</SessionContentLane>
|
||||
}
|
||||
>
|
||||
<SessionContentLane width="readable">
|
||||
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { type: "skill" }>} />
|
||||
</SessionContentLane>
|
||||
<Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}>
|
||||
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { type: "skill" }>} />
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.message.type === "compaction"}>
|
||||
<SessionContentLane width="readable">
|
||||
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
|
||||
</SessionContentLane>
|
||||
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
@@ -1480,13 +1400,11 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
|
||||
<TextPart part={item() as SessionMessageAssistantText} last={false} />
|
||||
</Match>
|
||||
<Match when={item().type === "reasoning"}>
|
||||
<SessionContentLane width="readable">
|
||||
<ReasoningPart
|
||||
part={item() as SessionMessageAssistantReasoning}
|
||||
message={message() as SessionMessageAssistant}
|
||||
last={false}
|
||||
/>
|
||||
</SessionContentLane>
|
||||
<ReasoningPart
|
||||
part={item() as SessionMessageAssistantReasoning}
|
||||
message={message() as SessionMessageAssistant}
|
||||
last={false}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={item().type === "tool"}>
|
||||
<ToolPart part={item() as SessionMessageAssistantTool} />
|
||||
@@ -1733,12 +1651,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const ctx = use()
|
||||
const theme = useTheme()
|
||||
const text = () => {
|
||||
if (props.message.type === "agent-switched") {
|
||||
const agent = Locale.titlecase(props.message.agent)
|
||||
if (props.message.previous && props.message.previous !== props.message.agent)
|
||||
return `Switched agent from ${Locale.titlecase(props.message.previous)} to ${agent}`
|
||||
return `Switched agent to ${agent}`
|
||||
}
|
||||
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
|
||||
if (props.message.type === "model-switched")
|
||||
return switchLabel(props.message.model, ctx.models(), props.message.previous)
|
||||
return ""
|
||||
@@ -1986,7 +1899,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const files = createMemo(() => deduplicateVisibleImages(props.message.files ?? []))
|
||||
const files = createMemo(() => props.message.files ?? [])
|
||||
const skills = createMemo(() => props.message.skills ?? [])
|
||||
const images = createMemo(() =>
|
||||
files().flatMap((file) =>
|
||||
@@ -2009,56 +1922,79 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
|
||||
return (
|
||||
<Show when={props.message.text.trim() || files().length || skills().length}>
|
||||
<SessionContentLane width="readable">
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<SessionImages images={images()} paddingLeft={2} />
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<SessionImages images={images()} paddingLeft={2} />
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
if (delivery() === "steer") {
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Pending steer"
|
||||
options={[
|
||||
{ title: "Move to queue", value: "queue" as const },
|
||||
{ title: "Delete", value: "cancel" as const },
|
||||
]}
|
||||
onSelect={(option) => {
|
||||
void updatePendingSteer(option.value)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
return
|
||||
}
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
if (delivery() === "steer") {
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={props.message.id}
|
||||
sessionID={ctx.sessionID}
|
||||
setPrompt={(value) => promptRef.current?.set(value)}
|
||||
<DialogSelect
|
||||
title="Pending steer"
|
||||
options={[
|
||||
{ title: "Move to queue", value: "queue" as const },
|
||||
{ title: "Delete", value: "cancel" as const },
|
||||
]}
|
||||
onSelect={(option) => {
|
||||
void updatePendingSteer(option.value)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
<Show when={skills().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={skills()}>
|
||||
{(skill) => (
|
||||
return
|
||||
}
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={props.message.id}
|
||||
sessionID={ctx.sessionID}
|
||||
setPrompt={(value) => promptRef.current?.set(value)}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
<Show when={skills().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={skills()}>
|
||||
{(skill) => (
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||
fg: theme.background.default,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{" skill "}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||
{` ${skill.name} `}
|
||||
</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const label = file.mime === "application/x-directory" ? "dir" : "file"
|
||||
return (
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
@@ -2067,45 +2003,20 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{" skill "}
|
||||
{` ${label} `}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||
{` ${skill.name} `}
|
||||
{" "}
|
||||
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||
</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const label = file.mime === "application/x-directory" ? "dir" : "file"
|
||||
return (
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||
fg: theme.background.default,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{` ${label} `}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||
{" "}
|
||||
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||
</span>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</SessionContentLane>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -2288,16 +2199,14 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const plugins = usePlugin()
|
||||
const constrained = () => (ctx.config.session?.max_width ?? "auto") !== "auto"
|
||||
|
||||
function Content(input: { content: string }) {
|
||||
return (
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<box paddingLeft={3} flexShrink={0}>
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
streaming={true}
|
||||
internalBlockMode="top-level"
|
||||
content={input.content.trim()}
|
||||
content={props.part.text.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.markdown.text}
|
||||
@@ -2305,38 +2214,6 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
renderNode={plugins.markdown()}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<Switch>
|
||||
<Match when={!constrained()}>
|
||||
<Content content={props.part.text} />
|
||||
</Match>
|
||||
<Match when={constrained()}>
|
||||
<Index each={markdownLanes(props.part.text.trim())}>
|
||||
{(segment, index) => {
|
||||
const content = <Content content={segment().content} />
|
||||
return (
|
||||
<box width="100%" marginTop={markdownLaneMarginTop(index, segment().width)} flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={segment().width === "full"}>
|
||||
<SessionBreakoutLane>{content}</SessionBreakoutLane>
|
||||
</Match>
|
||||
<Match when={segment().width === "technical"}>
|
||||
<SessionContentLane width="technical">{content}</SessionContentLane>
|
||||
</Match>
|
||||
<Match when={segment().width === "readable"}>
|
||||
<SessionContentLane width="readable">{content}</SessionContentLane>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -2345,7 +2222,6 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||
const display = createMemo(() => toolDisplay(props.part.name))
|
||||
const width = createMemo(() => toolLane(props.part.name))
|
||||
|
||||
const toolprops = {
|
||||
get metadata() {
|
||||
@@ -2415,12 +2291,10 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
|
||||
</Switch>
|
||||
)
|
||||
return [
|
||||
<SessionContentLane width={width()}>{content}</SessionContentLane>,
|
||||
<SessionContentLane width="readable">
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>
|
||||
</SessionContentLane>,
|
||||
content,
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3254,7 +3128,7 @@ function Edit(props: ToolProps) {
|
||||
const diffView = ctx.config.diffs?.view
|
||||
if (diffView === "unified") return "unified"
|
||||
if (diffView === "split") return "split"
|
||||
if ((ctx.config.session?.max_width ?? "auto") !== "auto") return "unified"
|
||||
// Default to "auto" behavior
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
@@ -3332,7 +3206,6 @@ function ApplyPatch(props: ToolProps) {
|
||||
const view = createMemo(() => {
|
||||
if (ctx.config.diffs?.view === "unified") return "unified"
|
||||
if (ctx.config.diffs?.view === "split") return "split"
|
||||
if ((ctx.config.session?.max_width ?? "auto") !== "auto") return "unified"
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
@@ -3518,17 +3391,11 @@ const toolDisplays = new Set([
|
||||
"skill",
|
||||
])
|
||||
|
||||
const technicalToolDisplays = new Set(["shell", "write", "edit", "execute", "patch", "generic"])
|
||||
|
||||
export function toolDisplay(tool: string) {
|
||||
const normalized = canonicalToolName(tool)
|
||||
return toolDisplays.has(normalized) ? normalized : "generic"
|
||||
}
|
||||
|
||||
export function toolLane(tool: string): "readable" | "technical" {
|
||||
return technicalToolDisplays.has(toolDisplay(tool)) ? "technical" : "readable"
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return
|
||||
return value as Record<string, unknown>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
export type MarkdownLane = {
|
||||
content: string
|
||||
width: "readable" | "technical" | "full"
|
||||
}
|
||||
|
||||
export function markdownLanes(content: string): MarkdownLane[] {
|
||||
const result: MarkdownLane[] = []
|
||||
let fence: { marker: "`" | "~"; length: number } | undefined
|
||||
let table = false
|
||||
const lines = content.match(/[^\n]*(?:\n|$)/g)?.filter(Boolean) ?? []
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const opening = fence ? undefined : line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)/)
|
||||
const marker = opening?.[1]
|
||||
const tableOpening = !opening && !fence && isTableRow(line) && isTableDelimiter(lines[index + 1])
|
||||
if (marker) fence = { marker: marker.startsWith("`") ? "`" : "~", length: marker.length }
|
||||
if (tableOpening) table = true
|
||||
|
||||
const width = opening
|
||||
? opening[2]?.trim().split(/\s/, 1)[0]?.toLowerCase() === "mermaid"
|
||||
? "full"
|
||||
: "technical"
|
||||
: fence
|
||||
? (result.at(-1)?.width ?? "technical")
|
||||
: table
|
||||
? "technical"
|
||||
: "readable"
|
||||
const previous = result.at(-1)
|
||||
if (previous?.width === width) previous.content += line
|
||||
else result.push({ content: line, width })
|
||||
|
||||
if (!fence) {
|
||||
if (table && !isTableRow(lines[index + 1])) table = false
|
||||
continue
|
||||
}
|
||||
const currentFence = fence
|
||||
const trimmed = line.trim()
|
||||
if (
|
||||
!opening &&
|
||||
(line.match(/^ */)?.[0].length ?? 0) <= 3 &&
|
||||
trimmed.length >= currentFence.length &&
|
||||
[...trimmed].every((character) => character === currentFence.marker)
|
||||
) {
|
||||
fence = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isTableRow(line: string | undefined) {
|
||||
return Boolean(line?.trim() && line.includes("|"))
|
||||
}
|
||||
|
||||
function isTableDelimiter(line: string | undefined) {
|
||||
if (!line) return false
|
||||
const value = line.trim().replace(/^\||\|$/g, "")
|
||||
const cells = value.split("|")
|
||||
return cells.length > 1 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))
|
||||
}
|
||||
|
||||
export function markdownLaneMarginTop(index: number, width: MarkdownLane["width"]) {
|
||||
if (index === 0 || width === "full") return 0
|
||||
return 1
|
||||
}
|
||||
@@ -1,19 +1,6 @@
|
||||
export const SESSION_SIDEBAR_WIDTH = 42
|
||||
export const SESSION_TECHNICAL_LANE_WIDTH = 88
|
||||
const SESSION_CONTENT_MIN_WIDTH = 44
|
||||
|
||||
export function sessionTabsFitVertically(total: number) {
|
||||
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
|
||||
}
|
||||
|
||||
// The shared spine centers the prose measure; the technical rail shares its
|
||||
// leading edge and extends rightward, clamped so it still fits the canvas.
|
||||
export function sessionLaneLayout(available: number, readable: number) {
|
||||
const technical = Math.min(available, Math.max(readable, SESSION_TECHNICAL_LANE_WIDTH))
|
||||
const centered = Math.floor((available - Math.min(readable, technical)) / 2)
|
||||
return {
|
||||
inset: Math.max(0, Math.min(centered, available - technical)),
|
||||
readable: Math.min(readable, technical),
|
||||
technical,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
parseQuestionAnswers,
|
||||
parseQuestions,
|
||||
toolDisplay,
|
||||
toolLane,
|
||||
} from "../../../src/routes/session"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
|
||||
@@ -132,11 +131,6 @@ describe("TUI inline tool wrapping", () => {
|
||||
expect(toolDisplay("apply_patch")).toBe("patch")
|
||||
expect(toolDisplay("patch")).toBe("patch")
|
||||
expect(toolDisplay("plugin_tool")).toBe("generic")
|
||||
expect(toolLane("glob")).toBe("readable")
|
||||
expect(toolLane("webfetch")).toBe("readable")
|
||||
expect(toolLane("shell")).toBe("technical")
|
||||
expect(toolLane("apply_patch")).toBe("technical")
|
||||
expect(toolLane("plugin_tool")).toBe("technical")
|
||||
})
|
||||
|
||||
test("replaces pending copy when a tool fails before completion", async () => {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { markdownLaneMarginTop, markdownLanes } from "../../../src/routes/session/markdown-lanes"
|
||||
|
||||
test("keeps prose in the readable lane", () => {
|
||||
expect(markdownLanes("Before\n\nAfter")).toEqual([{ content: "Before\n\nAfter", width: "readable" }])
|
||||
})
|
||||
|
||||
test("moves Mermaid fences into the full lane", () => {
|
||||
expect(
|
||||
markdownLanes(`Before
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A --> B
|
||||
\`\`\`
|
||||
|
||||
After`),
|
||||
).toEqual([
|
||||
{ content: "Before\n\n", width: "readable" },
|
||||
{ content: "```mermaid\nflowchart LR\n A --> B\n```\n", width: "full" },
|
||||
{ content: "\nAfter", width: "readable" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps an incomplete streaming Mermaid fence full width", () => {
|
||||
expect(markdownLanes("Before\n```mermaid\nflowchart LR\n A -->")).toEqual([
|
||||
{ content: "Before\n", width: "readable" },
|
||||
{ content: "```mermaid\nflowchart LR\n A -->", width: "full" },
|
||||
])
|
||||
})
|
||||
|
||||
test("supports tilde fences and longer closing fences", () => {
|
||||
expect(markdownLanes("~~~ts\nconst value = 1\n~~~~\nAfter")).toEqual([
|
||||
{ content: "~~~ts\nconst value = 1\n~~~~\n", width: "technical" },
|
||||
{ content: "After", width: "readable" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not close a fence indented as code", () => {
|
||||
expect(markdownLanes("```ts\n ```\nstill code")).toEqual([
|
||||
{ content: "```ts\n ```\nstill code", width: "technical" },
|
||||
])
|
||||
})
|
||||
|
||||
test("gives ordinary fenced code an intermediate lane", () => {
|
||||
expect(markdownLanes("```ts\nexport const value = true\n```")).toEqual([
|
||||
{ content: "```ts\nexport const value = true\n```", width: "technical" },
|
||||
])
|
||||
})
|
||||
|
||||
test("gives Markdown tables the technical lane", () => {
|
||||
expect(markdownLanes("Before\n\n| Name | Value |\n| --- | ---: |\n| Width | 88 |\n\nAfter")).toEqual([
|
||||
{ content: "Before\n\n", width: "readable" },
|
||||
{ content: "| Name | Value |\n| --- | ---: |\n| Width | 88 |\n", width: "technical" },
|
||||
{ content: "\nAfter", width: "readable" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not treat ordinary pipe characters as a table", () => {
|
||||
expect(markdownLanes("Use foo | bar in prose.")).toEqual([{ content: "Use foo | bar in prose.", width: "readable" }])
|
||||
})
|
||||
|
||||
test("restores spacing between separately rendered blocks", () => {
|
||||
expect(markdownLaneMarginTop(0, "readable")).toBe(0)
|
||||
expect(markdownLaneMarginTop(1, "technical")).toBe(1)
|
||||
expect(markdownLaneMarginTop(2, "readable")).toBe(1)
|
||||
expect(markdownLaneMarginTop(1, "full")).toBe(0)
|
||||
})
|
||||
@@ -27,15 +27,6 @@ test("validates the session tabs setting", () => {
|
||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||
})
|
||||
|
||||
test("validates the session max width setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ session: { max_width: "auto" } })).toEqual({ session: { max_width: "auto" } })
|
||||
expect(decode({ session: { max_width: 100 } })).toEqual({ session: { max_width: 100 } })
|
||||
expect(() => decode({ session: { max_width: 4 } })).toThrow()
|
||||
expect(() => decode({ session: { max_width: 100.5 } })).toThrow()
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
const config = resolve(
|
||||
{
|
||||
@@ -62,13 +53,6 @@ test("shows resolved tab defaults in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||
})
|
||||
|
||||
test("shows session reading width presets in settings", () => {
|
||||
const setting = settings.find((setting) => setting.path.join(".") === "session.max_width")
|
||||
|
||||
expect(setting?.default).toBe("auto")
|
||||
expect(setting?.values).toEqual(["auto", 66, 72, 80])
|
||||
})
|
||||
|
||||
test("provides config and its host interface", async () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
let current = {}
|
||||
|
||||
@@ -2855,8 +2855,6 @@ describe("V2 mini transport", () => {
|
||||
id: "msg_cmd",
|
||||
command: "deploy",
|
||||
arguments: "prod",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
files: [
|
||||
{ uri: "file:///tmp/context.txt", name: "context.txt" },
|
||||
{
|
||||
@@ -2868,9 +2866,11 @@ describe("V2 mini transport", () => {
|
||||
skills: [{ id: "api-design", mention: { start: 13, end: 24, text: "/api-design" } }],
|
||||
delivery: "steer",
|
||||
})
|
||||
// Selection rides the command payload; no separate client-side switch.
|
||||
expect(client.session.switchAgent).not.toHaveBeenCalled()
|
||||
expect(client.session.switchModel).not.toHaveBeenCalled()
|
||||
expect(client.session.switchAgent).toHaveBeenCalledWith({ sessionID: "ses_1", agent: "build" }, expect.anything())
|
||||
expect(client.session.switchModel).toHaveBeenCalledWith(
|
||||
{ sessionID: "ses_1", model: { providerID: "test", id: "model" } },
|
||||
expect.anything(),
|
||||
)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
deduplicatePromptImages,
|
||||
deduplicateVisibleImages,
|
||||
preserveMentionlessPromptAttachments,
|
||||
promptAttachmentLabel,
|
||||
} from "../../src/prompt/attachment"
|
||||
|
||||
describe("prompt attachments", () => {
|
||||
test("deduplicates identical inline images while preserving other attachments", () => {
|
||||
const files = [
|
||||
{
|
||||
uri: "data:image/png;base64,AAA",
|
||||
name: "first.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
},
|
||||
{ uri: "file:///same", name: "first.txt" },
|
||||
{ uri: "data:application/pdf;base64,CCC", name: "first.pdf" },
|
||||
{
|
||||
uri: "data:image/png;base64,BBB",
|
||||
name: "second.png",
|
||||
mention: { start: 10, end: 19, text: "[Image 2]" },
|
||||
},
|
||||
{
|
||||
uri: "data:image/png;base64,AAA",
|
||||
name: "first.png",
|
||||
mention: { start: 20, end: 29, text: "[Image 1]" },
|
||||
},
|
||||
{
|
||||
uri: "data:image/png;base64,AAA",
|
||||
name: "first.png",
|
||||
description: "alternate use",
|
||||
mention: { start: 30, end: 39, text: "[Image 1]" },
|
||||
},
|
||||
{ uri: "file:///same", name: "second.txt" },
|
||||
{ uri: "data:application/pdf;base64,CCC", name: "first.pdf" },
|
||||
]
|
||||
|
||||
expect(deduplicatePromptImages(files)).toEqual([
|
||||
files[0],
|
||||
files[1],
|
||||
files[2],
|
||||
files[3],
|
||||
files[5],
|
||||
files[6],
|
||||
files[7],
|
||||
])
|
||||
expect(files).toHaveLength(8)
|
||||
})
|
||||
|
||||
test("reuses labels for identical image data", () => {
|
||||
const first = "data:image/png;base64,AAA"
|
||||
const second = "data:image/png;base64,BBB"
|
||||
const files = [{ uri: first, mention: { start: 0, end: 9, text: "[Image 1]" } }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri: first })).toBe("[Image 1]")
|
||||
expect(promptAttachmentLabel([...files, { ...files[0], mention: undefined }], { uri: second })).toBe("[Image 2]")
|
||||
expect(promptAttachmentLabel([{ uri: first }], { uri: first })).toBe("[Image 1]")
|
||||
})
|
||||
|
||||
test("numbers PDFs independently from images", () => {
|
||||
const files = [{ uri: "data:image/png;base64,AAA" }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri: "data:application/pdf;base64,BBB" })).toBe("[PDF 1]")
|
||||
})
|
||||
|
||||
test("does not reuse a label when attachment metadata differs", () => {
|
||||
const uri = "data:image/png;base64,AAA"
|
||||
const files = [{ uri, name: "one.png", mention: { start: 0, end: 9, text: "[Image 1]" } }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri, name: "two.png" })).toBe("[Image 2]")
|
||||
})
|
||||
|
||||
test("does not reuse numbers after an earlier attachment is removed", () => {
|
||||
const files = [{ uri: "data:image/png;base64,BBB", mention: { start: 0, end: 9, text: "[Image 2]" } }]
|
||||
|
||||
expect(promptAttachmentLabel(files, { uri: "data:image/png;base64,CCC" })).toBe("[Image 3]")
|
||||
})
|
||||
|
||||
test("preserves mentionless attachments when tracked mentions are synchronized", () => {
|
||||
const mentionless = { uri: "data:image/png;base64,AAA" }
|
||||
const emptyMention = {
|
||||
uri: "data:image/png;base64,CCC",
|
||||
mention: { start: 0, end: 0, text: "" },
|
||||
}
|
||||
const mentioned = {
|
||||
uri: "data:image/png;base64,BBB",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}
|
||||
|
||||
const restored = preserveMentionlessPromptAttachments([mentionless, emptyMention, mentioned], [mentioned])
|
||||
expect(restored).toEqual([mentionless, emptyMention, mentioned])
|
||||
expect(restored.indexOf(mentioned)).toBe(2)
|
||||
|
||||
const another = {
|
||||
uri: "data:image/png;base64,DDD",
|
||||
mention: { start: 10, end: 19, text: "[Image 2]" },
|
||||
}
|
||||
expect(preserveMentionlessPromptAttachments([mentioned, mentionless, another], [another, mentioned])).toEqual([
|
||||
another,
|
||||
mentionless,
|
||||
mentioned,
|
||||
])
|
||||
})
|
||||
|
||||
test("deduplicates visible inline image cards without dropping durable references", () => {
|
||||
const file = {
|
||||
data: "AAA",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "clipboard",
|
||||
mention: { text: "[Image 1]" },
|
||||
}
|
||||
const files = [file, { ...file, mention: { text: "[Image 1]" } }]
|
||||
|
||||
expect(deduplicateVisibleImages(files)).toEqual([file])
|
||||
expect(files).toHaveLength(2)
|
||||
|
||||
const distinct = [
|
||||
{ ...file, mention: { text: "[Image 2]" } },
|
||||
{ ...file, mention: undefined },
|
||||
]
|
||||
expect(deduplicateVisibleImages([file, ...distinct])).toEqual([file, ...distinct])
|
||||
})
|
||||
})
|
||||
@@ -41,21 +41,4 @@ describe("prompt history", () => {
|
||||
const b = entry("describe this", [{ name: "b.png", uri: "data:image/png;base64,BBB" }])
|
||||
expect(isDuplicateEntry(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
test("preserves duplicate attachment mentions for prompt restoration", () => {
|
||||
const value = entry("[Image 1] [Image 1]", [
|
||||
{
|
||||
name: "clipboard",
|
||||
uri: "data:image/png;base64,AAA",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
},
|
||||
{
|
||||
name: "clipboard",
|
||||
uri: "data:image/png;base64,AAA",
|
||||
mention: { start: 10, end: 19, text: "[Image 1]" },
|
||||
},
|
||||
])
|
||||
|
||||
expect(parsePromptHistory(JSON.stringify(value))).toEqual([value])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
sessionLaneLayout,
|
||||
sessionTabsFitVertically,
|
||||
SESSION_SIDEBAR_WIDTH,
|
||||
SESSION_TECHNICAL_LANE_WIDTH,
|
||||
} from "../../src/ui/layout"
|
||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
|
||||
|
||||
test("vertical tabs match the session sidebar and preserve compact content width", () => {
|
||||
expect(SESSION_SIDEBAR_WIDTH).toBe(42)
|
||||
expect(sessionTabsFitVertically(86)).toBe(true)
|
||||
expect(sessionTabsFitVertically(85)).toBe(false)
|
||||
})
|
||||
|
||||
test("session lanes center the prose measure on one leading edge", () => {
|
||||
expect(SESSION_TECHNICAL_LANE_WIDTH).toBe(88)
|
||||
// Wide canvas: prose is truly centered, technical extends rightward.
|
||||
expect(sessionLaneLayout(156, 66)).toEqual({ inset: 45, readable: 66, technical: 88 })
|
||||
// Centering the prose would push the technical rail past the canvas; clamp.
|
||||
expect(sessionLaneLayout(100, 66)).toEqual({ inset: 12, readable: 66, technical: 88 })
|
||||
expect(sessionLaneLayout(80, 66)).toEqual({ inset: 0, readable: 66, technical: 80 })
|
||||
expect(sessionLaneLayout(60, 66)).toEqual({ inset: 0, readable: 60, technical: 60 })
|
||||
})
|
||||
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
@@ -12733,9 +12733,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "agent"],
|
||||
|
||||
+18
-20
@@ -35,34 +35,32 @@ if (Script.release && !Script.preview) {
|
||||
|
||||
await prepareReleaseFiles()
|
||||
|
||||
if (Script.channel !== "beta") {
|
||||
console.log("\n=== schema ===\n")
|
||||
await $`bun ./packages/schema/script/publish.ts`
|
||||
console.log("\n=== schema ===\n")
|
||||
await $`bun ./packages/schema/script/publish.ts`
|
||||
|
||||
console.log("\n=== theme ===\n")
|
||||
await $`bun ./packages/theme/script/publish.ts`
|
||||
console.log("\n=== theme ===\n")
|
||||
await $`bun ./packages/theme/script/publish.ts`
|
||||
|
||||
console.log("\n=== ai ===\n")
|
||||
await $`bun ./packages/ai/script/publish.ts`
|
||||
console.log("\n=== ai ===\n")
|
||||
await $`bun ./packages/ai/script/publish.ts`
|
||||
|
||||
console.log("\n=== util ===\n")
|
||||
await $`bun ./packages/util/script/publish.ts`
|
||||
console.log("\n=== util ===\n")
|
||||
await $`bun ./packages/util/script/publish.ts`
|
||||
|
||||
console.log("\n=== protocol ===\n")
|
||||
await $`bun ./packages/protocol/script/publish.ts`
|
||||
console.log("\n=== protocol ===\n")
|
||||
await $`bun ./packages/protocol/script/publish.ts`
|
||||
|
||||
console.log("\n=== client ===\n")
|
||||
await $`bun ./packages/client/script/publish.ts`
|
||||
console.log("\n=== client ===\n")
|
||||
await $`bun ./packages/client/script/publish.ts`
|
||||
|
||||
console.log("\n=== cli ===\n")
|
||||
await $`bun ./packages/cli/script/publish.ts`
|
||||
console.log("\n=== cli ===\n")
|
||||
await $`bun ./packages/cli/script/publish.ts`
|
||||
|
||||
console.log("\n=== plugin ===\n")
|
||||
await $`bun ./packages/plugin/script/publish.ts`
|
||||
console.log("\n=== plugin ===\n")
|
||||
await $`bun ./packages/plugin/script/publish.ts`
|
||||
|
||||
console.log("\n=== ui ===\n")
|
||||
await $`bun ./packages/ui/script/publish.ts`
|
||||
}
|
||||
console.log("\n=== ui ===\n")
|
||||
await $`bun ./packages/ui/script/publish.ts`
|
||||
|
||||
if (Script.release) {
|
||||
await $`bun ./packages/desktop/scripts/finalize-latest-json.ts`
|
||||
|
||||
Reference in New Issue
Block a user