mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 12:45:07 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03de58cfe3 | |||
| 37a8c6c916 | |||
| 23b0688a7f | |||
| d0dd49bd84 | |||
| db9a3b6c41 | |||
| 961b51b509 | |||
| 964f7f4254 | |||
| 8c27c8485e |
@@ -348,7 +348,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
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 })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -392,7 +395,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
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 })
|
||||
}
|
||||
|
||||
return messages
|
||||
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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,6 +255,57 @@ 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(
|
||||
@@ -1165,4 +1216,39 @@ 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,6 +69,63 @@ 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,6 +61,12 @@ 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":
|
||||
@@ -69,10 +75,12 @@ export function createV2SessionReducer() {
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous: source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
previous:
|
||||
event.data.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":
|
||||
|
||||
@@ -339,7 +339,11 @@ 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 data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly previous?: Agent.ID | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -348,7 +352,11 @@ 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 data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly model: Model.Ref
|
||||
readonly previous?: Model.Ref | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
|
||||
@@ -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 }
|
||||
data: { sessionID: string; agent: string; previous?: 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 }
|
||||
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
|
||||
}
|
||||
|
||||
export type SessionMoved = {
|
||||
|
||||
@@ -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, EventTable } from "../event/sql"
|
||||
import { EventSequenceTable } from "../event/sql"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { existsSync } from "node:fs"
|
||||
@@ -161,6 +161,7 @@ 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)
|
||||
@@ -485,7 +486,15 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx.delete(EventTable).run()
|
||||
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
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||
|
||||
@@ -716,10 +716,11 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const session = 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) {
|
||||
@@ -733,6 +734,7 @@ 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) {
|
||||
|
||||
@@ -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 = yield* adapter.getAgent()
|
||||
const previous = event.data.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 = yield* adapter.getModel()
|
||||
const previous = event.data.previous ?? (yield* adapter.getModel())
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.ModelSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
|
||||
@@ -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.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
|
||||
questions: Schema.Array(Question.Prompt).check(Schema.isNonEmpty()).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
|
||||
@@ -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" } }])
|
||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan", previous: "build" } }])
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||
{ type: "agent-switched", agent: "plan", previous: "build" },
|
||||
])
|
||||
@@ -678,7 +678,12 @@ describe("Session.create", () => {
|
||||
it.effect("switches the selected model through the durable Session event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const created = yield* session.create({ location })
|
||||
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 model = Model.Ref.make({
|
||||
id: Model.ID.make("sonnet"),
|
||||
providerID: Provider.ID.anthropic,
|
||||
@@ -692,7 +697,10 @@ 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 })
|
||||
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 },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -89,6 +89,30 @@ 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
|
||||
|
||||
@@ -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, Layer, Logger, Schedule, Schema, Scope } from "effect"
|
||||
import { Effect, Fiber, 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,6 +798,35 @@ 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")
|
||||
|
||||
@@ -208,12 +208,6 @@ export function createMainWindow(id: string = randomUUID()) {
|
||||
wireWindowRecovery(win, id)
|
||||
wireNavigationPolicy(win)
|
||||
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const { requestHeaders } = details
|
||||
upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"])
|
||||
callback({ requestHeaders })
|
||||
})
|
||||
|
||||
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const { responseHeaders = {} } = details
|
||||
addRendererHeaders(details.url, responseHeaders)
|
||||
@@ -500,8 +494,6 @@ function isTrustedRendererUrl(value?: string) {
|
||||
}
|
||||
|
||||
function addRendererHeaders(value: string, headers: Record<string, any>) {
|
||||
upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"])
|
||||
upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"])
|
||||
if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
TextRenderable,
|
||||
BoxRenderable,
|
||||
RenderableEvents,
|
||||
createMarkdownCodeBlockRenderer,
|
||||
parseColor,
|
||||
@@ -34,7 +33,6 @@ interface PreparedDiagram {
|
||||
readonly source: string
|
||||
readonly text: StyledText
|
||||
readonly height: number
|
||||
readonly width: number
|
||||
}
|
||||
|
||||
export interface MermaidMarkdownRendererOptions {
|
||||
@@ -57,23 +55,16 @@ function color(value: ColorInput | undefined): RGBA | undefined {
|
||||
return value === undefined ? undefined : parseColor(value)
|
||||
}
|
||||
|
||||
class StaticDiagramRenderable extends BoxRenderable {
|
||||
class StaticDiagramRenderable extends TextRenderable {
|
||||
constructor(ctx: RenderContext, prepared: PreparedDiagram) {
|
||||
super(ctx, {
|
||||
width: "100%",
|
||||
alignItems: "flex-start",
|
||||
flexShrink: 0,
|
||||
marginTop: 1,
|
||||
})
|
||||
const diagram = new TextRenderable(ctx, {
|
||||
content: prepared.text,
|
||||
width: prepared.width,
|
||||
maxWidth: "100%",
|
||||
width: "100%",
|
||||
height: prepared.height,
|
||||
wrapMode: "none",
|
||||
selectable: false,
|
||||
marginTop: 1,
|
||||
})
|
||||
this.add(diagram)
|
||||
let dragX: number | undefined
|
||||
this.onMouseDown = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
@@ -88,7 +79,7 @@ class StaticDiagramRenderable extends BoxRenderable {
|
||||
if (dragX === undefined) return
|
||||
const dx = event.x - dragX
|
||||
dragX = event.x
|
||||
if (dx) diagram.scrollX -= dx
|
||||
if (dx) this.scrollX -= dx
|
||||
}
|
||||
this.onMouseDragEnd = (event: MouseEvent) => {
|
||||
dragX = undefined
|
||||
@@ -130,7 +121,6 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
case "sequence": {
|
||||
@@ -154,7 +144,6 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
case "state": {
|
||||
@@ -179,7 +168,6 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TextRenderable, TreeSitterClient } from "@opentui/core"
|
||||
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TreeSitterClient } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { createMermaidMarkdownRenderer } from "../markdown.js"
|
||||
|
||||
@@ -78,31 +78,6 @@ flowchart LR
|
||||
expect(markdown.getChildren()[0]?.marginTop).toBe(1)
|
||||
})
|
||||
|
||||
test("leaves Mermaid alignment to its containing layout", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-centered-mermaid",
|
||||
content: `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const line = testRenderer
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((value) => value.includes("Start"))
|
||||
if (!line) throw new Error("Expected the rendered diagram")
|
||||
expect(line.indexOf("Start")).toBeLessThan(10)
|
||||
})
|
||||
|
||||
test("recognizes normalized Mermaid fence info strings", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
@@ -260,19 +235,17 @@ sequenceDiagram
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const wrapper = markdown.getChildren()[0]
|
||||
if (!wrapper) throw new Error("Expected the rendered diagram wrapper")
|
||||
const diagram = wrapper.getChildren()[0] as TextRenderable
|
||||
const diagram = markdown.getChildren()[0] as CodeRenderable
|
||||
expect(diagram.scrollWidth).toBeGreaterThan(diagram.width)
|
||||
expect(diagram.scrollX).toBe(0)
|
||||
|
||||
await testRenderer.mockMouse.drag(wrapper.x + 20, wrapper.y + 2, wrapper.x + 5, wrapper.y + 2)
|
||||
await testRenderer.mockMouse.drag(diagram.x + 20, diagram.y + 2, diagram.x + 5, diagram.y + 2)
|
||||
await testRenderer.renderOnce()
|
||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||
expect(diagram.hasSelection()).toBe(false)
|
||||
|
||||
diagram.scrollX = 0
|
||||
await testRenderer.mockMouse.scroll(wrapper.x + 20, wrapper.y + 2, "right")
|
||||
await testRenderer.mockMouse.scroll(diagram.x + 20, diagram.y + 2, "right")
|
||||
await testRenderer.renderOnce()
|
||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -14373,6 +14373,9 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "agent"],
|
||||
@@ -14441,6 +14444,9 @@
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"previous": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "model"],
|
||||
|
||||
@@ -69,6 +69,7 @@ export const AgentSelected = Event.durable({
|
||||
schema: {
|
||||
...Base,
|
||||
agent: Agent.ID,
|
||||
previous: Agent.ID.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type AgentSelected = typeof AgentSelected.Type
|
||||
@@ -79,6 +80,7 @@ export const ModelSelected = Event.durable({
|
||||
schema: {
|
||||
...Base,
|
||||
model: Model.Ref,
|
||||
previous: Model.Ref.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type ModelSelected = typeof ModelSelected.Type
|
||||
|
||||
@@ -12,6 +12,19 @@ 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())
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { isAllowedCorsOrigin } from "../src/cors"
|
||||
|
||||
describe("CORS origin policy", () => {
|
||||
test("allows the desktop renderer origin", () => {
|
||||
expect(isAllowedCorsOrigin("oc://renderer")).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects other custom protocol origins", () => {
|
||||
expect(isAllowedCorsOrigin("other://renderer")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
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,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",
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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.
|
||||
@@ -1084,57 +1084,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 +1176,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 +1199,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 +1207,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 +1215,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 +1377,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 +1411,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 +1938,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 +2019,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 +2215,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 +2230,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 +2238,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 +2307,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 +3144,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 +3222,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 +3407,11 @@ const toolDisplays = new Set([
|
||||
"skill",
|
||||
])
|
||||
|
||||
const technicalToolDisplays = new Set(["shell", "write", "edit", "execute", "patch", "generic"])
|
||||
|
||||
export function toolDisplay(tool: string) {
|
||||
const normalized = canonicalToolName(tool)
|
||||
return toolDisplays.has(normalized) ? normalized : "generic"
|
||||
}
|
||||
|
||||
export function toolLane(tool: string): "readable" | "technical" {
|
||||
return technicalToolDisplays.has(toolDisplay(tool)) ? "technical" : "readable"
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return
|
||||
return value as Record<string, unknown>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
export type MarkdownLane = {
|
||||
content: string
|
||||
width: "readable" | "technical" | "full"
|
||||
}
|
||||
|
||||
export function markdownLanes(content: string): MarkdownLane[] {
|
||||
const result: MarkdownLane[] = []
|
||||
let fence: { marker: "`" | "~"; length: number } | undefined
|
||||
let table = false
|
||||
const lines = content.match(/[^\n]*(?:\n|$)/g)?.filter(Boolean) ?? []
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const opening = fence ? undefined : line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)/)
|
||||
const marker = opening?.[1]
|
||||
const tableOpening = !opening && !fence && isTableRow(line) && isTableDelimiter(lines[index + 1])
|
||||
if (marker) fence = { marker: marker.startsWith("`") ? "`" : "~", length: marker.length }
|
||||
if (tableOpening) table = true
|
||||
|
||||
const width = opening
|
||||
? opening[2]?.trim().split(/\s/, 1)[0]?.toLowerCase() === "mermaid"
|
||||
? "full"
|
||||
: "technical"
|
||||
: fence
|
||||
? (result.at(-1)?.width ?? "technical")
|
||||
: table
|
||||
? "technical"
|
||||
: "readable"
|
||||
const previous = result.at(-1)
|
||||
if (previous?.width === width) previous.content += line
|
||||
else result.push({ content: line, width })
|
||||
|
||||
if (!fence) {
|
||||
if (table && !isTableRow(lines[index + 1])) table = false
|
||||
continue
|
||||
}
|
||||
const currentFence = fence
|
||||
const trimmed = line.trim()
|
||||
if (
|
||||
!opening &&
|
||||
(line.match(/^ */)?.[0].length ?? 0) <= 3 &&
|
||||
trimmed.length >= currentFence.length &&
|
||||
[...trimmed].every((character) => character === currentFence.marker)
|
||||
) {
|
||||
fence = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isTableRow(line: string | undefined) {
|
||||
return Boolean(line?.trim() && line.includes("|"))
|
||||
}
|
||||
|
||||
function isTableDelimiter(line: string | undefined) {
|
||||
if (!line) return false
|
||||
const value = line.trim().replace(/^\||\|$/g, "")
|
||||
const cells = value.split("|")
|
||||
return cells.length > 1 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))
|
||||
}
|
||||
|
||||
export function markdownLaneMarginTop(index: number, width: MarkdownLane["width"]) {
|
||||
if (index === 0 || width === "full") return 0
|
||||
return 1
|
||||
}
|
||||
@@ -1,19 +1,6 @@
|
||||
export const SESSION_SIDEBAR_WIDTH = 42
|
||||
export const SESSION_TECHNICAL_LANE_WIDTH = 88
|
||||
const SESSION_CONTENT_MIN_WIDTH = 44
|
||||
|
||||
export function sessionTabsFitVertically(total: number) {
|
||||
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
|
||||
}
|
||||
|
||||
// The shared spine centers the prose measure; the technical rail shares its
|
||||
// leading edge and extends rightward, clamped so it still fits the canvas.
|
||||
export function sessionLaneLayout(available: number, readable: number) {
|
||||
const technical = Math.min(available, Math.max(readable, SESSION_TECHNICAL_LANE_WIDTH))
|
||||
const centered = Math.floor((available - Math.min(readable, technical)) / 2)
|
||||
return {
|
||||
inset: Math.max(0, Math.min(centered, available - technical)),
|
||||
readable: Math.min(readable, technical),
|
||||
technical,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
parseQuestionAnswers,
|
||||
parseQuestions,
|
||||
toolDisplay,
|
||||
toolLane,
|
||||
} from "../../../src/routes/session"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
|
||||
@@ -132,11 +131,6 @@ describe("TUI inline tool wrapping", () => {
|
||||
expect(toolDisplay("apply_patch")).toBe("patch")
|
||||
expect(toolDisplay("patch")).toBe("patch")
|
||||
expect(toolDisplay("plugin_tool")).toBe("generic")
|
||||
expect(toolLane("glob")).toBe("readable")
|
||||
expect(toolLane("webfetch")).toBe("readable")
|
||||
expect(toolLane("shell")).toBe("technical")
|
||||
expect(toolLane("apply_patch")).toBe("technical")
|
||||
expect(toolLane("plugin_tool")).toBe("technical")
|
||||
})
|
||||
|
||||
test("replaces pending copy when a tool fails before completion", async () => {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { markdownLaneMarginTop, markdownLanes } from "../../../src/routes/session/markdown-lanes"
|
||||
|
||||
test("keeps prose in the readable lane", () => {
|
||||
expect(markdownLanes("Before\n\nAfter")).toEqual([{ content: "Before\n\nAfter", width: "readable" }])
|
||||
})
|
||||
|
||||
test("moves Mermaid fences into the full lane", () => {
|
||||
expect(
|
||||
markdownLanes(`Before
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A --> B
|
||||
\`\`\`
|
||||
|
||||
After`),
|
||||
).toEqual([
|
||||
{ content: "Before\n\n", width: "readable" },
|
||||
{ content: "```mermaid\nflowchart LR\n A --> B\n```\n", width: "full" },
|
||||
{ content: "\nAfter", width: "readable" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps an incomplete streaming Mermaid fence full width", () => {
|
||||
expect(markdownLanes("Before\n```mermaid\nflowchart LR\n A -->")).toEqual([
|
||||
{ content: "Before\n", width: "readable" },
|
||||
{ content: "```mermaid\nflowchart LR\n A -->", width: "full" },
|
||||
])
|
||||
})
|
||||
|
||||
test("supports tilde fences and longer closing fences", () => {
|
||||
expect(markdownLanes("~~~ts\nconst value = 1\n~~~~\nAfter")).toEqual([
|
||||
{ content: "~~~ts\nconst value = 1\n~~~~\n", width: "technical" },
|
||||
{ content: "After", width: "readable" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not close a fence indented as code", () => {
|
||||
expect(markdownLanes("```ts\n ```\nstill code")).toEqual([
|
||||
{ content: "```ts\n ```\nstill code", width: "technical" },
|
||||
])
|
||||
})
|
||||
|
||||
test("gives ordinary fenced code an intermediate lane", () => {
|
||||
expect(markdownLanes("```ts\nexport const value = true\n```")).toEqual([
|
||||
{ content: "```ts\nexport const value = true\n```", width: "technical" },
|
||||
])
|
||||
})
|
||||
|
||||
test("gives Markdown tables the technical lane", () => {
|
||||
expect(markdownLanes("Before\n\n| Name | Value |\n| --- | ---: |\n| Width | 88 |\n\nAfter")).toEqual([
|
||||
{ content: "Before\n\n", width: "readable" },
|
||||
{ content: "| Name | Value |\n| --- | ---: |\n| Width | 88 |\n", width: "technical" },
|
||||
{ content: "\nAfter", width: "readable" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not treat ordinary pipe characters as a table", () => {
|
||||
expect(markdownLanes("Use foo | bar in prose.")).toEqual([{ content: "Use foo | bar in prose.", width: "readable" }])
|
||||
})
|
||||
|
||||
test("restores spacing between separately rendered blocks", () => {
|
||||
expect(markdownLaneMarginTop(0, "readable")).toBe(0)
|
||||
expect(markdownLaneMarginTop(1, "technical")).toBe(1)
|
||||
expect(markdownLaneMarginTop(2, "readable")).toBe(1)
|
||||
expect(markdownLaneMarginTop(1, "full")).toBe(0)
|
||||
})
|
||||
@@ -27,15 +27,6 @@ test("validates the session tabs setting", () => {
|
||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||
})
|
||||
|
||||
test("validates the session max width setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ session: { max_width: "auto" } })).toEqual({ session: { max_width: "auto" } })
|
||||
expect(decode({ session: { max_width: 100 } })).toEqual({ session: { max_width: 100 } })
|
||||
expect(() => decode({ session: { max_width: 4 } })).toThrow()
|
||||
expect(() => decode({ session: { max_width: 100.5 } })).toThrow()
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
const config = resolve(
|
||||
{
|
||||
@@ -62,13 +53,6 @@ test("shows resolved tab defaults in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||
})
|
||||
|
||||
test("shows session reading width presets in settings", () => {
|
||||
const setting = settings.find((setting) => setting.path.join(".") === "session.max_width")
|
||||
|
||||
expect(setting?.default).toBe("auto")
|
||||
expect(setting?.values).toEqual(["auto", 66, 72, 80])
|
||||
})
|
||||
|
||||
test("provides config and its host interface", async () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
let current = {}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -14373,6 +14373,9 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "agent"],
|
||||
@@ -14441,6 +14444,9 @@
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"previous": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "model"],
|
||||
|
||||
@@ -14373,6 +14373,9 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "agent"],
|
||||
@@ -14441,6 +14444,9 @@
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"previous": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "model"],
|
||||
|
||||
Reference in New Issue
Block a user