Compare commits

..
38 changed files with 616 additions and 668 deletions
@@ -348,10 +348,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
}
const previous = messages.at(-1)
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
messages.push({ role: "user", content })
continue
}
@@ -395,10 +392,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint)
}
const previous = messages.at(-1)
if (previous?.role === "user")
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
messages.push({ role: "user", content })
}
return messages
@@ -1,36 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:bedrock-converse",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"tool",
"tool-loop",
"parallel"
],
"name": "bedrock-converse/continues-after-parallel-tool-results",
"recordedAt": "2026-08-11T16:41:46.482Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream",
"headers": {
"content-type": "application/json"
},
"body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Compare the weather in Paris and London.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"weather_paris\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}},{\"toolUse\":{\"toolUseId\":\"weather_london\",\"name\":\"get_weather\",\"input\":{\"city\":\"London\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"weather_paris\",\"content\":[{\"json\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"status\":\"success\"}},{\"toolResult\":{\"toolUseId\":\"weather_london\",\"content\":[{\"json\":{\"temperature\":14,\"condition\":\"rainy\"}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"After receiving both tool results, reply exactly: Paris is sunny; London is rainy.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/vnd.amazon.eventstream"
},
"body": "AAAAqAAAAFKgEDvmCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFEiLCJyb2xlIjoiYXNzaXN0YW50In189ig4AAAAzgAAAFfGCE2ECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IlBhcmlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVYifWttETIAAADDAAAAVz6YiTULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE0ifRHJ8Q0AAACqAAAAV6q6nAkLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHN1bm55In0sInAiOiJhYmNkZWZnaGlqayJ98ZCy6gAAALAAAABXgOoTKgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiI7In0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2In020bBKAAAAygAAAFcziOtECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBMb25kb24ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUCJ9ew04hAAAAK4AAABXXzo6yQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgaXMifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxciJ9yK3bdAAAALcAAABXMsrPOgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgcmFpbnkifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eCJ9JoCYVwAAALoAAABXyloLiws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIuIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRiJ9WJwR8wAAAMEAAABXRFjaVQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU4ifYzp4V0AAAChAAAAVqptnY4LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQyJ9AHyeLwAAAJUAAABRYKgWaws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0Iiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn2HCXz0AAABBgAAAE6wWpX7CzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTA5MH0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVSIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo1MjEsIm91dHB1dFRva2VucyI6OSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjUzMH19Uwfxiw==",
"bodyEncoding": "base64"
}
}
]
}
@@ -255,57 +255,6 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("merges parallel tool results into one user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
id: "req_parallel_history",
model,
messages: [
Message.user("Compare the weather."),
Message.assistant([
ToolCallPart.make({ id: "tool_paris", name: "lookup", input: { city: "Paris" } }),
ToolCallPart.make({ id: "tool_london", name: "lookup", input: { city: "London" } }),
]),
Message.tool({ id: "tool_paris", name: "lookup", result: { forecast: "sunny" } }),
Message.tool({ id: "tool_london", name: "lookup", result: { forecast: "rainy" } }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ text: "Compare the weather." }] },
{
role: "assistant",
content: [
{ toolUse: { toolUseId: "tool_paris", name: "lookup", input: { city: "Paris" } } },
{ toolUse: { toolUseId: "tool_london", name: "lookup", input: { city: "London" } } },
],
},
{
role: "user",
content: [
{
toolResult: {
toolUseId: "tool_paris",
content: [{ json: { forecast: "sunny" } }],
status: "success",
},
},
{
toolResult: {
toolUseId: "tool_london",
content: [{ json: { forecast: "rainy" } }],
status: "success",
},
},
],
},
])
}),
)
it.effect("lowers image content in tool-result messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1216,39 +1165,4 @@ describe("Bedrock Converse recorded", () => {
)
}),
)
recorded.effect.with("continues after parallel tool results", { tags: ["tool", "tool-loop", "parallel"] }, () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
id: "recorded_bedrock_parallel_tool_results",
model: recordedModel(),
system: "After receiving both tool results, reply exactly: Paris is sunny; London is rainy.",
messages: [
Message.user("Compare the weather in Paris and London."),
Message.assistant([
ToolCallPart.make({ id: "weather_paris", name: weatherToolName, input: { city: "Paris" } }),
ToolCallPart.make({ id: "weather_london", name: weatherToolName, input: { city: "London" } }),
]),
Message.tool({
id: "weather_paris",
name: weatherToolName,
result: { temperature: 22, condition: "sunny" },
}),
Message.tool({
id: "weather_london",
name: weatherToolName,
result: { temperature: 14, condition: "rainy" },
}),
],
tools: [weatherTool],
cache: "none",
generation: { maxTokens: 40, temperature: 0 },
}),
)
expect(response.text.trim()).toBe("Paris is sunny; London is rainy.")
expect(response.finishReason?.normalized).toBe("stop")
}),
)
})
@@ -69,63 +69,6 @@ describe("v2 session reducer", () => {
})
})
test("prefers durable selection predecessors and derives them for older events", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
{
id: "msg_previous_model",
type: "model-switched",
model: { id: "old", providerID: "provider" },
time: { created: 1 },
},
]
const reducer = createV2SessionReducer()
const agent = reducer.reduce(
source,
event({
...base,
id: "evt_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
}),
)
const model = reducer.reduce(
source,
event({
...base,
id: "evt_model",
type: "session.model.selected",
data: {
sessionID: "ses_1",
model: { id: "new", providerID: "provider" },
previous: { id: "durable", providerID: "provider" },
},
}),
)
const legacyAgent = reducer.reduce(
source,
event({
...base,
id: "evt_legacy_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan" },
}),
)
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
expect(model?.messages.at(-1)).toMatchObject({
type: "model-switched",
model: { id: "new" },
previous: { id: "durable" },
})
expect(legacyAgent?.messages.at(-1)).toMatchObject({
type: "agent-switched",
agent: "plan",
previous: "build",
})
})
test("folds tool, retry, and completion events", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
@@ -61,12 +61,6 @@ export function createV2SessionReducer() {
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
item.type === "agent-switched" || item.type === "assistant",
)?.agent,
time: { created: event.created },
})
case "session.model.selected":
@@ -75,12 +69,10 @@ export function createV2SessionReducer() {
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
previous: source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
time: { created: event.created },
})
case "session.synthetic":
+9 -14
View File
@@ -39,8 +39,6 @@ export const run = Effect.fnUntraced(function* (options: Options) {
})
const processEffect = Effect.fnUntraced(function* (options: Options) {
const serviceErrorFormat = process.env.OPENCODE_SERVICE_ERROR_FORMAT
delete process.env.OPENCODE_SERVICE_ERROR_FORMAT
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
@@ -129,7 +127,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
Effect.flatMap((found) =>
found ? Effect.void : managedPortInUse(hostname, port, error, serviceErrorFormat),
found
? Effect.void
: Effect.fail(
new Error(
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
"Configure another port with `opencode service set port <port>` and start the service again.",
{ cause: error },
),
),
),
)
}),
@@ -208,17 +214,6 @@ function serviceURL(hostname: string, port: number) {
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
}
function managedPortInUse(hostname: string, port: number, cause: unknown, format?: string) {
const message =
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
"Configure another port with `opencode service set port <port>` and start the service again."
const failure = new Error(message, { cause })
if (format !== "plain") return Effect.fail(failure)
return Effect.sync(() => process.stderr.write(`OPENCODE_SERVICE_ERROR:${message}\n`)).pipe(
Effect.andThen(Effect.fail(failure)),
)
}
function truthy(value?: string) {
return value === "1" || value?.toLowerCase() === "true"
}
+2 -10
View File
@@ -339,11 +339,7 @@ export type Endpoint5_31Output =
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly previous?: Agent.ID | undefined
}
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
}
| {
readonly id: Event.ID
@@ -352,11 +348,7 @@ export type Endpoint5_31Output =
readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly model: Model.Ref
readonly previous?: Model.Ref | undefined
}
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
}
| {
readonly id: Event.ID
+33 -8
View File
@@ -1,9 +1,9 @@
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
import { ServiceProcess } from "../service-process.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -17,6 +17,11 @@ export type Info = import("../service.js").Info
// is all a client needs to connect. The daemon's own configuration (port,
// persisted password) is CLI-owned and never read here.
type Contender = {
readonly child: ChildProcess
readonly error: () => Error | undefined
}
// Read-only lookup: registration file plus health check and version gate.
// Never spawns; escalation to ensure() is the caller's policy.
/** Discover a healthy, compatible local service without starting one. */
@@ -47,12 +52,11 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
// becomes discoverable. A contender is never killed merely for slow startup.
/** Ensure a healthy, compatible local service is running. */
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const contenders = new Set<ServiceProcess.Contender>()
const contenders = new Set<Contender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let lastFailure: Error | undefined
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
if (announced) return
@@ -63,7 +67,15 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
return yield* Effect.try({
try: () => ServiceProcess.start(command, args),
try: () => {
const child = spawn(command, args, { detached: true, stdio: "ignore" })
let error: Error | undefined
child.once("error", (cause) => {
error = new Error("Failed to start server", { cause })
})
child.unref()
return { child, error: () => error }
},
catch: (cause) => new Error("Failed to start server", { cause }),
})
})
@@ -96,9 +108,8 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(ServiceProcess.finished)
const failure = finished.map(ServiceProcess.failure).find((error): error is Error => error !== undefined)
if (failure !== undefined) lastFailure = failure
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
@@ -118,10 +129,24 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
}),
)
if (Option.isNone(found))
return yield* Effect.fail(lastFailure ?? new Error("Timed out waiting for the background service to start"))
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
return found.value.endpoint
})
function contenderFailure(contender: Contender) {
const error = contender.error()
if (error !== undefined) return error
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
return new Error(`Server process exited with code ${contender.child.exitCode}`)
if (contender.child.signalCode !== null)
return new Error(`Server process terminated by ${contender.child.signalCode}`)
return undefined
}
function contenderFinished(contender: Contender) {
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
}
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const existing = yield* find(options)
@@ -436,7 +436,7 @@ export type SessionAgentSelected = {
type: "session.agent.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; agent: string; previous?: string }
data: { sessionID: string; agent: string }
}
export type SessionModelSelected = {
@@ -446,7 +446,7 @@ export type SessionModelSelected = {
type: "session.model.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
data: { sessionID: string; model: ModelRef }
}
export type SessionMoved = {
+35 -8
View File
@@ -1,8 +1,8 @@
import { readFile } from "node:fs/promises"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
import { ServiceProcess } from "../service-process.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -13,6 +13,11 @@ export * from "../service.js"
// intentionally implemented with Node APIs so Promise clients do not need
// Effect or @effect/platform-node at runtime.
type Contender = {
readonly child: ChildProcess
readonly error: () => Error | undefined
}
/** Discover a healthy, compatible local service without starting one. */
export async function discover(options: DiscoverOptions = {}) {
return (await discoverLocal(options))?.endpoint
@@ -28,12 +33,11 @@ async function discoverLocal(options: DiscoverOptions) {
/** Ensure a healthy, compatible local service is running. */
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const deadline = Date.now() + 120_000
const contenders = new Set<ServiceProcess.Contender>()
const contenders = new Set<Contender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let lastFailure: Error | undefined
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -43,11 +47,21 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const spawnContender = () => {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) throw new Error("Missing service command")
return ServiceProcess.start(command, args)
try {
const child = spawn(command, args, { detached: true, stdio: "ignore" })
let error: Error | undefined
child.once("error", (cause) => {
error = new Error("Failed to start server", { cause })
})
child.unref()
return { child, error: () => error }
} catch (cause) {
throw new Error("Failed to start server", { cause })
}
}
while (true) {
if (Date.now() >= deadline) throw lastFailure ?? new Error("Timed out waiting for the background service to start")
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
const registration = await registered(options.file, true)
if (registration.timedOut && registration.info !== undefined) {
timeouts = {
@@ -75,9 +89,8 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
} else {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(ServiceProcess.finished)
const failure = finished.map(ServiceProcess.failure).find((error) => error !== undefined)
if (failure !== undefined) lastFailure = failure
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
if (finished.some((item) => item.child.exitCode === 0)) {
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
@@ -94,6 +107,20 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
}
}
function contenderFailure(contender: Contender) {
const error = contender.error()
if (error !== undefined) return error
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
return new Error(`Server process exited with code ${contender.child.exitCode}`)
if (contender.child.signalCode !== null)
return new Error(`Server process terminated by ${contender.child.signalCode}`)
return undefined
}
function contenderFinished(contender: Contender) {
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
}
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
const existing = await find(options)
-57
View File
@@ -1,57 +0,0 @@
export * as ServiceProcess from "./service-process"
import { spawn, type ChildProcess } from "node:child_process"
const errorPrefix = "OPENCODE_SERVICE_ERROR:"
export type Contender = {
readonly child: ChildProcess
readonly error: () => Error | undefined
readonly startupError: () => string
}
export function start(command: string, args: ReadonlyArray<string>) {
try {
const child = spawn(command, args, {
detached: true,
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, OPENCODE_SERVICE_ERROR_FORMAT: "plain" },
})
let error: Error | undefined
let pending = ""
let startupError = ""
child.once("error", (cause) => {
error = new Error("Failed to start server", { cause })
})
child.stderr?.on("data", (chunk) => {
const lines = (pending + chunk.toString()).split(/\r?\n/)
pending = lines.pop()?.slice(-64 * 1024) ?? ""
const message = lines.findLast((line) => line.startsWith(errorPrefix))
if (message !== undefined) startupError = message.slice(errorPrefix.length)
})
unref(child.stderr)
child.unref()
return { child, error: () => error, startupError: () => startupError } satisfies Contender
} catch (cause) {
throw new Error("Failed to start server", { cause })
}
}
export function failure(contender: Contender) {
const error = contender.error()
if (error !== undefined) return error
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
return new Error(contender.startupError() || `Server process exited with code ${contender.child.exitCode}`)
if (contender.child.signalCode !== null)
return new Error(`Server process terminated by ${contender.child.signalCode}`)
return undefined
}
export function finished(contender: Contender) {
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
}
function unref(stream: ChildProcess["stderr"]) {
if (!stream || !("unref" in stream) || typeof stream.unref !== "function") return
stream.unref()
}
-5
View File
@@ -3,11 +3,6 @@ import { appendFile, rename, writeFile } from "node:fs/promises"
const [registration, mode, delay] = process.argv.slice(2)
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
if (mode === "failed") process.exit(1)
if (mode === "failed-message") {
console.error("sensitive startup detail")
console.error("OPENCODE_SERVICE_ERROR:Managed service port is already in use")
process.exit(1)
}
if (mode === "record-start") {
await writeFile(registration + ".started", "")
process.exit(1)
@@ -70,19 +70,6 @@ test("reports a failed registered service", async () => {
)
})
test("reports the native contender's startup error", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
await expect(
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "failed-message"],
}),
).rejects.toThrow(/^Managed service port is already in use$/)
}, 10_000)
test("evicts an unresponsive registered service before starting its replacement", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
-14
View File
@@ -197,20 +197,6 @@ test("reports a contender that fails to start", async () => {
).rejects.toThrow("Server process exited with code 1")
}, 10_000)
test("reports the contender's startup error", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
await expect(
run(
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "failed-message"],
}),
),
).rejects.toThrow(/^Managed service port is already in use$/)
}, 10_000)
test("reports a contender terminated by a signal", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+2 -11
View File
@@ -7,7 +7,7 @@ import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { KVTable } from "../kv/sql"
import { EventSequenceTable } from "../event/sql"
import { EventSequenceTable, EventTable } from "../event/sql"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
@@ -161,7 +161,6 @@ type NextMessage = {
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
@@ -486,15 +485,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx.delete(EventTable).run()
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
+1 -3
View File
@@ -716,11 +716,10 @@ const layer = Layer.effect(
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
})
}),
switchModel: Effect.fn("Session.switchModel")(function* (input) {
@@ -734,7 +733,6 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
model: input.model,
previous: session.model,
})
}),
rename: Effect.fn("Session.rename")(function* (input) {
+2 -2
View File
@@ -61,7 +61,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return Effect.gen(function* () {
const previous = event.data.previous ?? (yield* adapter.getAgent())
const previous = yield* adapter.getAgent()
yield* adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
@@ -76,7 +76,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.model.selected": (event) => {
return Effect.gen(function* () {
const previous = event.data.previous ?? (yield* adapter.getModel())
const previous = yield* adapter.getModel()
yield* adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
+1 -1
View File
@@ -21,7 +21,7 @@ Usage notes:
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Input = Schema.Struct({
questions: Schema.Array(Question.Prompt).check(Schema.isNonEmpty()).annotate({ description: "Questions to ask" }),
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
})
export const Output = Schema.Struct({
+3 -11
View File
@@ -654,7 +654,7 @@ describe("Session.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
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", previous: "build" } }])
).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" },
])
@@ -678,12 +678,7 @@ describe("Session.create", () => {
it.effect("switches the selected model through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const previous = Model.Ref.make({
id: Model.ID.make("haiku"),
providerID: Provider.ID.anthropic,
variant: Model.VariantID.make("default"),
})
const created = yield* session.create({ location, model: previous })
const created = yield* session.create({ location })
const model = Model.Ref.make({
id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic,
@@ -697,10 +692,7 @@ describe("Session.create", () => {
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
)
expect(bus).toMatchObject([{ type: "session.model.selected" }])
expect(bus[0]?.data).toEqual({ sessionID: created.id, model, previous })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "model-switched", model, previous },
])
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
}),
)
-24
View File
@@ -89,30 +89,6 @@ const it = testEffect(
)
describe("QuestionTool", () => {
it.effect("emits one item schema for the nonempty questions array", () =>
Effect.gen(function* () {
captured = undefined
const registry = yield* Tool.Service
const definition = (yield* toolDefinitions(registry)).find((tool) => tool.name === QuestionTool.name)
expect(definition?.inputSchema).toHaveProperty("properties.questions.type", "array")
expect(definition?.inputSchema).toHaveProperty("properties.questions.minItems", 1)
expect(definition?.inputSchema).toHaveProperty("properties.questions.items")
expect(definition?.inputSchema).not.toHaveProperty("properties.questions.prefixItems")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-empty", name: QuestionTool.name, input: { questions: [] } },
}),
).toMatchObject({
status: "error",
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
})
expect(capturedInput()).toBeUndefined()
}),
)
it.effect("omits a catalog-denied question and enforces its leaf permission", () =>
Effect.gen(function* () {
captured = undefined
+1 -30
View File
@@ -11,7 +11,7 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { Effect, Fiber, Layer, Logger, Schedule, Schema, Scope } from "effect"
import { Effect, Layer, Logger, Schedule, Schema, Scope } from "effect"
import { eq, sql } from "drizzle-orm"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { tmpdir } from "./fixture/tmpdir"
@@ -798,35 +798,6 @@ describe("V1Migration database workflow", () => {
)
})
test("yields while clearing stale events in batches", async () => {
await database(
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('stale', 2500)`)
yield* db.run(sql`
WITH RECURSIVE rows(value) AS (
VALUES(1)
UNION ALL
SELECT value + 1 FROM rows WHERE value < 2500
)
INSERT INTO event (id, aggregate_id, seq, created, type, data)
SELECT printf('event_%04d', value), 'stale', value, 1, 'session.renamed.1', '{}'
FROM rows
`)
let yielded = false
const heartbeat = yield* Effect.yieldNow.pipe(
Effect.andThen(Effect.sync(() => (yielded = true))),
Effect.forkChild({ startImmediately: true }),
)
expect(yield* V1Migration.run()).toEqual({ status: "completed" })
expect(yielded).toBe(true)
yield* Fiber.join(heartbeat)
expect(yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM event`)).toEqual({ value: 0 })
}),
)
})
test("imports previous V2 sessions and messages as part of the migration", async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "opencode-next.db")
+16 -4
View File
@@ -1,5 +1,6 @@
import {
TextRenderable,
BoxRenderable,
RenderableEvents,
createMarkdownCodeBlockRenderer,
parseColor,
@@ -33,6 +34,7 @@ interface PreparedDiagram {
readonly source: string
readonly text: StyledText
readonly height: number
readonly width: number
}
export interface MermaidMarkdownRendererOptions {
@@ -55,16 +57,23 @@ function color(value: ColorInput | undefined): RGBA | undefined {
return value === undefined ? undefined : parseColor(value)
}
class StaticDiagramRenderable extends TextRenderable {
class StaticDiagramRenderable extends BoxRenderable {
constructor(ctx: RenderContext, prepared: PreparedDiagram) {
super(ctx, {
content: prepared.text,
width: "100%",
alignItems: "flex-start",
flexShrink: 0,
marginTop: 1,
})
const diagram = new TextRenderable(ctx, {
content: prepared.text,
width: prepared.width,
maxWidth: "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
@@ -79,7 +88,7 @@ class StaticDiagramRenderable extends TextRenderable {
if (dragX === undefined) return
const dx = event.x - dragX
dragX = event.x
if (dx) this.scrollX -= dx
if (dx) diagram.scrollX -= dx
}
this.onMouseDragEnd = (event: MouseEvent) => {
dragX = undefined
@@ -121,6 +130,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
}),
),
height: size.height,
width: size.width,
}
}
case "sequence": {
@@ -144,6 +154,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
}),
),
height: size.height,
width: size.width,
}
}
case "state": {
@@ -168,6 +179,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
}),
),
height: size.height,
width: size.width,
}
}
}
+31 -4
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, TreeSitterClient } from "@opentui/core"
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TextRenderable, TreeSitterClient } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { createMermaidMarkdownRenderer } from "../markdown.js"
@@ -78,6 +78,31 @@ 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
@@ -235,17 +260,19 @@ sequenceDiagram
renderer.root.add(markdown)
await renderMarkdown(markdown, testRenderer.renderOnce)
const diagram = markdown.getChildren()[0] as CodeRenderable
const wrapper = markdown.getChildren()[0]
if (!wrapper) throw new Error("Expected the rendered diagram wrapper")
const diagram = wrapper.getChildren()[0] as TextRenderable
expect(diagram.scrollWidth).toBeGreaterThan(diagram.width)
expect(diagram.scrollX).toBe(0)
await testRenderer.mockMouse.drag(diagram.x + 20, diagram.y + 2, diagram.x + 5, diagram.y + 2)
await testRenderer.mockMouse.drag(wrapper.x + 20, wrapper.y + 2, wrapper.x + 5, wrapper.y + 2)
await testRenderer.renderOnce()
expect(diagram.scrollX).toBeGreaterThan(0)
expect(diagram.hasSelection()).toBe(false)
diagram.scrollX = 0
await testRenderer.mockMouse.scroll(diagram.x + 20, diagram.y + 2, "right")
await testRenderer.mockMouse.scroll(wrapper.x + 20, wrapper.y + 2, "right")
await testRenderer.renderOnce()
expect(diagram.scrollX).toBeGreaterThan(0)
})
-6
View File
@@ -14373,9 +14373,6 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14444,9 +14441,6 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
-2
View File
@@ -69,7 +69,6 @@ export const AgentSelected = Event.durable({
schema: {
...Base,
agent: Agent.ID,
previous: Agent.ID.pipe(optional),
},
})
export type AgentSelected = typeof AgentSelected.Type
@@ -80,7 +79,6 @@ export const ModelSelected = Event.durable({
schema: {
...Base,
model: Model.Ref,
previous: Model.Ref.pipe(optional),
},
})
export type ModelSelected = typeof ModelSelected.Type
-13
View File
@@ -12,19 +12,6 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
.handle(
"model.list",
Effect.fn(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush.pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () =>
Effect.fail(
new ServiceUnavailableError({
message: "Model catalog initialization timed out",
service: "model.catalog",
}),
),
}),
)
const catalog = yield* Catalog.Service
return yield* response(catalog.model.available())
}),
-57
View File
@@ -1,57 +0,0 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("waits for plugin initialization before listing models", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-model-endpoint-")),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
settings: { apiKey: "secret" },
models: { chat: {} },
},
},
}),
),
)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
})
const url = new URL("/api/model", HttpServer.formatAddress(server.address))
url.searchParams.set("location[directory]", tmp.path)
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
expect(response.status).toBe(200)
const body: unknown = yield* Effect.promise(() => response.json())
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
expect(
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
).toBeTrue()
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -60,6 +60,15 @@ 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
View File
@@ -125,6 +125,11 @@ 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",
}),
+274 -157
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 { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
import { sessionLaneLayout, 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.
@@ -1084,55 +1084,57 @@ export function Session() {
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<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 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>
</Show>
</box>
<Show when={sidebarVisible()}>
@@ -1176,22 +1178,30 @@ function SessionRowView(props: SessionRowViewProps) {
)}
</Match>
<Match when={props.row.type === "compaction-queued"}>
<CompactionQueued />
<SessionContentLane width="readable">
<CompactionQueued />
</SessionContentLane>
</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) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
{(row) => (
<SessionContentLane width="readable">
<SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />
</SessionContentLane>
)}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
{(row) => (
<SessionGroupView
refs={row().refs}
pending={row().pending}
completed={row().completed}
message={props.message}
/>
<SessionContentLane width="readable">
<SessionGroupView
refs={row().refs}
pending={row().pending}
completed={row().completed}
message={props.message}
/>
</SessionContentLane>
)}
</Match>
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
@@ -1199,7 +1209,9 @@ function SessionRowView(props: SessionRowViewProps) {
<Show when={props.message(row().messageID)}>
{(message) => (
<Show when={message().type === "assistant"}>
<AssistantFooter message={message() as SessionMessageAssistant} />
<SessionContentLane width="readable">
<AssistantFooter message={message() as SessionMessageAssistant} />
</SessionContentLane>
</Show>
)}
</Show>
@@ -1207,7 +1219,13 @@ function SessionRowView(props: SessionRowViewProps) {
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
<SessionContentLane width="technical">
<TurnTokenUsage
messageIDs={row().messageIDs}
previousCache={row().previousCache}
message={props.message}
/>
</SessionContentLane>
)}
</Match>
</Switch>
@@ -1215,6 +1233,42 @@ 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
@@ -1377,20 +1431,35 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
<UserMessage message={props.message as SessionMessageUser} />
</Match>
<Match when={props.message.type === "shell"}>
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
<SessionContentLane width="technical">
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
</SessionContentLane>
</Match>
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
<SessionSwitchMessageV2 message={props.message} />
<SessionContentLane width="readable">
<SessionSwitchMessageV2 message={props.message} />
</SessionContentLane>
</Match>
<Match
when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}
>
<Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}>
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { 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>
</Match>
<Match when={props.message.type === "compaction"}>
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
<SessionContentLane width="readable">
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
</SessionContentLane>
</Match>
</Switch>
)
@@ -1411,11 +1480,13 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
<TextPart part={item() as SessionMessageAssistantText} last={false} />
</Match>
<Match when={item().type === "reasoning"}>
<ReasoningPart
part={item() as SessionMessageAssistantReasoning}
message={message() as SessionMessageAssistant}
last={false}
/>
<SessionContentLane width="readable">
<ReasoningPart
part={item() as SessionMessageAssistantReasoning}
message={message() as SessionMessageAssistant}
last={false}
/>
</SessionContentLane>
</Match>
<Match when={item().type === "tool"}>
<ToolPart part={item() as SessionMessageAssistantTool} />
@@ -1938,79 +2009,56 @@ function UserMessage(props: { message: SessionMessageUser }) {
return (
<Show when={props.message.text.trim() || files().length || skills().length}>
<box
border={["left"]}
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
>
<SessionImages images={images()} paddingLeft={2} />
<SessionContentLane width="readable">
<box
onMouseOver={() => {
setHover(true)
}}
onMouseOut={() => {
setHover(false)
}}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
if (delivery() === "steer") {
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
}
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)
}}
<DialogMessage
messageID={props.message.id}
sessionID={ctx.sessionID}
setPrompt={(value) => promptRef.current?.set(value)}
/>
))
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 (
}}
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={{
@@ -2019,20 +2067,45 @@ function UserMessage(props: { message: SessionMessageUser }) {
bold: true,
}}
>
{` ${label} `}
{" skill "}
</span>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
{" "}
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
{` ${skill.name} `}
</span>
</text>
)
}}
</For>
</box>
</Show>
)}
</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>
</box>
</box>
</SessionContentLane>
</Show>
)
}
@@ -2215,14 +2288,16 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
const theme = useTheme()
const { currentSyntax: syntax } = useThemes()
const plugins = usePlugin()
return (
<Show when={props.part.text.trim()}>
const constrained = () => (ctx.config.session?.max_width ?? "auto") !== "auto"
function Content(input: { content: string }) {
return (
<box paddingLeft={3} flexShrink={0}>
<markdown
syntaxStyle={syntax()}
streaming={true}
internalBlockMode="top-level"
content={props.part.text.trim()}
content={input.content.trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
@@ -2230,6 +2305,38 @@ 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>
)
}
@@ -2238,6 +2345,7 @@ 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() {
@@ -2307,10 +2415,12 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
</Switch>
)
return [
content,
<Show when={props.images !== false}>
<ToolImages parts={[props.part]} />
</Show>,
<SessionContentLane width={width()}>{content}</SessionContentLane>,
<SessionContentLane width="readable">
<Show when={props.images !== false}>
<ToolImages parts={[props.part]} />
</Show>
</SessionContentLane>,
]
}
@@ -3144,7 +3254,7 @@ function Edit(props: ToolProps) {
const diffView = ctx.config.diffs?.view
if (diffView === "unified") return "unified"
if (diffView === "split") return "split"
// Default to "auto" behavior
if ((ctx.config.session?.max_width ?? "auto") !== "auto") return "unified"
return ctx.width > 120 ? "split" : "unified"
})
@@ -3222,6 +3332,7 @@ 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"
})
@@ -3407,11 +3518,17 @@ 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>
@@ -0,0 +1,65 @@
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,6 +1,19 @@
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,6 +10,7 @@ import {
parseQuestionAnswers,
parseQuestions,
toolDisplay,
toolLane,
} from "../../../src/routes/session"
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
@@ -131,6 +132,11 @@ 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 () => {
@@ -0,0 +1,68 @@
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,6 +27,15 @@ 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(
{
@@ -53,6 +62,13 @@ 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 = {}
+16 -1
View File
@@ -1,8 +1,23 @@
import { expect, test } from "bun:test"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
import {
sessionLaneLayout,
sessionTabsFitVertically,
SESSION_SIDEBAR_WIDTH,
SESSION_TECHNICAL_LANE_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 })
})
-6
View File
@@ -14373,9 +14373,6 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14444,9 +14441,6 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
-6
View File
@@ -14373,9 +14373,6 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14444,9 +14441,6 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],