Compare commits

..
44 changed files with 400 additions and 1015 deletions
+10 -13
View File
@@ -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
-1
View File
@@ -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:",
+2 -2
View File
@@ -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,
+5 -4
View File
@@ -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)
`
}
+1 -16
View File
@@ -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 {
+3 -3
View File
@@ -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) =>
+1 -1
View File
@@ -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
View File
@@ -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)
@@ -285,9 +285,8 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
cacheWrite: undefined,
},
outputTokens: {
total: responseBody.usage?.completion_tokens ?? undefined,
...outputUsage(responseBody.usage),
text: undefined,
reasoning: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
},
raw: responseBody.usage ?? undefined,
},
@@ -357,6 +356,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
cachedTokens: number | undefined
}
totalTokens: number | undefined
rawCompletionTokens: number | undefined
} = {
completionTokens: undefined,
completionTokensDetails: {
@@ -369,6 +369,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
cachedTokens: undefined,
},
totalTokens: undefined,
rawCompletionTokens: undefined,
}
let isFirstChunk = true
const providerOptionsName = this.providerOptionsName
@@ -432,11 +433,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
} = value.usage
usage.promptTokens = prompt_tokens ?? undefined
usage.completionTokens = completion_tokens ?? undefined
usage.rawCompletionTokens = completion_tokens ?? undefined
const output = outputUsage(value.usage)
usage.completionTokens = output.total
usage.completionTokensDetails.reasoningTokens = output.reasoning
usage.totalTokens = total_tokens ?? undefined
if (completion_tokens_details?.reasoning_tokens != null) {
usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
}
if (completion_tokens_details?.accepted_prediction_tokens != null) {
usage.completionTokensDetails.acceptedPredictionTokens =
completion_tokens_details?.accepted_prediction_tokens
@@ -708,7 +709,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
},
raw: {
prompt_tokens: usage.promptTokens ?? null,
completion_tokens: usage.completionTokens ?? null,
completion_tokens: usage.rawCompletionTokens ?? null,
total_tokens: usage.totalTokens ?? null,
},
},
@@ -727,6 +728,7 @@ const openaiCompatibleTokenUsageSchema = z
.object({
prompt_tokens: z.number().nullish(),
completion_tokens: z.number().nullish(),
reasoning_tokens: z.number().nullish(),
total_tokens: z.number().nullish(),
prompt_tokens_details: z
.object({
@@ -743,6 +745,17 @@ const openaiCompatibleTokenUsageSchema = z
})
.nullish()
function outputUsage(usage: z.infer<typeof openaiCompatibleTokenUsageSchema>) {
const nested = usage?.completion_tokens_details?.reasoning_tokens
return {
total:
usage?.completion_tokens == null
? undefined
: usage.completion_tokens + (nested == null ? (usage.reasoning_tokens ?? 0) : 0),
reasoning: nested ?? usage?.reasoning_tokens ?? undefined,
}
}
// limited version of the schema, focussed on what is needed for the implementation
// this approach limits breakages when the API changes and increases efficiency
const OpenAICompatibleChatResponseSchema = z.object({
+2 -11
View File
@@ -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
})
+7 -19
View File
@@ -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
-1
View File
@@ -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))
}
}
@@ -65,15 +65,15 @@ const userAttachmentContent = (files: readonly FileAttachment[]) => {
)
if (eligible.length < 2) return files.flatMap(attachmentContent)
const seen = new Map<string, Set<string>>()
const seen = new Map<string, 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)
const matches = seen.get(metadata)
if (matches?.includes(file.data)) return []
if (matches) matches.push(file.data)
if (!matches) seen.set(metadata, [file.data])
return attachmentContent(file)
})
}
-67
View File
@@ -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
@@ -204,7 +204,7 @@ describe("doStream", () => {
finishReason: { unified: "tool-calls" },
usage: {
inputTokens: { total: 19581 },
outputTokens: { total: 53 },
outputTokens: { total: 187, reasoning: 134 },
},
})
})
@@ -259,7 +259,7 @@ describe("doStream", () => {
finishReason: { unified: "stop" },
usage: {
inputTokens: { total: 5778 },
outputTokens: { total: 59 },
outputTokens: { total: 154, reasoning: 95 },
},
providerMetadata: {
copilot: {
@@ -391,7 +391,7 @@ describe("doStream", () => {
finishReason: { unified: "tool-calls" },
usage: {
inputTokens: { total: 3767 },
outputTokens: { total: 19 },
outputTokens: { total: 30, reasoning: 11 },
},
})
})
-28
View File
@@ -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()
-48
View File
@@ -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
@@ -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*"],
})
})
+1 -1
View File
@@ -57,7 +57,7 @@ const getBase = (appId: string): Configuration => ({
},
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
extraResources: [
...(channel !== "prod"
...(channel === "dev"
? [
{
from: "resources/",
-1
View File
@@ -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:",
+2 -2
View File
@@ -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")
-1
View File
@@ -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 -13
View File
@@ -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) {
+48 -23
View File
@@ -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) {
+2 -2
View File
@@ -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,
+3 -2
View File
@@ -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> {
+7 -8
View File
@@ -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
})
}
+4 -16
View File
@@ -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,
}
}
}
+4 -31
View File
@@ -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)
})
+2 -4
View File
@@ -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>
+1 -7
View File
@@ -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>
}
@@ -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",
+5 -1
View File
@@ -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)
-5
View File
@@ -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",
}),
+6 -6
View File
@@ -25,14 +25,14 @@ function deduplicateByIdentity<T>(
items: readonly T[],
identity: (item: T) => { metadata: string; payload: string } | undefined,
) {
const seen = new Map<string, Set<string>>()
const seen = new Map<string, 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)
const matches = seen.get(key.metadata)
if (matches?.includes(key.payload)) return false
if (matches) matches.push(key.payload)
if (!matches) seen.set(key.metadata, [key.payload])
return true
})
}
@@ -42,7 +42,7 @@ export function deduplicatePromptImages(files: readonly PromptFile[] | undefined
return deduplicateByIdentity(files, (file) =>
file.uri.startsWith("data:image/") && file.mention?.text
? {
metadata: JSON.stringify([file.name ?? null, file.description ?? null, file.mention.text]),
metadata: JSON.stringify([attachmentMetadata(file), file.mention.text]),
payload: file.uri,
}
: undefined,
+159 -286
View File
@@ -68,7 +68,7 @@ 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"
@@ -108,13 +108,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 +650,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 +1074,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 +1166,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 +1189,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 +1197,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 +1205,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 +1367,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 +1401,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} />
@@ -2009,56 +1928,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 +2009,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 +2205,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 +2220,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 +2228,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 +2297,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 +3134,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 +3212,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 +3397,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
}
-13
View File
@@ -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)
})
-16
View File
@@ -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 = {}
+1 -16
View File
@@ -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 })
})
+18 -20
View File
@@ -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`