mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 16:56:33 -04:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eda325c24 | |||
| fcfb78e433 | |||
| beaa51b4ff | |||
| 42e04c41c7 | |||
| 1a28b03e05 | |||
| 2040aa40e1 | |||
| 7a4054559d | |||
| e374393e3c | |||
| 819522830b | |||
| f2278649c0 | |||
| 70a767db9b | |||
| c377b73995 | |||
| 67dbdb64e5 | |||
| 1e5b32aac8 | |||
| fa5ecdd722 | |||
| 8cac8b5722 | |||
| 49cd448f8e | |||
| 77dcae0968 | |||
| a653744d78 | |||
| daf8e539bf | |||
| e6607fb58d | |||
| 128e2550ef | |||
| e8d273ae5b | |||
| 7ec1b6e580 | |||
| c4fa5e6619 | |||
| 6ec17e5d55 | |||
| 0405670cab | |||
| caf727ecb7 |
@@ -627,6 +627,13 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/quark": {
|
||||
"name": "@opencode-ai/quark",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode-ai/schema",
|
||||
"version": "1.17.11",
|
||||
@@ -882,6 +889,7 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/quark": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -2091,6 +2099,8 @@
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
"@opencode-ai/quark": ["@opencode-ai/quark@workspace:packages/quark"],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
|
||||
|
||||
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
|
||||
|
||||
@@ -93,10 +93,8 @@ const GeminiToolConfig = Schema.Struct({
|
||||
|
||||
const GeminiThinkingConfig = Schema.Struct({
|
||||
thinkingBudget: Schema.optional(Schema.Number),
|
||||
thinkingLevel: Schema.optional(Schema.Literals(["minimal", "low", "medium", "high"])),
|
||||
includeThoughts: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
type GeminiThinkingLevel = NonNullable<Schema.Schema.Type<typeof GeminiThinkingConfig>["thinkingLevel"]>
|
||||
|
||||
const GeminiGenerationConfig = Schema.Struct({
|
||||
maxOutputTokens: Schema.optional(Schema.Number),
|
||||
@@ -291,16 +289,11 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
|
||||
const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini
|
||||
|
||||
const isThinkingLevel = (value: unknown): value is GeminiThinkingLevel =>
|
||||
value === "minimal" || value === "low" || value === "medium" || value === "high"
|
||||
|
||||
const thinkingConfig = (request: LLMRequest) => {
|
||||
const value = geminiOptions(request)?.thinkingConfig
|
||||
if (!ProviderShared.isRecord(value)) return undefined
|
||||
const thinkingLevel = value.thinkingLevel
|
||||
const result = {
|
||||
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
thinkingLevel: isThinkingLevel(thinkingLevel) ? thinkingLevel : undefined,
|
||||
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
|
||||
}
|
||||
return Object.values(result).some((item) => item !== undefined) ? result : undefined
|
||||
|
||||
@@ -36,20 +36,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares thinking level", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "minimal" } } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.generationConfig).toEqual({ thinkingConfig: { thinkingLevel: "minimal" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/util/installation/version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
@@ -75,7 +75,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
password,
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
database: {
|
||||
path: process.env.OPENCODE_DB,
|
||||
path:
|
||||
process.env.OPENCODE_DB ??
|
||||
(["latest", "beta", "prod"].includes(InstallationChannel) ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
? "opencode.db"
|
||||
: `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
|
||||
},
|
||||
models: {
|
||||
url: process.env.OPENCODE_MODELS_URL,
|
||||
|
||||
@@ -131,7 +131,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.update((draft) => {
|
||||
draft.prompt = { paste: "compact" }
|
||||
draft.mini = { thinking: "hide", shell_output: "hide" }
|
||||
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true }
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -139,7 +139,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||
expect(config).toEqual({
|
||||
animations: true,
|
||||
prompt: { paste: "compact" },
|
||||
mini: { thinking: "hide", shell_output: "hide" },
|
||||
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", mono: true },
|
||||
})
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
export * as CodeMode from "./codemode"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { ExecuteTool } from "./tool/execute"
|
||||
import { permission, registrationEntries, type AnyTool } from "./tool/tool"
|
||||
import { Tools } from "./tool/tools"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
|
||||
export interface Materialization {
|
||||
readonly tool?: AnyTool
|
||||
readonly instructions?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, AnyTool>>,
|
||||
options?: Tools.RegisterOptions,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeMode") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const local = new Map<
|
||||
string,
|
||||
Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>
|
||||
>()
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("CodeMode.register")(function* (tools, options) {
|
||||
const entries = registrationEntries(tools, options?.namespace)
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const entry of entries)
|
||||
local.set(entry.key, [
|
||||
...(local.get(entry.key) ?? []),
|
||||
{ token, registration: { tool: entry.tool, name: entry.name, namespace: entry.namespace } },
|
||||
])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const entry of entries) {
|
||||
const registrations = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
|
||||
if (registrations.length > 0) local.set(entry.key, registrations)
|
||||
else local.delete(entry.key)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
materialize: Effect.fn("CodeMode.materialize")(function* (permissions) {
|
||||
const registrations = new Map<string, ExecuteTool.Registration>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
const rule = rules.findLast((rule) => Wildcard.match(permission(registration.tool, name), rule.action))
|
||||
if (rule?.resource === "*" && rule.effect === "deny") continue
|
||||
registrations.set(name, registration)
|
||||
}
|
||||
if (registrations.size === 0) return {}
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
|
||||
return {
|
||||
tool: ExecuteTool.create(registrations),
|
||||
instructions: ExecuteTool.instructions(registrations),
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
@@ -0,0 +1,45 @@
|
||||
export * as CodeModeInstructions from "./instructions"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { CodeMode } from "../codemode"
|
||||
import { Instructions } from "../instructions/index"
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeModeInstructions") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const codeMode = yield* CodeMode.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("CodeModeInstructions.load")(function* (selection) {
|
||||
const instructions = selection.info
|
||||
? (yield* codeMode.materialize(selection.info.permissions)).instructions
|
||||
: undefined
|
||||
return Instructions.make({
|
||||
key: Instructions.Key.make("core/codemode"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.succeed(instructions ?? Instructions.removed),
|
||||
render: {
|
||||
initial: (current) => current,
|
||||
changed: (_previous, current) =>
|
||||
[
|
||||
"The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.",
|
||||
current,
|
||||
].join("\n\n"),
|
||||
removed: () =>
|
||||
"Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
},
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [CodeMode.node] })
|
||||
@@ -6,7 +6,6 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
import { InstallationChannel } from "@opencode-ai/util/installation/version"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||
@@ -40,20 +39,12 @@ const databaseLayer = Layer.effect(
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
export function layer(options?: Options) {
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.suspend(() => {
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
if (options?.path === ":memory:" || (options?.path && isAbsolute(options.path))) return provide(options.path)
|
||||
if (options?.path) return provide(join(Global.Path.data, options.path))
|
||||
if (
|
||||
["latest", "beta", "prod"].includes(InstallationChannel) ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
|
||||
)
|
||||
return provide(join(Global.Path.data, "opencode.db"))
|
||||
return provide(
|
||||
join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
|
||||
)
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
return provide(join(Global.Path.data, filename))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export type ResolveInput = typeof ResolveInput.Type
|
||||
|
||||
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
||||
path: Schema.String,
|
||||
reason: Schema.Literals(["location_escape", "non_directory_ancestor"]),
|
||||
reason: Schema.Literal("non_directory_ancestor"),
|
||||
}) {}
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
@@ -83,7 +83,6 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const locationRoot = yield* fs.realPath(location.directory)
|
||||
|
||||
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
@@ -124,14 +123,8 @@ const layer = Layer.effect(
|
||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||
|
||||
const resolved = yield* resolvePath(absolute)
|
||||
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
|
||||
return yield* new PathError({ path: input.path, reason: "location_escape" })
|
||||
}
|
||||
|
||||
const external = !lexicallyInternal
|
||||
const resource = external
|
||||
? slash(resolved.canonical)
|
||||
: slash(path.relative(locationRoot, resolved.canonical) || ".")
|
||||
const resource = external ? slash(resolved.canonical) : slash(path.relative(location.directory, absolute) || ".")
|
||||
const externalDirectory =
|
||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Effect, Layer, LayerMap } from "effect"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
import { CodeMode } from "./codemode"
|
||||
import { CodeModeInstructions } from "./codemode/instructions"
|
||||
import { CommandV2 } from "./command"
|
||||
import { Config } from "./config"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -66,6 +68,7 @@ const locationServiceNodes = [
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
CodeMode.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
@@ -77,6 +80,7 @@ const locationServiceNodes = [
|
||||
ToolRegistry.toolsNode,
|
||||
Image.node,
|
||||
SkillInstructions.node,
|
||||
CodeModeInstructions.node,
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { McpInstructions } from "../mcp/instructions"
|
||||
import { PluginSupervisor } from "../plugin/supervisor"
|
||||
import { ReferenceInstructions } from "../reference/instructions"
|
||||
import { SkillInstructions } from "../skill/instructions"
|
||||
import { CodeModeInstructions } from "../codemode/instructions"
|
||||
import { AgentNotFoundError } from "./error"
|
||||
import { SessionHistory } from "./history"
|
||||
import { InstructionEntry } from "./instruction-entry"
|
||||
@@ -54,6 +55,7 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const builtins = yield* InstructionBuiltIns.Service
|
||||
const codeModeInstructions = yield* CodeModeInstructions.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
const entries = yield* InstructionEntry.Service
|
||||
@@ -77,6 +79,7 @@ const layer = Layer.effect(
|
||||
const instructions = yield* Effect.all(
|
||||
[
|
||||
builtins.load(sessionID),
|
||||
codeModeInstructions.load(agent),
|
||||
discovery.load(),
|
||||
skillInstructions.load(agent),
|
||||
referenceInstructions.load(),
|
||||
@@ -109,6 +112,7 @@ export const node = makeLocationNode({
|
||||
layer,
|
||||
deps: [
|
||||
AgentV2.node,
|
||||
CodeModeInstructions.node,
|
||||
Database.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLM, Message, SystemPart, type LLMRequest, type ToolContent } from "@opencode-ai/ai"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Client } from "@opencode-ai/util/client"
|
||||
import { ModelV2 } from "../model"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { SessionContext } from "./context"
|
||||
@@ -27,6 +28,45 @@ interface PrepareInput {
|
||||
readonly step: number
|
||||
}
|
||||
|
||||
const mimeToModality = (mime: string) => {
|
||||
if (mime.startsWith("image/")) return "image"
|
||||
if (mime.startsWith("audio/")) return "audio"
|
||||
if (mime.startsWith("video/")) return "video"
|
||||
if (mime === "application/pdf") return "pdf"
|
||||
}
|
||||
|
||||
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: ModelV2.Capabilities) => {
|
||||
const modality = mimeToModality(mime)
|
||||
if (!modality || capabilities.input.some((item) => item.startsWith(modality))) return
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`,
|
||||
}
|
||||
}
|
||||
|
||||
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: ModelV2.Capabilities) =>
|
||||
messages.map((message) =>
|
||||
Message.make({
|
||||
...message,
|
||||
content: message.content.map((part) => {
|
||||
if (part.type === "media") {
|
||||
return unsupportedMedia(part.mediaType, part.filename, capabilities) ?? part
|
||||
}
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
||||
return {
|
||||
...part,
|
||||
result: {
|
||||
...part.result,
|
||||
value: part.result.value.map((item: ToolContent) => {
|
||||
if (item.type !== "file") return item
|
||||
return unsupportedMedia(item.mime, item.name, capabilities) ?? item
|
||||
}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an outbound model request and captures the tool-call capability that
|
||||
* must remain paired with it. It does not execute the request or mutate
|
||||
@@ -87,7 +127,7 @@ export const layer = Layer.effect(
|
||||
},
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: contextEvent.messages,
|
||||
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
|
||||
@@ -82,6 +82,8 @@ export interface Resolved {
|
||||
readonly model: Model
|
||||
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
|
||||
readonly ref: ModelV2.Ref
|
||||
/** Catalog capabilities used to shape requests before provider lowering. */
|
||||
readonly capabilities: ModelV2.Capabilities
|
||||
/** Catalog pricing in dollars per million tokens. */
|
||||
readonly cost: ModelV2.Info["cost"]
|
||||
}
|
||||
@@ -96,14 +98,22 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
|
||||
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
|
||||
export const resolved = (
|
||||
model: Model,
|
||||
options: {
|
||||
readonly capabilities: ModelV2.Capabilities
|
||||
readonly variant?: ModelV2.VariantID
|
||||
readonly cost: ModelV2.Info["cost"]
|
||||
},
|
||||
): Resolved => ({
|
||||
model,
|
||||
ref: ModelV2.Ref.make({
|
||||
id: ModelV2.ID.make(model.id),
|
||||
providerID: ProviderV2.ID.make(model.provider),
|
||||
...(variant === undefined ? {} : { variant }),
|
||||
...(options.variant === undefined ? {} : { variant: options.variant }),
|
||||
}),
|
||||
cost,
|
||||
capabilities: options.capabilities,
|
||||
cost: options.cost,
|
||||
})
|
||||
|
||||
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
||||
@@ -359,6 +369,7 @@ const layer = Layer.effect(
|
||||
providerID: selected.providerID,
|
||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||
}),
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -36,34 +36,23 @@ type CollectedFiles = {
|
||||
readonly files: Array<typeof ExecuteFile.Type>
|
||||
}
|
||||
|
||||
interface Registration {
|
||||
export interface Registration {
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly namespace?: string
|
||||
}
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime through { code }.",
|
||||
"Call Code Mode tools through `tools` using the exact paths and signatures from the instructions.",
|
||||
"Use `search({ query })` to discover exact signatures when needed.",
|
||||
"Await important calls and use `Promise.all` for independent calls.",
|
||||
].join("\n")
|
||||
|
||||
export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
const runtime = (
|
||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) => {
|
||||
const tools: Record<string, Tool.Definition<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = definition(name, registration.tool)
|
||||
const value = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
output: child.outputSchema,
|
||||
run: (input) => invoke(name, registration, input),
|
||||
})
|
||||
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
|
||||
tools[path] = value
|
||||
}
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
const discovery = runtime(() => Effect.fail(toolError("Execute context is unavailable")))
|
||||
return make({
|
||||
description: discovery.instructions(),
|
||||
description,
|
||||
input: CodeMode.Input,
|
||||
output: ExecuteOutput,
|
||||
structured: ExecuteMetadata,
|
||||
@@ -92,6 +81,7 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
),
|
||||
)
|
||||
const result = yield* runtime(
|
||||
registrations,
|
||||
(name, registration, input) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||
@@ -141,6 +131,29 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const instructions = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).instructions()
|
||||
}
|
||||
|
||||
function runtime(
|
||||
registrations: ReadonlyMap<string, Registration>,
|
||||
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) {
|
||||
const tools: Record<string, Tool.Definition<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = definition(name, registration.tool)
|
||||
const path = registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
|
||||
tools[path] = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
output: child.outputSchema,
|
||||
run: (input) => invoke(name, registration, input),
|
||||
})
|
||||
}
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
if (typeof input !== "object" || Array.isArray(input)) return { input }
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { ExecuteTool } from "./execute"
|
||||
import { CodeMode } from "../codemode"
|
||||
import {
|
||||
definition,
|
||||
permission,
|
||||
@@ -74,6 +74,7 @@ const registryLayer = Layer.effect(
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const image = yield* Image.Service
|
||||
const codeMode = yield* CodeMode.Service
|
||||
|
||||
type NormalizedItem = ToolOutput["content"][number] | "decode" | "size"
|
||||
const normalizeImages = Effect.fn("ToolRegistry.normalizeImages")(function* (content: ToolOutput["content"]) {
|
||||
@@ -111,7 +112,6 @@ const registryLayer = Layer.effect(
|
||||
readonly tool: AnyTool
|
||||
readonly name: string
|
||||
readonly namespace?: string
|
||||
readonly codemode: boolean
|
||||
}
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
const registrationLock = Semaphore.makeUnsafe(1)
|
||||
@@ -212,15 +212,22 @@ const registryLayer = Layer.effect(
|
||||
return yield* Effect.fail(
|
||||
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
|
||||
)
|
||||
return { entries, codemode }
|
||||
return { tools, options, entries, codemode }
|
||||
}),
|
||||
)
|
||||
if (planned.every((plan) => plan.entries.length === 0)) return
|
||||
// CodeMode registrations live in the CodeMode service; the registry keeps only direct tools.
|
||||
yield* Effect.forEach(
|
||||
planned.filter((plan) => plan.codemode && plan.entries.length > 0),
|
||||
(plan) => codeMode.register(plan.tools, plan.options),
|
||||
{ discard: true },
|
||||
)
|
||||
const direct = planned.filter((plan) => !plan.codemode)
|
||||
if (direct.every((plan) => plan.entries.length === 0)) return
|
||||
yield* Effect.uninterruptible(
|
||||
registrationLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const { entries, codemode } of planned)
|
||||
for (const { entries } of direct)
|
||||
for (const entry of entries)
|
||||
local.set(entry.key, [
|
||||
...(local.get(entry.key) ?? []),
|
||||
@@ -230,14 +237,13 @@ const registryLayer = Layer.effect(
|
||||
tool: entry.tool,
|
||||
name: entry.name,
|
||||
namespace: entry.namespace,
|
||||
codemode,
|
||||
},
|
||||
},
|
||||
])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
registrationLock.withPermit(
|
||||
Effect.sync(() => {
|
||||
for (const { entries } of planned)
|
||||
for (const { entries } of direct)
|
||||
for (const entry of entries) {
|
||||
const registrations =
|
||||
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
|
||||
@@ -265,19 +271,16 @@ const registryLayer = Layer.effect(
|
||||
registerBatch,
|
||||
materialize: Effect.fn("ToolRegistry.materialize")((permissions) =>
|
||||
registrationLock.withPermit(
|
||||
Effect.sync(() => {
|
||||
Effect.gen(function* () {
|
||||
const direct = new Map<string, Registration>()
|
||||
const codemode = new Map<string, Registration>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
if (whollyDisabled(permission(registration.tool, name), rules)) continue
|
||||
if (registration.codemode) codemode.set(name, registration)
|
||||
else direct.set(name, registration)
|
||||
direct.set(name, registration)
|
||||
}
|
||||
const execute =
|
||||
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
|
||||
const execute = (yield* codeMode.materialize(permissions)).tool
|
||||
return {
|
||||
definitions: [
|
||||
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
|
||||
@@ -315,11 +318,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node, ToolHooks.node, Image.node],
|
||||
deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node, ToolHooks.node, Image.node],
|
||||
deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { CodeMode } from "@opencode-ai/core/codemode"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("CodeMode", () => {
|
||||
it.effect("owns registrations, execute, and catalog materialization", () =>
|
||||
Effect.gen(function* () {
|
||||
const codeMode = yield* CodeMode.Service
|
||||
yield* codeMode.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ text }) => Effect.succeed(text),
|
||||
}),
|
||||
})
|
||||
|
||||
const materialized = yield* codeMode.materialize()
|
||||
expect(materialized.tool).toBeDefined()
|
||||
expect(materialized.instructions).toContain("Echo text")
|
||||
expect(materialized.instructions).toContain("tools.echo(input:")
|
||||
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(CodeMode.node))),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { CodeMode } from "@opencode-ai/core/codemode"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { readInitial, readUpdate } from "../lib/instructions"
|
||||
|
||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(AgentV2.ID.make("build")))
|
||||
|
||||
describe("CodeModeInstructions", () => {
|
||||
it.effect("renders catalog changes and removal", () => {
|
||||
let catalog: string | undefined = "Initial Code Mode catalog"
|
||||
const layer = AppNodeBuilder.build(CodeModeInstructions.node, [
|
||||
[
|
||||
CodeMode.node,
|
||||
Layer.mock(CodeMode.Service, {
|
||||
materialize: () => Effect.succeed({ ...(catalog === undefined ? {} : { instructions: catalog }) }),
|
||||
register: () => Effect.void,
|
||||
}),
|
||||
],
|
||||
])
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const instructions = yield* CodeModeInstructions.Service
|
||||
const initialized = yield* instructions.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(readInitial))
|
||||
expect(initialized.text).toBe("Initial Code Mode catalog")
|
||||
|
||||
catalog = "Updated Code Mode catalog"
|
||||
expect(
|
||||
yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
|
||||
).toMatchObject({
|
||||
text: "The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.\n\nUpdated Code Mode catalog",
|
||||
})
|
||||
|
||||
catalog = undefined
|
||||
expect(
|
||||
yield* instructions
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => readUpdate(context, initialized))),
|
||||
).toMatchObject({
|
||||
text: "Code Mode tools are no longer available. Do not use any previously listed Code Mode tools.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer))
|
||||
})
|
||||
})
|
||||
@@ -77,7 +77,7 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a prospective target below an escaping symlink ancestor", () =>
|
||||
it.live("authorizes a prospective target below an external symlink by its in-location path", () =>
|
||||
withTmp((directory) => {
|
||||
const outside = `${directory}-outside`
|
||||
return Effect.gen(function* () {
|
||||
@@ -86,10 +86,12 @@ describe("LocationMutation", () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const error = yield* Effect.flip(
|
||||
(yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") }),
|
||||
)
|
||||
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "location_escape" })
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(outside)), "new.txt"),
|
||||
resource: "escape/new.txt",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
|
||||
}).pipe(provide(directory))
|
||||
}),
|
||||
@@ -106,7 +108,7 @@ describe("LocationMutation", () => {
|
||||
|
||||
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
canonical: path.join(yield* Effect.promise(() => fs.realpath(directory)), "actual", "new.txt"),
|
||||
resource: "actual/new.txt",
|
||||
resource: "linked/new.txt",
|
||||
})
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
|
||||
@@ -767,9 +767,10 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* waitForTool(registry, "execute")
|
||||
const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute")
|
||||
const materialized = yield* registry.materialize()
|
||||
const execute = materialized.definitions.find((tool) => tool.name === "execute")
|
||||
|
||||
expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>")
|
||||
expect(execute?.description).not.toContain("tools.demo.search")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -200,6 +200,22 @@ describe("Patch", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("does not normalize ellipses", () => {
|
||||
expect(() =>
|
||||
Patch.derive("ellipsis.txt", [{ oldLines: ["wait..."], newLines: ["done"] }], "wait…\n"),
|
||||
).toThrow("Failed to find expected lines")
|
||||
})
|
||||
|
||||
test("prefers a later exact match over an earlier normalized match", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
"quotes.txt",
|
||||
[{ oldLines: ['He said "hello"'], newLines: ['He said "goodbye"'] }],
|
||||
'He said “hello”\nmiddle\nHe said "hello"\n',
|
||||
).content,
|
||||
).toBe('He said “hello”\nmiddle\nHe said "goodbye"\n')
|
||||
})
|
||||
|
||||
test("matches EOF-anchored chunks from the end", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
|
||||
@@ -49,7 +49,14 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||
const models = SessionRunnerModel.layerWith(() =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
|
||||
@@ -68,7 +68,13 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
})
|
||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost,
|
||||
}),
|
||||
),
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
|
||||
@@ -65,7 +65,14 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
return response
|
||||
}),
|
||||
})
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||
const models = SessionRunnerModel.layerWith(() =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const builtins = Layer.mock(InstructionBuiltIns.Service, {
|
||||
load: () =>
|
||||
Effect.succeed(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, ToolResultPart } from "@opencode-ai/ai"
|
||||
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
|
||||
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
|
||||
|
||||
describe("SessionModelRequest.unsupportedParts", () => {
|
||||
test("replaces unsupported user media with a visible error", () => {
|
||||
const messages = unsupportedParts(
|
||||
[
|
||||
Message.user([
|
||||
Message.text("Describe this image"),
|
||||
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "logo.png" },
|
||||
]),
|
||||
],
|
||||
capabilities(["text"]),
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
Message.text("Describe this image"),
|
||||
Message.text('ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.'),
|
||||
])
|
||||
})
|
||||
|
||||
test("replaces unsupported media nested in tool results", () => {
|
||||
const messages = unsupportedParts(
|
||||
[
|
||||
Message.tool(
|
||||
ToolResultPart.make({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "logo.png" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
capabilities(["text"]),
|
||||
)
|
||||
|
||||
expect(messages[0]?.content[0]).toMatchObject({
|
||||
type: "tool-result",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{
|
||||
type: "text",
|
||||
text: 'ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves supported media", () => {
|
||||
const message = Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })
|
||||
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
|
||||
})
|
||||
})
|
||||
@@ -73,7 +73,14 @@ const model = OpenAIChat.route
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||
const models = SessionRunnerModel.layerWith(() =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
|
||||
@@ -481,6 +481,9 @@ describe("ToolRegistry", () => {
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
const materialized = yield* service.materialize()
|
||||
const execute = materialized.definitions.find((tool) => tool.name === "execute")
|
||||
expect(execute?.description).toContain("confined Code Mode runtime")
|
||||
expect(execute?.description).not.toContain("Echo text")
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* service.register({
|
||||
echo: Tool.make({
|
||||
|
||||
@@ -283,10 +283,11 @@ let currentModel = model
|
||||
const models = SessionRunnerModel.layerWith((session) =>
|
||||
modelResolveHook.pipe(
|
||||
Effect.as(
|
||||
SessionRunnerModel.resolved(
|
||||
session.model?.id === "replacement" ? replacementModel : currentModel,
|
||||
session.model?.variant,
|
||||
),
|
||||
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
variant: session.model?.variant,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -65,7 +65,13 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost,
|
||||
}),
|
||||
),
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
|
||||
@@ -199,6 +199,41 @@ describe("EditTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("edits an external symlink target with only its in-location permission", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
if (process.platform === "win32") return Effect.void
|
||||
const target = path.join(outside.path, "external.txt")
|
||||
const link = path.join(active.path, "link.txt")
|
||||
return Effect.promise(async () => {
|
||||
await fs.writeFile(target, "before")
|
||||
await fs.symlink(target, link)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
withTool(active.path, (registry) =>
|
||||
executeTool(registry, call({ path: "link.txt", oldString: "before", newString: "after" })),
|
||||
),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.sync(() => {
|
||||
expect(result.type).toBe("text")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(assertions[0]?.resources).toEqual(["link.txt"])
|
||||
}),
|
||||
),
|
||||
Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))),
|
||||
Effect.tap((content) => Effect.sync(() => expect(content).toBe("after"))),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an explicit external absolute path before edit", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
|
||||
@@ -220,6 +220,39 @@ describe("WriteTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("writes an external symlink target with only its in-location permission", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
if (process.platform === "win32") return Effect.void
|
||||
const target = path.join(outside.path, "external.txt")
|
||||
const link = path.join(active.path, "link.txt")
|
||||
return Effect.promise(async () => {
|
||||
await fs.writeFile(target, "before")
|
||||
await fs.symlink(target, link)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
withTool(active.path, (registry) => executeTool(registry, call({ path: "link.txt", content: "after" }))),
|
||||
),
|
||||
Effect.andThen((result) =>
|
||||
Effect.sync(() => {
|
||||
expect(result.type).toBe("text")
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||
expect(assertions[0]?.resources).toEqual(["link.txt"])
|
||||
}),
|
||||
),
|
||||
Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))),
|
||||
Effect.tap((content) => Effect.sync(() => expect(content).toBe("after"))),
|
||||
)
|
||||
},
|
||||
([active, outside]) =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an explicit external absolute path before edit", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Third-Party Notices
|
||||
|
||||
## alien-signals
|
||||
|
||||
The internal reactive kernel in `src/reactivity.ts` adapts the dependency
|
||||
graph algorithm of [alien-signals](https://github.com/stackblitz/alien-signals)
|
||||
3.2.1: the intrusive doubly-linked dependency and subscriber links with
|
||||
versioned in-place reuse, and the iterative `propagate` and `checkDirty`
|
||||
graph walks. alien-signals is not a runtime dependency of Quark.
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-present Johnson Chu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Layout } from "../src/layout"
|
||||
import { createHarness, type Workload } from "./harness"
|
||||
|
||||
type Job = {
|
||||
readonly id: number
|
||||
readonly labels: readonly number[]
|
||||
readonly status: "running" | "retrying"
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
const size = 1_000
|
||||
const iterations = 200_000
|
||||
const initial = Array.from({ length: size }, (_, id): Job => ({
|
||||
id,
|
||||
labels: [id],
|
||||
status: id === 750 ? "retrying" : "running",
|
||||
value: 0,
|
||||
}))
|
||||
const JobLayout = Layout.struct({
|
||||
id: Layout.key(Layout.number),
|
||||
labels: Layout.array(Layout.number),
|
||||
status: Layout.string,
|
||||
value: Layout.number,
|
||||
})
|
||||
const Jobs = Layout.collection(JobLayout, ({ members, first }) => ({
|
||||
labels: members(["labels"], (job) => job.labels),
|
||||
retry: first(["status"], (job) => job.status === "retrying"),
|
||||
}))
|
||||
const LabeledJobs = Layout.collection(JobLayout, ({ members }) => ({
|
||||
labels: members(["labels"], (job) => job.labels),
|
||||
}))
|
||||
const JobPlan = Layout.compile(JobLayout)
|
||||
const bench = createHarness({ warmup: 10_000, width: 38 })
|
||||
|
||||
function manualValueUpdate(): Workload {
|
||||
const jobs = JobPlan.make(initial)
|
||||
const labels = new Set(initial.flatMap((job) => job.labels))
|
||||
const retry = jobs.get(750)!
|
||||
return {
|
||||
run(index) {
|
||||
const id = index % size
|
||||
jobs.modify(id, (job) => ({ ...job, value: index + 1 }))
|
||||
},
|
||||
consume: () => Number(labels.has(500)) + retry().id + jobs.get(iterations % size)!().value,
|
||||
}
|
||||
}
|
||||
|
||||
function indexedValueUpdate(): Workload {
|
||||
const jobs = Jobs.make(initial)
|
||||
return {
|
||||
run(index) {
|
||||
const id = index % size
|
||||
jobs.modify(id, (job) => ({ ...job, value: index + 1 }))
|
||||
},
|
||||
consume: () =>
|
||||
Number(jobs.hasMember("labels", 500)) + jobs.first("retry")()!.id + jobs.get(iterations % size)!().value,
|
||||
}
|
||||
}
|
||||
|
||||
function manualMemberAppend(): Workload {
|
||||
const jobs = JobPlan.make(initial)
|
||||
const labels = new Map(initial.flatMap((job) => job.labels.map((label) => [label, 1])))
|
||||
const members = new Map(initial.map((job) => [job.id, new Set(job.labels)]))
|
||||
return {
|
||||
run(index) {
|
||||
const id = index % size
|
||||
const label = size + index
|
||||
jobs.modify(id, (job) => {
|
||||
const next = [...job.labels.slice(-7), label]
|
||||
const previous = members.get(id)!
|
||||
const current = new Set(next)
|
||||
previous.forEach((member) => {
|
||||
if (current.has(member)) return
|
||||
const count = labels.get(member)!
|
||||
if (count === 1) labels.delete(member)
|
||||
if (count > 1) labels.set(member, count - 1)
|
||||
})
|
||||
current.forEach((member) => {
|
||||
if (!previous.has(member)) labels.set(member, (labels.get(member) ?? 0) + 1)
|
||||
})
|
||||
members.set(id, current)
|
||||
return { ...job, labels: next }
|
||||
})
|
||||
},
|
||||
consume: () => Number(labels.has(size + iterations - 1)) + jobs.get(iterations % size)!().labels.length,
|
||||
}
|
||||
}
|
||||
|
||||
function indexedMemberAppend(): Workload {
|
||||
const jobs = LabeledJobs.make(initial)
|
||||
return {
|
||||
run(index) {
|
||||
const id = index % size
|
||||
const label = size + index
|
||||
jobs.modify(id, (job) => ({
|
||||
...job,
|
||||
labels: [...job.labels.slice(-7), label],
|
||||
}))
|
||||
},
|
||||
consume: () =>
|
||||
Number(jobs.hasMember("labels", size + iterations - 1)) + jobs.get(iterations % size)!().labels.length,
|
||||
}
|
||||
}
|
||||
|
||||
function manualGrowingAppend(): Workload {
|
||||
const jobs = JobPlan.make([{ id: 0, labels: [], status: "running", value: 0 }])
|
||||
const labels = new Set<number>()
|
||||
let label = 0
|
||||
return {
|
||||
run() {
|
||||
const next = label++
|
||||
jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] }))
|
||||
labels.add(next)
|
||||
},
|
||||
consume: () => Number(labels.has(label - 1)) + jobs.get(0)!().labels.length,
|
||||
}
|
||||
}
|
||||
|
||||
function indexedGrowingAppend(): Workload {
|
||||
const jobs = LabeledJobs.make([{ id: 0, labels: [], status: "running", value: 0 }])
|
||||
let label = 0
|
||||
return {
|
||||
run() {
|
||||
const next = label++
|
||||
jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] }), {
|
||||
members: { labels: { add: [next] } },
|
||||
})
|
||||
},
|
||||
consume: () => Number(jobs.hasMember("labels", label - 1)) + jobs.get(0)!().labels.length,
|
||||
}
|
||||
}
|
||||
|
||||
function automaticGrowingAppend(): Workload {
|
||||
const jobs = LabeledJobs.make([{ id: 0, labels: [], status: "running", value: 0 }])
|
||||
let label = 0
|
||||
return {
|
||||
run() {
|
||||
const next = label++
|
||||
jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] }))
|
||||
},
|
||||
consume: () => Number(jobs.hasMember("labels", label - 1)) + jobs.get(0)!().labels.length,
|
||||
}
|
||||
}
|
||||
|
||||
function collectionSet(indexed: boolean, changed: boolean): Workload {
|
||||
const jobs = indexed ? Jobs.make(initial) : JobPlan.make(initial)
|
||||
return {
|
||||
run(index) {
|
||||
const id = index % size
|
||||
const current = jobs.values()
|
||||
const job = current[id]
|
||||
jobs.set(current.with(id, { ...job, value: changed ? job.value + 1 : job.value }))
|
||||
},
|
||||
consume: () => jobs.values()[0].value + jobs.values().length,
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Compiled collection benchmark (${size} items, ${bench.samples} samples)\n`)
|
||||
|
||||
const value = bench.compare(iterations, [
|
||||
{ name: "Handwritten value update", make: manualValueUpdate },
|
||||
{ name: "Compiled indexed value update", make: indexedValueUpdate },
|
||||
])
|
||||
const member = bench.compare(iterations, [
|
||||
{ name: "Handwritten member append", make: manualMemberAppend },
|
||||
{ name: "Compiled indexed member append", make: indexedMemberAppend },
|
||||
])
|
||||
const growing = bench.compare(2_000, [
|
||||
{ name: "Handwritten growing append", make: manualGrowingAppend },
|
||||
{ name: "Automatic indexed growing append", make: automaticGrowingAppend },
|
||||
{ name: "Indexed delta growing append", make: indexedGrowingAppend },
|
||||
])
|
||||
const equivalentSet = bench.compare(2_000, [
|
||||
{ name: "Bare keyed equivalent set", make: () => collectionSet(false, false) },
|
||||
{ name: "Compiled indexed equivalent set", make: () => collectionSet(true, false) },
|
||||
])
|
||||
const changedSet = bench.compare(2_000, [
|
||||
{ name: "Bare keyed changed set", make: () => collectionSet(false, true) },
|
||||
{ name: "Compiled indexed changed set", make: () => collectionSet(true, true) },
|
||||
])
|
||||
|
||||
console.log("\nRatios to handwritten (lower is faster)")
|
||||
console.log(`Value update: ${value.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`Member append: ${member.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`Automatic growing append: ${growing.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`Delta growing append: ${growing.ratio(2, 0).toFixed(3)}x`)
|
||||
console.log(`Equivalent collection set: ${equivalentSet.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`Changed collection set: ${changedSet.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`METRIC collection_value_ratio=${value.ratio(1, 0).toFixed(6)}`)
|
||||
console.log(`METRIC collection_member_ratio=${member.ratio(1, 0).toFixed(6)}`)
|
||||
console.log(`METRIC collection_automatic_growing_ratio=${growing.ratio(1, 0).toFixed(6)}`)
|
||||
console.log(`METRIC collection_delta_growing_ratio=${growing.ratio(2, 0).toFixed(6)}`)
|
||||
console.log(`METRIC collection_equivalent_set_ratio=${equivalentSet.ratio(1, 0).toFixed(6)}`)
|
||||
console.log(`METRIC collection_changed_set_ratio=${changedSet.ratio(1, 0).toFixed(6)}`)
|
||||
bench.finish()
|
||||
@@ -0,0 +1,60 @@
|
||||
export type Workload = {
|
||||
run(index: number): void
|
||||
consume(): number
|
||||
dispose?(): void
|
||||
}
|
||||
|
||||
export type Variant = {
|
||||
readonly name: string
|
||||
readonly make: () => Workload
|
||||
}
|
||||
|
||||
export function createHarness(options: { readonly samples?: number; readonly warmup?: number } = {}) {
|
||||
const samples = options.samples ?? 9
|
||||
const warmup = options.warmup ?? 500
|
||||
let checksum = 0
|
||||
|
||||
return {
|
||||
samples,
|
||||
compare(iterations: number, variants: readonly Variant[]) {
|
||||
const timings = variants.map(() => [] as number[])
|
||||
for (let sample = -1; sample < samples; sample++) {
|
||||
const offset = sample < 0 ? 0 : sample % variants.length
|
||||
variants
|
||||
.map((_variant, index) => (index + offset) % variants.length)
|
||||
.forEach((variantIndex) => {
|
||||
const workload = variants[variantIndex].make()
|
||||
for (let index = 0; index < Math.min(iterations, warmup); index++) workload.run(index)
|
||||
const start = Bun.nanoseconds()
|
||||
for (let index = 0; index < iterations; index++) workload.run(index)
|
||||
const elapsed = Bun.nanoseconds() - start
|
||||
checksum += workload.consume()
|
||||
workload.dispose?.()
|
||||
if (sample >= 0) timings[variantIndex].push(elapsed / iterations)
|
||||
})
|
||||
}
|
||||
|
||||
const medians = variants.map((variant, index) => {
|
||||
const median = middle(timings[index])
|
||||
const mad = middle(timings[index].map((value) => Math.abs(value - median)))
|
||||
const metric = variant.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")
|
||||
console.log(`${variant.name.padEnd(42)} ${median.toFixed(1).padStart(10)} ns/op +/- ${mad.toFixed(1)} MAD`)
|
||||
console.log(`METRIC ${metric}_ns_per_op=${median.toFixed(3)}`)
|
||||
return median
|
||||
})
|
||||
return {
|
||||
medians,
|
||||
ratio(left: number, right: number) {
|
||||
return middle(timings[left].map((value, index) => value / timings[right][index]))
|
||||
},
|
||||
}
|
||||
},
|
||||
finish() {
|
||||
console.log(`CHECKSUM ${checksum}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function middle(values: number[]) {
|
||||
return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { createComputed, createRoot } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { Keyed } from "../src"
|
||||
import { createHarness, type Workload } from "./harness"
|
||||
|
||||
type Item = {
|
||||
readonly id: number
|
||||
readonly value: number
|
||||
}
|
||||
|
||||
const bench = createHarness()
|
||||
const results: Array<{ readonly name: string; readonly ratio: number }> = []
|
||||
|
||||
function initial(size: number) {
|
||||
return Array.from({ length: size }, (_, id): Item => ({ id, value: 0 }))
|
||||
}
|
||||
|
||||
function project(values: readonly Item[]) {
|
||||
return values.reduce((total, value) => total + value.id + value.value, 0)
|
||||
}
|
||||
|
||||
function quarkDirect(size: number, aggregate: boolean): Workload {
|
||||
const values = initial(size)
|
||||
const keyed = Keyed.make<Item, number>({
|
||||
key: (item) => item.id,
|
||||
equivalent: (left, right) => left.value === right.value,
|
||||
})
|
||||
keyed.set(values)
|
||||
const target = keyed.slots()[Math.floor(size / 2)]
|
||||
let sink = aggregate ? project(keyed.values()) : target().value
|
||||
const dispose = aggregate
|
||||
? keyed.values.subscribe((next) => (sink = project(next)))
|
||||
: target.subscribe((value) => (sink = value.value))
|
||||
return {
|
||||
run: (index) => keyed.update({ id: Math.floor(size / 2), value: index + 1 }),
|
||||
consume: () => sink,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function solidDirect(size: number, aggregate: boolean): Workload {
|
||||
let run = (_index: number) => {}
|
||||
let consume = () => 0
|
||||
let dispose = () => {}
|
||||
createRoot((rootDispose) => {
|
||||
dispose = rootDispose
|
||||
const [values, setValues] = createStore(initial(size))
|
||||
const target = Math.floor(size / 2)
|
||||
let sink = aggregate ? project(values) : values[target].value
|
||||
if (aggregate) createComputed(() => (sink = project(values)))
|
||||
else createComputed(() => (sink = values[target].value))
|
||||
run = (index) => setValues(target, reconcile({ id: target, value: index + 1 }))
|
||||
consume = () => sink
|
||||
})
|
||||
return { run, consume, dispose }
|
||||
}
|
||||
|
||||
function quarkNoSubscriber(size: number): Workload {
|
||||
const keyed = Keyed.make<Item, number>({
|
||||
key: (item) => item.id,
|
||||
equivalent: (left, right) => left.value === right.value,
|
||||
})
|
||||
keyed.set(initial(size))
|
||||
const target = Math.floor(size / 2)
|
||||
return {
|
||||
run: (index) => keyed.update({ id: target, value: index + 1 }),
|
||||
consume: () => keyed.slots()[target]().value,
|
||||
}
|
||||
}
|
||||
|
||||
function solidNoSubscriber(size: number): Workload {
|
||||
const [values, setValues] = createStore(initial(size))
|
||||
const target = Math.floor(size / 2)
|
||||
return {
|
||||
run: (index) => setValues(target, reconcile({ id: target, value: index + 1 })),
|
||||
consume: () => values[target].value,
|
||||
}
|
||||
}
|
||||
|
||||
function solidPathWriteNoSubscriber(size: number): Workload {
|
||||
const [values, setValues] = createStore(initial(size))
|
||||
const target = Math.floor(size / 2)
|
||||
return {
|
||||
run: (index) => setValues(target, "value", index + 1),
|
||||
consume: () => values[target].value,
|
||||
}
|
||||
}
|
||||
|
||||
function quarkDense(size: number): Workload {
|
||||
const keyed = Keyed.make<Item, number>({
|
||||
key: (item) => item.id,
|
||||
equivalent: (left, right) => left.value === right.value,
|
||||
})
|
||||
keyed.set(initial(size))
|
||||
let sink = project(keyed.values())
|
||||
const dispose = keyed.values.subscribe((values) => (sink = project(values)))
|
||||
return {
|
||||
run: (index) => keyed.set(initial(size).map((item) => ({ ...item, value: index + 1 }))),
|
||||
consume: () => sink,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function solidDense(size: number): Workload {
|
||||
let run = (_index: number) => {}
|
||||
let consume = () => 0
|
||||
let dispose = () => {}
|
||||
createRoot((rootDispose) => {
|
||||
dispose = rootDispose
|
||||
const [values, setValues] = createStore(initial(size))
|
||||
let sink = project(values)
|
||||
createComputed(() => (sink = project(values)))
|
||||
run = (index) => setValues(reconcile(initial(size).map((item) => ({ ...item, value: index + 1 }))))
|
||||
consume = () => sink
|
||||
})
|
||||
return { run, consume, dispose }
|
||||
}
|
||||
|
||||
function quarkUnstable(size: number): Workload {
|
||||
const keyed = Keyed.make<Item, number>({ key: (item) => item.id })
|
||||
keyed.set(initial(size))
|
||||
let sink = project(keyed.values())
|
||||
const dispose = keyed.values.subscribe((values) => (sink = project(values)))
|
||||
return {
|
||||
run(index) {
|
||||
const offset = (index + 1) * size
|
||||
keyed.set(initial(size).map((item) => ({ id: item.id + offset, value: index })))
|
||||
},
|
||||
consume: () => sink,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function solidUnstable(size: number): Workload {
|
||||
let run = (_index: number) => {}
|
||||
let consume = () => 0
|
||||
let dispose = () => {}
|
||||
createRoot((rootDispose) => {
|
||||
dispose = rootDispose
|
||||
const [values, setValues] = createStore(initial(size))
|
||||
let sink = project(values)
|
||||
createComputed(() => (sink = project(values)))
|
||||
run = (index) => {
|
||||
const offset = (index + 1) * size
|
||||
setValues(reconcile(initial(size).map((item) => ({ id: item.id + offset, value: index }))))
|
||||
}
|
||||
consume = () => sink
|
||||
})
|
||||
return { run, consume, dispose }
|
||||
}
|
||||
|
||||
function compare(name: string, iterations: number, quark: () => Workload, solid: () => Workload) {
|
||||
console.log(`\n${name}`)
|
||||
const result = bench.compare(iterations, [
|
||||
{ name: `Quark ${name}`, make: quark },
|
||||
{ name: `Solid ${name}`, make: solid },
|
||||
])
|
||||
results.push({ name, ratio: result.ratio(0, 1) })
|
||||
}
|
||||
|
||||
console.log(`Keyed integration benchmark (${bench.samples} samples)`)
|
||||
|
||||
compare(
|
||||
"direct no subscribers 1000",
|
||||
200_000,
|
||||
() => quarkNoSubscriber(1_000),
|
||||
() => solidNoSubscriber(1_000),
|
||||
)
|
||||
compare(
|
||||
"adversarial direct path write 1000",
|
||||
200_000,
|
||||
() => quarkNoSubscriber(1_000),
|
||||
() => solidPathWriteNoSubscriber(1_000),
|
||||
)
|
||||
compare(
|
||||
"subscribed values 10",
|
||||
100_000,
|
||||
() => quarkDirect(10, true),
|
||||
() => solidDirect(10, true),
|
||||
)
|
||||
compare(
|
||||
"subscribed values 100",
|
||||
25_000,
|
||||
() => quarkDirect(100, true),
|
||||
() => solidDirect(100, true),
|
||||
)
|
||||
compare(
|
||||
"subscribed values 1000",
|
||||
2_500,
|
||||
() => quarkDirect(1_000, true),
|
||||
() => solidDirect(1_000, true),
|
||||
)
|
||||
compare(
|
||||
"subscribed values 10000",
|
||||
250,
|
||||
() => quarkDirect(10_000, true),
|
||||
() => solidDirect(10_000, true),
|
||||
)
|
||||
compare(
|
||||
"dense update 1000",
|
||||
250,
|
||||
() => quarkDense(1_000),
|
||||
() => solidDense(1_000),
|
||||
)
|
||||
compare(
|
||||
"unstable keys 100",
|
||||
1_000,
|
||||
() => quarkUnstable(100),
|
||||
() => solidUnstable(100),
|
||||
)
|
||||
|
||||
console.log("\nRatios to Solid (lower is faster)")
|
||||
results.forEach((result) => {
|
||||
console.log(`${result.name.padEnd(34)} ${result.ratio.toFixed(3)}x`)
|
||||
console.log(`METRIC ${result.name.replaceAll(/[^a-z0-9]+/g, "_")}_ratio=${result.ratio.toFixed(6)}`)
|
||||
})
|
||||
bench.finish()
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHarness, type Workload } from "./harness"
|
||||
|
||||
type Row =
|
||||
| { readonly type: "message"; readonly messageID: string }
|
||||
| { readonly type: "part"; readonly messageID: string; readonly partID: string }
|
||||
| {
|
||||
readonly type: "group"
|
||||
readonly kind: "reasoning" | "exploration"
|
||||
readonly messageID: string
|
||||
readonly partID: string
|
||||
}
|
||||
|
||||
const size = 10_000
|
||||
const iterations = 1_000_000
|
||||
const rows = Array.from({ length: size }, (_, index): Row => {
|
||||
if (index % 3 === 0) return { type: "message", messageID: `message-${index}` }
|
||||
if (index % 3 === 1) return { type: "part", messageID: `message-${index >> 2}`, partID: `text:${index}` }
|
||||
return {
|
||||
type: "group",
|
||||
kind: index % 2 === 0 ? "reasoning" : "exploration",
|
||||
messageID: `message-${index >> 2}`,
|
||||
partID: `call-${index}`,
|
||||
}
|
||||
})
|
||||
const precomputed = rows.map((row) => ({ row, id: concatenate(row) }))
|
||||
const bench = createHarness()
|
||||
|
||||
function workload(read: (index: number) => string): Workload {
|
||||
let sink = 0
|
||||
return {
|
||||
run(index) {
|
||||
sink += read(index % size).length
|
||||
},
|
||||
consume: () => sink,
|
||||
}
|
||||
}
|
||||
|
||||
function json(row: Row) {
|
||||
if (row.type === "message") return JSON.stringify([row.type, row.messageID])
|
||||
if (row.type === "part") return JSON.stringify([row.type, row.messageID, row.partID])
|
||||
return JSON.stringify([row.type, row.kind, row.messageID, row.partID])
|
||||
}
|
||||
|
||||
function concatenate(row: Row) {
|
||||
if (row.type === "message") return `m${row.messageID.length}:${row.messageID}`
|
||||
if (row.type === "part") return `p${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}`
|
||||
return `g${row.kind === "reasoning" ? "r" : "e"}${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}`
|
||||
}
|
||||
|
||||
console.log(`Session row key benchmark (${size.toLocaleString()} rows, ${bench.samples} samples)\n`)
|
||||
|
||||
const result = bench.compare(iterations, [
|
||||
{ name: "JSON tuple key", make: () => workload((index) => json(rows[index])) },
|
||||
{ name: "Concatenated key", make: () => workload((index) => concatenate(rows[index])) },
|
||||
{ name: "Precomputed key", make: () => workload((index) => precomputed[index].id) },
|
||||
])
|
||||
|
||||
console.log("\nRatios to JSON tuple (lower is faster)")
|
||||
console.log(`Concatenated: ${result.ratio(1, 0).toFixed(3)}x`)
|
||||
console.log(`Precomputed: ${result.ratio(2, 0).toFixed(3)}x`)
|
||||
console.log(`METRIC concatenated_key_ratio=${result.ratio(1, 0).toFixed(6)}`)
|
||||
console.log(`METRIC precomputed_key_ratio=${result.ratio(2, 0).toFixed(6)}`)
|
||||
bench.finish()
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/quark",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./solid": "./src/solid.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"bench:keyed": "bun --conditions=browser bench/keyed.ts",
|
||||
"bench:row-key": "bun --conditions=browser bench/row-key.ts",
|
||||
"test": "bun --conditions=browser test"
|
||||
},
|
||||
"dependencies": {
|
||||
"solid-js": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Keyed } from "./keyed"
|
||||
export { Layout } from "./layout"
|
||||
export { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"
|
||||
|
||||
export namespace Keyed {
|
||||
export type Position<Key> = "end" | { readonly before: Key } | { readonly after: Key }
|
||||
|
||||
export interface Metrics {
|
||||
slotPublications: number
|
||||
structuralPublications: number
|
||||
equivalenceSuppressions: number
|
||||
}
|
||||
|
||||
/** Read surface of a keyed collection; hand this to consumers that must not mutate. */
|
||||
export interface ReadOnly<A, Key> {
|
||||
readonly slots: Readable<readonly Readable<A>[]>
|
||||
readonly values: Readable<readonly A[]>
|
||||
has(key: Key): boolean
|
||||
get(key: Key): Readable<A> | undefined
|
||||
before(key: Key): Readable<A> | undefined
|
||||
after(key: Key): Readable<A> | undefined
|
||||
}
|
||||
|
||||
export interface Keyed<A, Key> extends ReadOnly<A, Key> {
|
||||
set(values: readonly A[]): boolean
|
||||
update(value: A): boolean
|
||||
modify(key: Key, f: (value: A) => A): boolean
|
||||
insert(value: A, position?: Position<Key>): Readable<A>
|
||||
remove(key: Key): boolean
|
||||
move(key: Key, position?: Position<Key>): boolean
|
||||
}
|
||||
|
||||
export function make<A, Key>(options: {
|
||||
readonly key: (value: A) => Key
|
||||
readonly equivalent?: (left: A, right: A) => boolean
|
||||
readonly metrics?: Metrics
|
||||
}): Keyed<A, Key> {
|
||||
const slots = State.make<readonly Writable<A>[]>([])
|
||||
const byKey = new Map<Key, Writable<A>>()
|
||||
const equivalent = options.equivalent ?? Object.is
|
||||
const values = Computed.make<readonly A[]>((previous) => {
|
||||
const next = slots().map((slot) => slot())
|
||||
return same(previous, next) ? previous! : next
|
||||
})
|
||||
|
||||
return {
|
||||
slots,
|
||||
values,
|
||||
has: (key) => byKey.has(key),
|
||||
get: (key) => byKey.get(key),
|
||||
set(next) {
|
||||
const keys = next.map(options.key)
|
||||
const retained = new Set(keys)
|
||||
if (retained.size !== keys.length) throw new Error("Keyed values must have unique keys")
|
||||
|
||||
return Transaction.run(() => {
|
||||
let changed = false
|
||||
const previous = slots()
|
||||
const reconciled = next.map((value, index) => {
|
||||
const key = keys[index]
|
||||
const slot = byKey.get(key)
|
||||
if (!slot) {
|
||||
const created = State.make(value)
|
||||
byKey.set(key, created)
|
||||
return created
|
||||
}
|
||||
if (publish(slot, value)) changed = true
|
||||
return slot
|
||||
})
|
||||
// After reconciliation byKey is a superset of retained; equal sizes
|
||||
// mean no stale keys and the sweep can be skipped.
|
||||
if (byKey.size !== retained.size)
|
||||
byKey.forEach((_slot, key) => {
|
||||
if (!retained.has(key)) byKey.delete(key)
|
||||
})
|
||||
if (!same(previous, reconciled)) {
|
||||
slots.set(reconciled)
|
||||
if (options.metrics) options.metrics.structuralPublications++
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
})
|
||||
},
|
||||
update(value) {
|
||||
const slot = byKey.get(options.key(value))
|
||||
if (!slot) return false
|
||||
return publish(slot, value)
|
||||
},
|
||||
modify(key, f) {
|
||||
const slot = byKey.get(key)
|
||||
if (!slot) return false
|
||||
const value = f(slot())
|
||||
if (byKey.get(options.key(value)) !== slot) throw new Error("Keyed modify must preserve the value key")
|
||||
return publish(slot, value)
|
||||
},
|
||||
insert(value, position) {
|
||||
const key = options.key(value)
|
||||
if (byKey.has(key)) throw new Error(`Keyed value already exists: ${String(key)}`)
|
||||
const current = slots()
|
||||
const index = positionIndex(current, position)
|
||||
const slot = State.make(value)
|
||||
// byKey is not reactive and slots.set is a single publication, so no
|
||||
// transaction is required here; callers batch when they need to.
|
||||
byKey.set(key, slot)
|
||||
slots.set(current.toSpliced(index, 0, slot))
|
||||
if (options.metrics) options.metrics.structuralPublications++
|
||||
return slot
|
||||
},
|
||||
remove(key) {
|
||||
const slot = byKey.get(key)
|
||||
if (!slot) return false
|
||||
byKey.delete(key)
|
||||
slots.set(slots().filter((candidate) => candidate !== slot))
|
||||
if (options.metrics) options.metrics.structuralPublications++
|
||||
return true
|
||||
},
|
||||
move(key, position) {
|
||||
const slot = byKey.get(key)
|
||||
if (!slot) return false
|
||||
const current = slots()
|
||||
const from = current.indexOf(slot)
|
||||
const target = positionIndex(current, position)
|
||||
const to = from < target ? target - 1 : target
|
||||
if (from === to) return false
|
||||
const next = current.slice()
|
||||
next.splice(from, 1)
|
||||
next.splice(to, 0, slot)
|
||||
slots.set(next)
|
||||
if (options.metrics) options.metrics.structuralPublications++
|
||||
return true
|
||||
},
|
||||
before(key) {
|
||||
return neighbor(key, -1)
|
||||
},
|
||||
after(key) {
|
||||
return neighbor(key, 1)
|
||||
},
|
||||
}
|
||||
|
||||
function publish(slot: Writable<A>, value: A) {
|
||||
const current = slot()
|
||||
// The reactive kernel uses strict identity for primitive writes.
|
||||
if (current === value || equivalent(current, value)) {
|
||||
if (options.metrics) options.metrics.equivalenceSuppressions++
|
||||
return false
|
||||
}
|
||||
slot.set(value)
|
||||
if (options.metrics) options.metrics.slotPublications++
|
||||
return true
|
||||
}
|
||||
|
||||
function positionIndex(current: readonly Writable<A>[], position?: Position<Key>) {
|
||||
if (position === undefined || position === "end") return current.length
|
||||
if ("before" in position) return indexOf(current, position.before)
|
||||
return indexOf(current, position.after) + 1
|
||||
}
|
||||
|
||||
function indexOf(current: readonly Writable<A>[], key: Key) {
|
||||
const target = byKey.get(key)
|
||||
if (!target) throw new Error(`Keyed value does not exist: ${String(key)}`)
|
||||
return current.indexOf(target)
|
||||
}
|
||||
|
||||
function neighbor(key: Key, offset: -1 | 1) {
|
||||
const slot = byKey.get(key)
|
||||
if (!slot) return undefined
|
||||
const current = slots()
|
||||
return current[current.indexOf(slot) + offset]
|
||||
}
|
||||
}
|
||||
|
||||
export function metrics(): Metrics {
|
||||
return { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 }
|
||||
}
|
||||
|
||||
function same<A>(left: readonly A[] | undefined, right: readonly A[]) {
|
||||
return left?.length === right.length && left.every((value, index) => Object.is(value, right[index]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
import { Keyed } from "./keyed"
|
||||
import { Computed, State, Transaction, type Readable } from "./reactivity"
|
||||
|
||||
export namespace Layout {
|
||||
export interface Field<A> {
|
||||
readonly isKey?: true
|
||||
readonly primitive?: true
|
||||
readonly immutable?: true
|
||||
equivalent(left: A, right: A): boolean
|
||||
}
|
||||
|
||||
export interface KeyField<A> extends Field<A> {
|
||||
readonly isKey: true
|
||||
}
|
||||
|
||||
export interface NamedKey<Name extends PropertyKey, A> {
|
||||
readonly name: Name
|
||||
readonly field: Field<A>
|
||||
}
|
||||
|
||||
export type Type<Field> = Field extends Layout.Field<infer A> ? A : never
|
||||
|
||||
type Fields = Readonly<Record<PropertyKey, Field<unknown>>>
|
||||
type Value<StructFields> = {
|
||||
readonly [Key in keyof StructFields]: Type<StructFields[Key]>
|
||||
}
|
||||
type KeyName<StructFields> = {
|
||||
readonly [Key in keyof StructFields]: StructFields[Key] extends KeyField<unknown> ? Key : never
|
||||
}[keyof StructFields]
|
||||
type Variant<Tag extends PropertyKey, Variants> = {
|
||||
readonly [Name in keyof Variants & string]: {
|
||||
readonly [Key in Tag]: Name
|
||||
} & Type<Variants[Name]>
|
||||
}[keyof Variants & string]
|
||||
type KeyedVariant<Name extends PropertyKey, A, Tag extends PropertyKey, Variants> = {
|
||||
readonly [Key in Name]: A
|
||||
} & Variant<Tag, Variants>
|
||||
|
||||
export interface Struct<StructFields extends Fields> extends Field<Value<StructFields>> {
|
||||
readonly type: "struct"
|
||||
readonly fields: StructFields
|
||||
}
|
||||
|
||||
export interface Union<Tag extends PropertyKey, Variants extends Readonly<Record<string, Field<unknown>>>>
|
||||
extends Field<Variant<Tag, Variants>> {
|
||||
readonly type: "union"
|
||||
readonly tag: Tag
|
||||
readonly variants: Variants
|
||||
}
|
||||
|
||||
export interface KeyedUnion<
|
||||
Name extends PropertyKey,
|
||||
A,
|
||||
Tag extends PropertyKey,
|
||||
Variants extends Readonly<Record<string, Field<unknown>>>,
|
||||
> extends Field<KeyedVariant<Name, A, Tag, Variants>> {
|
||||
readonly type: "keyed-union"
|
||||
readonly key: NamedKey<Name, A>
|
||||
readonly tag: Tag
|
||||
readonly variants: Variants
|
||||
}
|
||||
|
||||
export interface Plan<A, Key> {
|
||||
readonly key: PropertyKey
|
||||
readonly fields?: Fields
|
||||
readonly equivalent: (left: A, right: A) => boolean
|
||||
/** Bitmask of changed top-level fields; 0 means equivalent. Consistent with `equivalent`. */
|
||||
readonly diff: (left: A, right: A) => number
|
||||
/** Every known top-level property name to its change bit; immutable and key names map to 0. */
|
||||
readonly bits: ReadonlyMap<PropertyKey, number>
|
||||
readonly keyOf: (value: A) => Key
|
||||
make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Keyed.Keyed<A, Key>
|
||||
}
|
||||
|
||||
type IndexField<A> = A extends unknown ? keyof A : never
|
||||
|
||||
export interface MembersIndex<A, Member> {
|
||||
readonly type: "members"
|
||||
readonly fields?: readonly IndexField<A>[]
|
||||
readonly extract: (value: A) => Iterable<Member>
|
||||
}
|
||||
|
||||
export interface FirstIndex<A> {
|
||||
readonly type: "first"
|
||||
readonly fields?: readonly IndexField<A>[]
|
||||
readonly matches: (value: A) => boolean
|
||||
}
|
||||
|
||||
export type Index<A> = MembersIndex<A, unknown> | FirstIndex<A>
|
||||
type Indexes<A> = Readonly<Record<PropertyKey, Index<A>>>
|
||||
type MembersNames<Definitions> = {
|
||||
readonly [Name in keyof Definitions]: Definitions[Name] extends {
|
||||
readonly type: "members"
|
||||
}
|
||||
? Name
|
||||
: never
|
||||
}[keyof Definitions]
|
||||
type FirstNames<Definitions> = {
|
||||
readonly [Name in keyof Definitions]: Definitions[Name] extends {
|
||||
readonly type: "first"
|
||||
}
|
||||
? Name
|
||||
: never
|
||||
}[keyof Definitions]
|
||||
type Member<Definition> = Definition extends {
|
||||
readonly extract: (value: never) => Iterable<infer A>
|
||||
}
|
||||
? A
|
||||
: never
|
||||
type MemberChanges<Definitions> = {
|
||||
readonly [Name in MembersNames<Definitions>]?: {
|
||||
readonly add?: readonly Member<Definitions[Name]>[]
|
||||
readonly remove?: readonly Member<Definitions[Name]>[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator backend. "generated" (the default) compiles one specialized
|
||||
* Function per plan operation; "closure" is the compatibility fallback for
|
||||
* environments that forbid runtime code generation (CSP).
|
||||
*/
|
||||
export interface CompileOptions {
|
||||
readonly backend?: "closure" | "generated"
|
||||
}
|
||||
|
||||
export interface IndexBuilder<A> {
|
||||
members<Member>(extract: (value: A) => Iterable<Member>): MembersIndex<A, Member>
|
||||
members<Member>(fields: readonly IndexField<A>[], extract: (value: A) => Iterable<Member>): MembersIndex<A, Member>
|
||||
first(matches: (value: A) => boolean): FirstIndex<A>
|
||||
first(fields: readonly IndexField<A>[], matches: (value: A) => boolean): FirstIndex<A>
|
||||
}
|
||||
|
||||
export interface Collection<A, Key, Definitions extends Indexes<A>> extends Keyed.Keyed<A, Key> {
|
||||
modify(key: Key, f: (value: A) => A, changes?: { readonly members?: MemberChanges<Definitions> }): boolean
|
||||
hasMember<Name extends MembersNames<Definitions>>(name: Name, member: Member<Definitions[Name]>): boolean
|
||||
/** Reactive first match of the named index; publishes when the first match changes identity or value. */
|
||||
first<Name extends FirstNames<Definitions>>(name: Name): Readable<A | undefined>
|
||||
}
|
||||
|
||||
export interface CollectionPlan<A, Key, Definitions extends Indexes<A>> extends Plan<A, Key> {
|
||||
make(initial?: readonly A[], options?: { readonly metrics?: Keyed.Metrics }): Collection<A, Key, Definitions>
|
||||
}
|
||||
|
||||
export const string: Field<string> = primitive()
|
||||
export const number: Field<number> = primitive()
|
||||
export const boolean: Field<boolean> = primitive()
|
||||
|
||||
interface ArrayField<A> extends Field<readonly A[]> {
|
||||
readonly type: "array"
|
||||
readonly item: Field<A>
|
||||
}
|
||||
|
||||
export function array<A>(item: Field<A>): Field<readonly A[]> {
|
||||
const field: ArrayField<A> = {
|
||||
type: "array",
|
||||
item,
|
||||
equivalent(left, right) {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
if (!item.equivalent(left[index], right[index])) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
export function immutable<A>(field: Field<A>): Field<A> {
|
||||
return { ...field, immutable: true, equivalent: () => true }
|
||||
}
|
||||
|
||||
export function key<A>(field: Field<A>): KeyField<A>
|
||||
export function key<const Name extends PropertyKey, A>(name: Name, field: Field<A>): NamedKey<Name, A>
|
||||
export function key<A>(name: Field<A> | PropertyKey, field?: Field<A>): KeyField<A> | NamedKey<PropertyKey, A> {
|
||||
if (field) return { name: name as PropertyKey, field }
|
||||
return { ...(name as Field<A>), isKey: true }
|
||||
}
|
||||
|
||||
export function struct<const StructFields extends Fields>(fields: StructFields): Struct<StructFields> {
|
||||
return {
|
||||
type: "struct",
|
||||
fields,
|
||||
equivalent: compileFields<Value<StructFields>>(fields),
|
||||
}
|
||||
}
|
||||
|
||||
export function union<
|
||||
const Tag extends PropertyKey,
|
||||
const Variants extends Readonly<Record<string, Field<unknown>>>,
|
||||
>(options: { readonly tag: Tag; readonly variants: Variants }): Union<Tag, Variants> {
|
||||
const equivalent = compileUnion<Tag, Variants>(options.tag, options.variants)
|
||||
return { type: "union", ...options, equivalent }
|
||||
}
|
||||
|
||||
export function keyedUnion<
|
||||
const Name extends PropertyKey,
|
||||
A,
|
||||
const Tag extends PropertyKey,
|
||||
const Variants extends Readonly<Record<string, Field<unknown>>>,
|
||||
>(options: {
|
||||
readonly key: NamedKey<Name, A>
|
||||
readonly tag: Tag
|
||||
readonly variants: Variants
|
||||
}): KeyedUnion<Name, A, Tag, Variants> {
|
||||
const equivalentVariant = compileUnion<Tag, Variants>(options.tag, options.variants)
|
||||
const key = (value: KeyedVariant<Name, A, Tag, Variants>) => value[options.key.name]
|
||||
const equivalent = (left: KeyedVariant<Name, A, Tag, Variants>, right: KeyedVariant<Name, A, Tag, Variants>) =>
|
||||
options.key.field.equivalent(key(left), key(right)) && equivalentVariant(left, right)
|
||||
return { type: "keyed-union", ...options, equivalent }
|
||||
}
|
||||
|
||||
export function compile<const StructFields extends Fields>(
|
||||
layout: Struct<StructFields>,
|
||||
options?: CompileOptions,
|
||||
): Plan<Value<StructFields>, Value<StructFields>[KeyName<StructFields>]>
|
||||
export function compile<
|
||||
const Name extends PropertyKey,
|
||||
A,
|
||||
const Tag extends PropertyKey,
|
||||
const Variants extends Readonly<Record<string, Field<unknown>>>,
|
||||
>(layout: KeyedUnion<Name, A, Tag, Variants>, options?: CompileOptions): Plan<KeyedVariant<Name, A, Tag, Variants>, A>
|
||||
export function compile(input: unknown, options: CompileOptions = {}): unknown {
|
||||
const layout = input as
|
||||
| Struct<Fields>
|
||||
| KeyedUnion<PropertyKey, unknown, PropertyKey, Readonly<Record<string, Field<unknown>>>>
|
||||
if (layout.type === "keyed-union") {
|
||||
const model = unionDiffModel(layout.key.name, layout.tag, layout.variants)
|
||||
return makePlan(
|
||||
layout.key.name,
|
||||
(value: unknown) => (value as Record<PropertyKey, unknown>)[layout.key.name],
|
||||
(options.backend !== "closure"
|
||||
? generateUnion(layout.tag, layout.variants)
|
||||
: compileUnion(layout.tag, layout.variants)) as (left: unknown, right: unknown) => boolean,
|
||||
options.backend !== "closure"
|
||||
? generateUnionDiff(layout.tag, layout.variants, model)
|
||||
: compileUnionDiff(layout.tag, layout.variants, model),
|
||||
model.bits,
|
||||
)
|
||||
}
|
||||
|
||||
const keys = Reflect.ownKeys(layout.fields).filter((name) => layout.fields[name].isKey)
|
||||
if (keys.length !== 1) throw new Error("Keyed layout must declare exactly one key field")
|
||||
const key = keys[0]
|
||||
const fields = Reflect.ownKeys(layout.fields)
|
||||
.filter((name) => name !== key && !layout.fields[name].immutable)
|
||||
.map((name, index) => ({ name, field: layout.fields[name], bit: 1 << Math.min(index, 30) }))
|
||||
const bits = new Map<PropertyKey, number>()
|
||||
Reflect.ownKeys(layout.fields).forEach((name) => bits.set(name, 0))
|
||||
fields.forEach((field) => bits.set(field.name, field.bit))
|
||||
const equivalent =
|
||||
options.backend !== "closure" ? generateEquivalent<unknown>(fields) : compileEquivalent<unknown>(fields)
|
||||
const diff = options.backend !== "closure" ? generateDiff<unknown>(fields) : compileDiff<unknown>(fields)
|
||||
return {
|
||||
fields: layout.fields,
|
||||
...makePlan(key, (value: unknown) => (value as Record<PropertyKey, unknown>)[key], equivalent, diff, bits),
|
||||
}
|
||||
}
|
||||
|
||||
export function collection<const StructFields extends Fields, const Definitions extends Indexes<Value<StructFields>>>(
|
||||
layout: Struct<StructFields>,
|
||||
define: (index: IndexBuilder<Value<StructFields>>) => Definitions,
|
||||
options?: CompileOptions,
|
||||
): CollectionPlan<Value<StructFields>, Value<StructFields>[KeyName<StructFields>], Definitions>
|
||||
export function collection<
|
||||
const Name extends PropertyKey,
|
||||
A,
|
||||
const Tag extends PropertyKey,
|
||||
const Variants extends Readonly<Record<string, Field<unknown>>>,
|
||||
const Definitions extends Indexes<KeyedVariant<Name, A, Tag, Variants>>,
|
||||
>(
|
||||
layout: KeyedUnion<Name, A, Tag, Variants>,
|
||||
define: (index: IndexBuilder<KeyedVariant<Name, A, Tag, Variants>>) => Definitions,
|
||||
options?: CompileOptions,
|
||||
): CollectionPlan<KeyedVariant<Name, A, Tag, Variants>, A, Definitions>
|
||||
export function collection(input: unknown, define: unknown, options?: CompileOptions): unknown {
|
||||
const plan = compile(input as never, options) as Plan<unknown, unknown>
|
||||
const definitions = (define as (index: IndexBuilder<unknown>) => Indexes<unknown>)({
|
||||
members: ((
|
||||
fieldsOrExtract: readonly PropertyKey[] | ((value: unknown) => Iterable<unknown>),
|
||||
extract?: (value: unknown) => Iterable<unknown>,
|
||||
) =>
|
||||
typeof fieldsOrExtract === "function"
|
||||
? { type: "members", extract: fieldsOrExtract }
|
||||
: { type: "members", fields: fieldsOrExtract, extract: extract! }) as IndexBuilder<unknown>["members"],
|
||||
first: ((
|
||||
fieldsOrMatches: readonly PropertyKey[] | ((value: unknown) => boolean),
|
||||
matches?: (value: unknown) => boolean,
|
||||
) =>
|
||||
typeof fieldsOrMatches === "function"
|
||||
? { type: "first", matches: fieldsOrMatches }
|
||||
: { type: "first", fields: fieldsOrMatches, matches: matches! }) as IndexBuilder<unknown>["first"],
|
||||
})
|
||||
return {
|
||||
...plan,
|
||||
make(initial: readonly unknown[] = [], makeOptions?: { readonly metrics?: Keyed.Metrics }) {
|
||||
return makeCollection(plan, definitions, initial, makeOptions)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit-target collection compiler for keyed-union layouts whose derived
|
||||
* value type intentionally under-describes the real one (e.g. reference
|
||||
* fields typed `unknown`, optional fields omitted). The layout's key name,
|
||||
* key type, and tag values are checked against `Target`; field-level shapes
|
||||
* are trusted at this single boundary.
|
||||
*/
|
||||
export function collectionOf<Target>() {
|
||||
return function <
|
||||
const Name extends keyof Target & PropertyKey,
|
||||
const Tag extends keyof Target & PropertyKey,
|
||||
const Variants extends Readonly<Record<Extract<Target[Tag], string>, Field<unknown>>>,
|
||||
const Definitions extends Indexes<Target>,
|
||||
>(
|
||||
layout: KeyedUnion<Name, Target[Name], Tag, Variants>,
|
||||
define: (index: IndexBuilder<Target>) => Definitions,
|
||||
options?: CompileOptions,
|
||||
): CollectionPlan<Target, Target[Name], Definitions> {
|
||||
return collection(layout as never, define as never, options) as CollectionPlan<Target, Target[Name], Definitions>
|
||||
}
|
||||
}
|
||||
|
||||
function makePlan<A, Key>(
|
||||
key: PropertyKey,
|
||||
getKey: (value: A) => Key,
|
||||
equivalent: (left: A, right: A) => boolean,
|
||||
diff: (left: A, right: A) => number,
|
||||
bits: ReadonlyMap<PropertyKey, number>,
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
equivalent,
|
||||
diff,
|
||||
bits,
|
||||
keyOf: getKey,
|
||||
make(initial: readonly A[] = [], options?: { readonly metrics?: Keyed.Metrics }) {
|
||||
const values = Keyed.make({
|
||||
key: getKey,
|
||||
equivalent,
|
||||
metrics: options?.metrics,
|
||||
})
|
||||
values.set(initial)
|
||||
return values
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeCollection<A, Key, Definitions extends Indexes<A>>(
|
||||
plan: Plan<A, Key>,
|
||||
definitions: Definitions,
|
||||
initial: readonly A[],
|
||||
options?: { readonly metrics?: Keyed.Metrics },
|
||||
): Collection<A, Key, Definitions> {
|
||||
// `pending` hands a diff the collection already computed for a staged
|
||||
// mutation to the comparator, so equivalence costs one comparison, not two.
|
||||
let pending: { readonly previous: A; readonly next: A; readonly mask: number } | undefined
|
||||
const values = Keyed.make<A, Key>({
|
||||
key: plan.keyOf,
|
||||
equivalent(left, right) {
|
||||
if (pending && pending.previous === left && pending.next === right) {
|
||||
const mask = pending.mask
|
||||
pending = undefined
|
||||
return mask === 0
|
||||
}
|
||||
return plan.diff(left, right) === 0
|
||||
},
|
||||
metrics: options?.metrics,
|
||||
})
|
||||
|
||||
function dependencyMask(fields: readonly PropertyKey[] | undefined) {
|
||||
return fields?.reduce<number>((mask, field) => mask | (plan.bits.get(normalizeName(field)) ?? -1), 0) ?? -1
|
||||
}
|
||||
|
||||
// Each index entry stages user-code projections before any state mutation
|
||||
// (a throwing callback must not desynchronize values and indexes) and
|
||||
// returns a commit applied after publication succeeds.
|
||||
type Commit = (slot: Readable<A>) => void
|
||||
type MemberChange = { readonly add?: readonly unknown[]; readonly remove?: readonly unknown[] }
|
||||
interface Entry {
|
||||
readonly name: PropertyKey
|
||||
readonly reads: number
|
||||
stage(key: Key, value: A, mode: "add" | "replace"): Commit
|
||||
applyDelta?(key: Key, change: MemberChange): void
|
||||
remove(key: Key, slot?: Readable<A>): void
|
||||
afterPlacement?(slot: Readable<A>): void
|
||||
afterRebuild?(): void
|
||||
member?(candidate: unknown): boolean
|
||||
readonly first?: Readable<A | undefined>
|
||||
}
|
||||
|
||||
function membersEntry(
|
||||
name: PropertyKey,
|
||||
fields: readonly PropertyKey[] | undefined,
|
||||
extract: (value: A) => Iterable<unknown>,
|
||||
): Entry {
|
||||
const counts = new Map<unknown, number>()
|
||||
const byKey = new Map<Key, { readonly source: Iterable<unknown>; readonly members: Set<unknown> }>()
|
||||
|
||||
function adjust(member: unknown, amount: 1 | -1) {
|
||||
const count = (counts.get(member) ?? 0) + amount
|
||||
if (count === 0) {
|
||||
counts.delete(member)
|
||||
return
|
||||
}
|
||||
counts.set(member, count)
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
reads: dependencyMask(fields),
|
||||
stage(key, value) {
|
||||
const previous = byKey.get(key)
|
||||
const source = extract(value)
|
||||
const members = source === previous?.source ? previous.members : new Set(source)
|
||||
return () => {
|
||||
if (!previous) members.forEach((member) => adjust(member, 1))
|
||||
if (previous && previous.members !== members) {
|
||||
members.forEach((member) => !previous.members.has(member) && adjust(member, 1))
|
||||
previous.members.forEach((member) => !members.has(member) && adjust(member, -1))
|
||||
}
|
||||
byKey.set(key, { source, members })
|
||||
}
|
||||
},
|
||||
applyDelta(key, change) {
|
||||
const members = byKey.get(key)!.members
|
||||
change.remove?.forEach((member) => {
|
||||
if (members.delete(member)) adjust(member, -1)
|
||||
})
|
||||
change.add?.forEach((member) => {
|
||||
if (members.has(member)) return
|
||||
members.add(member)
|
||||
adjust(member, 1)
|
||||
})
|
||||
// The set was mutated in place, so poison the source-identity check.
|
||||
byKey.set(key, { source: members, members })
|
||||
},
|
||||
remove(key) {
|
||||
byKey.get(key)?.members.forEach((member) => adjust(member, -1))
|
||||
byKey.delete(key)
|
||||
},
|
||||
member: (candidate) => counts.has(candidate),
|
||||
}
|
||||
}
|
||||
|
||||
function firstEntry(
|
||||
name: PropertyKey,
|
||||
fields: readonly PropertyKey[] | undefined,
|
||||
matches: (value: A) => boolean,
|
||||
): Entry {
|
||||
const matching = new Set<Key>()
|
||||
// The current first-matching slot is reactive state so `first` readers
|
||||
// observe identity changes; the flattening computed below also tracks
|
||||
// the slot itself, so value changes of the first match publish too.
|
||||
const slot = State.make<Readable<A> | undefined>(undefined)
|
||||
const first = Computed.make<A | undefined>(() => slot()?.())
|
||||
|
||||
function findFirst() {
|
||||
slot.set(values.slots().find((candidate) => matching.has(plan.keyOf(candidate()))))
|
||||
}
|
||||
|
||||
function afterPlacement(candidate: Readable<A>) {
|
||||
if (!matching.has(plan.keyOf(candidate()))) return
|
||||
const current = slot()
|
||||
if (!current) {
|
||||
slot.set(candidate)
|
||||
return
|
||||
}
|
||||
if (current === candidate) {
|
||||
findFirst()
|
||||
return
|
||||
}
|
||||
// Single pass: whichever of the two slots appears first wins.
|
||||
for (const item of values.slots()) {
|
||||
if (item === candidate) {
|
||||
slot.set(candidate)
|
||||
return
|
||||
}
|
||||
if (item === current) return
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
reads: dependencyMask(fields),
|
||||
stage(key, value, mode) {
|
||||
const matched = matches(value)
|
||||
return (valueSlot) => {
|
||||
const previous = matching.has(key)
|
||||
if (matched) matching.add(key)
|
||||
if (!matched) matching.delete(key)
|
||||
if (mode === "add") return
|
||||
if (slot() === valueSlot && !matched) {
|
||||
findFirst()
|
||||
return
|
||||
}
|
||||
if (slot() !== valueSlot && !previous && matched) afterPlacement(valueSlot)
|
||||
}
|
||||
},
|
||||
remove(key, removedSlot) {
|
||||
matching.delete(key)
|
||||
if (removedSlot && slot() === removedSlot) findFirst()
|
||||
},
|
||||
afterPlacement,
|
||||
afterRebuild: findFirst,
|
||||
first,
|
||||
}
|
||||
}
|
||||
|
||||
const entries: Entry[] = Reflect.ownKeys(definitions).map((name) => {
|
||||
const definition = definitions[name]
|
||||
if (definition.type === "members") return membersEntry(name, definition.fields, definition.extract)
|
||||
return firstEntry(name, definition.fields, definition.matches)
|
||||
})
|
||||
const byName = new Map(entries.map((entry) => [entry.name, entry]))
|
||||
|
||||
// Stage refresh work for one changed value: explicit member deltas win,
|
||||
// then projections whose declared fields are disjoint from the change are
|
||||
// skipped entirely. Returns undefined when no index work is required.
|
||||
function stageRefresh(key: Key, value: A, mask: number, changes?: unknown) {
|
||||
const record = changes as Readonly<Record<PropertyKey, MemberChange | undefined>> | undefined
|
||||
let staged: Commit[] | undefined
|
||||
for (const entry of entries) {
|
||||
const change = record?.[entry.name]
|
||||
if (change && entry.applyDelta) {
|
||||
const delta = entry.applyDelta
|
||||
;(staged ??= []).push(() => delta(key, change))
|
||||
continue
|
||||
}
|
||||
if ((mask & entry.reads) === 0) continue
|
||||
;(staged ??= []).push(entry.stage(key, value, "replace"))
|
||||
}
|
||||
return staged
|
||||
}
|
||||
|
||||
function publishStaged(
|
||||
previous: A,
|
||||
value: A,
|
||||
mask: number,
|
||||
staged: readonly Commit[] | undefined,
|
||||
slot: Readable<A>,
|
||||
) {
|
||||
// Indexes are plain data, not reactive state; the transaction exists so
|
||||
// subscribers cannot observe published values with stale indexes. With
|
||||
// no staged commits there is nothing to observe out of order.
|
||||
if (!staged) return publishValue()
|
||||
return Transaction.run(() => {
|
||||
const changed = publishValue()
|
||||
if (changed) staged.forEach((commit) => commit(slot))
|
||||
return changed
|
||||
})
|
||||
|
||||
function publishValue() {
|
||||
pending = { previous, next: value, mask }
|
||||
try {
|
||||
return values.update(value)
|
||||
} finally {
|
||||
pending = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const collection: Collection<A, Key, Definitions> = {
|
||||
...values,
|
||||
set(next) {
|
||||
const keys = next.map(plan.keyOf)
|
||||
const retained = new Set(keys)
|
||||
if (retained.size !== keys.length) throw new Error("Keyed values must have unique keys")
|
||||
const existing = new Map<Key, A>()
|
||||
values.slots().forEach((slot) => {
|
||||
const value = slot()
|
||||
existing.set(plan.keyOf(value), value)
|
||||
})
|
||||
// Stage user-code projections for new and changed values only;
|
||||
// retained equivalent values keep their index state and reads.
|
||||
const staged: Array<{ readonly key: Key; readonly commits: readonly Commit[] }> = []
|
||||
next.forEach((value, index) => {
|
||||
const key = keys[index]
|
||||
if (!existing.has(key)) {
|
||||
staged.push({ key, commits: entries.map((entry) => entry.stage(key, value, "add")) })
|
||||
return
|
||||
}
|
||||
const previous = existing.get(key)!
|
||||
if (previous === value) return
|
||||
const mask = plan.diff(previous, value)
|
||||
if (mask === 0) return
|
||||
const commits = stageRefresh(key, value, mask)
|
||||
if (commits) staged.push({ key, commits })
|
||||
})
|
||||
const structure = values.slots()
|
||||
return Transaction.run(() => {
|
||||
const changed = values.set(next)
|
||||
if (!changed) return false
|
||||
const structural = structure !== values.slots()
|
||||
if (structural)
|
||||
existing.forEach((_value, key) => {
|
||||
if (!retained.has(key)) entries.forEach((entry) => entry.remove(key))
|
||||
})
|
||||
staged.forEach((item) => {
|
||||
const slot = values.get(item.key)!
|
||||
item.commits.forEach((commit) => commit(slot))
|
||||
})
|
||||
if (structural) entries.forEach((entry) => entry.afterRebuild?.())
|
||||
return true
|
||||
})
|
||||
},
|
||||
update(value) {
|
||||
const key = plan.keyOf(value)
|
||||
const slot = values.get(key)
|
||||
if (!slot) return false
|
||||
const previous = slot()
|
||||
if (previous === value) return false
|
||||
const mask = plan.diff(previous, value)
|
||||
const staged = mask === 0 ? undefined : stageRefresh(key, value, mask)
|
||||
return publishStaged(previous, value, mask, staged, slot)
|
||||
},
|
||||
modify(key, f, changes) {
|
||||
const slot = values.get(key)
|
||||
if (!slot) return false
|
||||
const previous = slot()
|
||||
const value = f(previous)
|
||||
// Publication below targets the slot for the value's own key, so the
|
||||
// modify key-preservation invariant must be enforced here.
|
||||
if (values.get(plan.keyOf(value)) !== slot) throw new Error("Keyed modify must preserve the value key")
|
||||
if (previous === value) return false
|
||||
const mask = plan.diff(previous, value)
|
||||
const staged = mask === 0 ? undefined : stageRefresh(key, value, mask, changes?.members)
|
||||
return publishStaged(previous, value, mask, staged, slot)
|
||||
},
|
||||
insert(value, position) {
|
||||
const key = plan.keyOf(value)
|
||||
if (values.has(key)) throw new Error(`Keyed value already exists: ${String(key)}`)
|
||||
requirePosition(position)
|
||||
const staged = entries.map((entry) => entry.stage(key, value, "add"))
|
||||
return Transaction.run(() => {
|
||||
const slot = values.insert(value, position)
|
||||
staged.forEach((commit) => commit(slot))
|
||||
entries.forEach((entry) => entry.afterPlacement?.(slot))
|
||||
return slot
|
||||
})
|
||||
},
|
||||
remove(key) {
|
||||
const slot = values.get(key)
|
||||
if (!slot) return false
|
||||
return Transaction.run(() => {
|
||||
const removed = values.remove(key)
|
||||
entries.forEach((entry) => entry.remove(key, slot))
|
||||
return removed
|
||||
})
|
||||
},
|
||||
move(key, position) {
|
||||
const slot = values.get(key)
|
||||
if (!slot) return false
|
||||
return Transaction.run(() => {
|
||||
const moved = values.move(key, position)
|
||||
if (moved) entries.forEach((entry) => entry.afterPlacement?.(slot))
|
||||
return moved
|
||||
})
|
||||
},
|
||||
hasMember(name, member) {
|
||||
return byName.get(normalizeName(name))?.member?.(member) ?? false
|
||||
},
|
||||
first(name) {
|
||||
const entry = byName.get(normalizeName(name))
|
||||
if (!entry?.first) throw new Error(`Unknown first index: ${String(name)}`)
|
||||
return entry.first
|
||||
},
|
||||
}
|
||||
collection.set(initial)
|
||||
return collection
|
||||
|
||||
function requirePosition(position?: Keyed.Position<Key>) {
|
||||
if (!position || position === "end") return
|
||||
const key = "before" in position ? position.before : position.after
|
||||
if (!values.has(key)) throw new Error(`Keyed value does not exist: ${String(key)}`)
|
||||
}
|
||||
|
||||
function normalizeName(name: PropertyKey) {
|
||||
return typeof name === "number" ? String(name) : name
|
||||
}
|
||||
}
|
||||
|
||||
function primitive<A>(): Field<A> {
|
||||
return { primitive: true, equivalent: Object.is }
|
||||
}
|
||||
|
||||
function compileUnion<Tag extends PropertyKey, Variants extends Readonly<Record<string, Field<unknown>>>>(
|
||||
tag: Tag,
|
||||
variants: Variants,
|
||||
) {
|
||||
type A = Variant<Tag, Variants>
|
||||
return (left: A, right: A) => {
|
||||
const name = left[tag]
|
||||
if (name !== right[tag] || typeof name !== "string") return false
|
||||
const variant = variants[name]
|
||||
return variant ? variant.equivalent(left, right) : false
|
||||
}
|
||||
}
|
||||
|
||||
function compileFields<A>(fields: Fields) {
|
||||
return compileEquivalent<A>(
|
||||
Reflect.ownKeys(fields)
|
||||
.filter((name) => !fields[name].immutable)
|
||||
.map((name) => ({ name, field: fields[name] })),
|
||||
)
|
||||
}
|
||||
|
||||
// Field diff: a bitmask of changed top-level fields, 0 meaning equivalent.
|
||||
// Bits are assigned per mutable field name; names beyond 31 share the top
|
||||
// bit conservatively. The diff is consistent with `equivalent` by
|
||||
// construction: both compare the same fields with the same field comparators.
|
||||
type DiffField = { readonly name: PropertyKey; readonly field: Field<unknown>; readonly bit: number }
|
||||
|
||||
function compileDiff<A>(fields: ReadonlyArray<DiffField>) {
|
||||
return (left: A, right: A) => {
|
||||
const a = left as Record<PropertyKey, unknown>
|
||||
const b = right as Record<PropertyKey, unknown>
|
||||
let changed = 0
|
||||
for (const field of fields) if (!field.field.equivalent(a[field.name], b[field.name])) changed |= field.bit
|
||||
return changed
|
||||
}
|
||||
}
|
||||
|
||||
function generateDiff<A>(fields: ReadonlyArray<DiffField>) {
|
||||
if (fields.some((field) => typeof field.name === "symbol")) return compileDiff<A>(fields)
|
||||
const emitter: Emitter = { custom: [], declarations: [], names: new Map() }
|
||||
const statements = fields.map((field) => {
|
||||
const property = JSON.stringify(String(field.name))
|
||||
return `if (!(${expression(emitter, field.field, `left[${property}]`, `right[${property}]`)})) changed |= ${field.bit}`
|
||||
})
|
||||
const factory = Function(
|
||||
"custom",
|
||||
`${emitter.declarations.join("\n")}\nreturn (left, right) => { let changed = 0\n${statements.join("\n")}\nreturn changed }`,
|
||||
) as (custom: ReadonlyArray<Field<unknown>>) => (left: A, right: A) => number
|
||||
return factory(emitter.custom)
|
||||
}
|
||||
|
||||
type UnionDiffModel = {
|
||||
readonly bits: ReadonlyMap<PropertyKey, number>
|
||||
readonly perVariant: ReadonlyMap<string, ReadonlyArray<DiffField> | undefined>
|
||||
}
|
||||
|
||||
function unionDiffModel(
|
||||
keyName: PropertyKey,
|
||||
tag: PropertyKey,
|
||||
variants: Readonly<Record<string, Field<unknown>>>,
|
||||
): UnionDiffModel {
|
||||
const bits = new Map<PropertyKey, number>()
|
||||
bits.set(tag, 1)
|
||||
bits.set(keyName, 0)
|
||||
const assigned = new Map<PropertyKey, number>()
|
||||
const perVariant = new Map<string, ReadonlyArray<DiffField> | undefined>()
|
||||
let count = 0
|
||||
for (const variantName of Object.keys(variants)) {
|
||||
const variant = variants[variantName] as Field<unknown> & { readonly type?: string; readonly fields?: Fields }
|
||||
if (variant.type !== "struct") {
|
||||
perVariant.set(variantName, undefined)
|
||||
continue
|
||||
}
|
||||
const fields: DiffField[] = []
|
||||
for (const name of Reflect.ownKeys(variant.fields!)) {
|
||||
const field = variant.fields![name]
|
||||
if (field.immutable) {
|
||||
if (!assigned.has(name) && !bits.has(name)) bits.set(name, 0)
|
||||
continue
|
||||
}
|
||||
const bit = assigned.get(name) ?? 1 << Math.min(count++ + 1, 30)
|
||||
assigned.set(name, bit)
|
||||
bits.set(name, bit)
|
||||
fields.push({ name, field, bit })
|
||||
}
|
||||
perVariant.set(variantName, fields)
|
||||
}
|
||||
return { bits, perVariant }
|
||||
}
|
||||
|
||||
function compileUnionDiff(
|
||||
tag: PropertyKey,
|
||||
variants: Readonly<Record<string, Field<unknown>>>,
|
||||
model: UnionDiffModel,
|
||||
) {
|
||||
return (left: unknown, right: unknown) => {
|
||||
const a = left as Record<PropertyKey, unknown>
|
||||
const b = right as Record<PropertyKey, unknown>
|
||||
const name = a[tag]
|
||||
if (name !== b[tag] || typeof name !== "string") return -1
|
||||
const fields = model.perVariant.get(name)
|
||||
if (!fields) {
|
||||
const variant = variants[name]
|
||||
return variant && variant.equivalent(left, right) ? 0 : -1
|
||||
}
|
||||
let changed = 0
|
||||
for (const field of fields) if (!field.field.equivalent(a[field.name], b[field.name])) changed |= field.bit
|
||||
return changed
|
||||
}
|
||||
}
|
||||
|
||||
function generateUnionDiff(
|
||||
tag: PropertyKey,
|
||||
variants: Readonly<Record<string, Field<unknown>>>,
|
||||
model: UnionDiffModel,
|
||||
) {
|
||||
const symbols = [...model.perVariant.values()].some((fields) =>
|
||||
fields?.some((field) => typeof field.name === "symbol"),
|
||||
)
|
||||
if (typeof tag === "symbol" || symbols) return compileUnionDiff(tag, variants, model)
|
||||
const emitter: Emitter = { custom: [], declarations: [], names: new Map() }
|
||||
const property = JSON.stringify(String(tag))
|
||||
const cases = Object.keys(variants).map((name) => {
|
||||
const label = JSON.stringify(name)
|
||||
const fields = model.perVariant.get(name)
|
||||
if (!fields) {
|
||||
const variant = variants[name]
|
||||
const index = emitter.custom.push(variant) - 1
|
||||
return `case ${label}: return custom[${index}].equivalent(left, right) ? 0 : -1`
|
||||
}
|
||||
const statements = fields.map((field) => {
|
||||
const fieldProperty = JSON.stringify(String(field.name))
|
||||
return `if (!(${expression(emitter, field.field, `left[${fieldProperty}]`, `right[${fieldProperty}]`)})) changed |= ${field.bit}`
|
||||
})
|
||||
return `case ${label}: { let changed = 0\n${statements.join("\n")}\nreturn changed }`
|
||||
})
|
||||
const factory = Function(
|
||||
"custom",
|
||||
`${emitter.declarations.join("\n")}\nreturn (left, right) => { if (left[${property}] !== right[${property}]) return -1; switch (left[${property}]) { ${cases.join("\n")}\ndefault: return -1 } }`,
|
||||
) as (custom: ReadonlyArray<Field<unknown>>) => (left: unknown, right: unknown) => number
|
||||
return factory(emitter.custom)
|
||||
}
|
||||
|
||||
// Whole-tree generation: one Function() per plan operation whose source
|
||||
// inlines struct comparisons, emits real loops for arrays, and hoists unions
|
||||
// into named inner functions the engine can inline. User-supplied equivalence
|
||||
// functions remain indirect calls through the `custom` array; everything
|
||||
// structural compiles to direct code with no interior closure boundaries.
|
||||
type Emitter = {
|
||||
readonly custom: Field<unknown>[]
|
||||
readonly declarations: string[]
|
||||
readonly names: Map<unknown, string>
|
||||
}
|
||||
|
||||
function generateEquivalent<A>(
|
||||
fields: ReadonlyArray<{
|
||||
readonly name: PropertyKey
|
||||
readonly field: Field<unknown>
|
||||
}>,
|
||||
) {
|
||||
// Symbol names cannot appear in generated source; degrade to the closure backend.
|
||||
if (fields.some((field) => typeof field.name === "symbol")) return compileEquivalent<A>(fields)
|
||||
const emitter: Emitter = { custom: [], declarations: [], names: new Map() }
|
||||
const comparisons = fields
|
||||
.map((field) => {
|
||||
const property = JSON.stringify(String(field.name))
|
||||
return expression(emitter, field.field, `left[${property}]`, `right[${property}]`)
|
||||
})
|
||||
.filter((comparison) => comparison !== "true")
|
||||
return assemble<A>(emitter, comparisons.join(" && ") || "true")
|
||||
}
|
||||
|
||||
function generateUnion<Tag extends PropertyKey, Variants extends Readonly<Record<string, Field<unknown>>>>(
|
||||
tag: Tag,
|
||||
variants: Variants,
|
||||
) {
|
||||
if (typeof tag === "symbol") return compileUnion(tag, variants)
|
||||
const emitter: Emitter = { custom: [], declarations: [], names: new Map() }
|
||||
const root = declareUnion(emitter, { tag, variants }, tag, variants)
|
||||
return assemble<Variant<Tag, Variants>>(emitter, `${root}(left, right)`)
|
||||
}
|
||||
|
||||
function assemble<A>(emitter: Emitter, body: string) {
|
||||
const factory = Function("custom", `${emitter.declarations.join("\n")}\nreturn (left, right) => ${body}`) as (
|
||||
custom: ReadonlyArray<Field<unknown>>,
|
||||
) => (left: A, right: A) => boolean
|
||||
return factory(emitter.custom)
|
||||
}
|
||||
|
||||
function expression(emitter: Emitter, field: Field<unknown>, left: string, right: string): string {
|
||||
if (field.immutable) return "true"
|
||||
if (field.primitive && field.equivalent === Object.is) return `Object.is(${left}, ${right})`
|
||||
const layout = field as Field<unknown> & {
|
||||
readonly type?: "struct" | "union" | "keyed-union" | "array"
|
||||
readonly fields?: Fields
|
||||
readonly item?: Field<unknown>
|
||||
readonly tag?: PropertyKey
|
||||
readonly variants?: Readonly<Record<string, Field<unknown>>>
|
||||
readonly key?: NamedKey<PropertyKey, unknown>
|
||||
}
|
||||
if (layout.type === "struct" && Reflect.ownKeys(layout.fields!).every((name) => typeof name !== "symbol")) {
|
||||
const comparisons = Reflect.ownKeys(layout.fields!)
|
||||
.filter((name) => !layout.fields![name].immutable)
|
||||
.map((name) => {
|
||||
const property = JSON.stringify(String(name))
|
||||
return expression(emitter, layout.fields![name], `${left}[${property}]`, `${right}[${property}]`)
|
||||
})
|
||||
.filter((comparison) => comparison !== "true")
|
||||
if (comparisons.length === 0) return "true"
|
||||
// Shared-reference fast path: immutable updates reuse untouched sub-objects.
|
||||
return `(${left} === ${right} || (${comparisons.join(" && ")}))`
|
||||
}
|
||||
if (layout.type === "array") {
|
||||
const name = declare(emitter, layout, (fn) => {
|
||||
const item = expression(emitter, layout.item!, "l[i]", "r[i]")
|
||||
return `function ${fn}(l, r) { if (l === r) return true; if (l.length !== r.length) return false; for (let i = 0; i < l.length; i++) if (!(${item})) return false; return true }`
|
||||
})
|
||||
return `${name}(${left}, ${right})`
|
||||
}
|
||||
if (layout.type === "union" && typeof layout.tag !== "symbol") {
|
||||
return `${declareUnion(emitter, layout, layout.tag!, layout.variants!)}(${left}, ${right})`
|
||||
}
|
||||
if (layout.type === "keyed-union" && typeof layout.tag !== "symbol" && typeof layout.key!.name !== "symbol") {
|
||||
const name = declare(emitter, layout, (fn) => {
|
||||
const property = JSON.stringify(String(layout.key!.name))
|
||||
const key = expression(emitter, layout.key!.field, `l[${property}]`, `r[${property}]`)
|
||||
const union = declareUnion(emitter, {}, layout.tag!, layout.variants!)
|
||||
return `function ${fn}(l, r) { return ${key === "true" ? "" : `${key} && `}${union}(l, r) }`
|
||||
})
|
||||
return `${name}(${left}, ${right})`
|
||||
}
|
||||
const index = emitter.custom.push(field) - 1
|
||||
return `custom[${index}].equivalent(${left}, ${right})`
|
||||
}
|
||||
|
||||
function declareUnion(
|
||||
emitter: Emitter,
|
||||
identity: unknown,
|
||||
tag: PropertyKey,
|
||||
variants: Readonly<Record<string, Field<unknown>>>,
|
||||
) {
|
||||
return declare(emitter, identity, (fn) => {
|
||||
const property = JSON.stringify(String(tag))
|
||||
const cases = Object.keys(variants)
|
||||
.map((name) => `case ${JSON.stringify(name)}: return ${expression(emitter, variants[name], "l", "r")}`)
|
||||
.join("; ")
|
||||
return `function ${fn}(l, r) { if (l[${property}] !== r[${property}]) return false; switch (l[${property}]) { ${cases}; default: return false } }`
|
||||
})
|
||||
}
|
||||
|
||||
function declare(emitter: Emitter, identity: unknown, build: (name: string) => string) {
|
||||
const existing = emitter.names.get(identity)
|
||||
if (existing) return existing
|
||||
const name = `q${emitter.names.size}`
|
||||
emitter.names.set(identity, name)
|
||||
// Reserve declaration order before build runs: build may recurse into
|
||||
// declare for nested layouts, and the reserved slot keeps this function
|
||||
// textually before its dependents without infinite recursion.
|
||||
const index = emitter.declarations.push("") - 1
|
||||
emitter.declarations[index] = build(name)
|
||||
return name
|
||||
}
|
||||
|
||||
// Compatibility comparator for the closure backend: a simple monomorphic
|
||||
// loop. The generated backend is the performance path.
|
||||
function compileEquivalent<A>(
|
||||
fields: ReadonlyArray<{
|
||||
readonly name: PropertyKey
|
||||
readonly field: Field<unknown>
|
||||
}>,
|
||||
) {
|
||||
if (fields.length === 0) return (_left: A, _right: A) => true
|
||||
return (left: A, right: A) => {
|
||||
const a = left as Record<PropertyKey, unknown>
|
||||
const b = right as Record<PropertyKey, unknown>
|
||||
for (const field of fields) if (!field.field.equivalent(a[field.name], b[field.name])) return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* A callable reactive value.
|
||||
*
|
||||
* `subscribe`, `set`, and `update` are shared this-based methods, not
|
||||
* per-instance closures: invoke them as methods (`readable.subscribe(f)`) or
|
||||
* bind before detaching (`readable.subscribe.bind(readable)`). A detached
|
||||
* bare reference loses its node and throws.
|
||||
*/
|
||||
export interface Readable<A> {
|
||||
(): A
|
||||
subscribe(listener: (value: A) => void): () => void
|
||||
}
|
||||
|
||||
export interface Writable<A> extends Readable<A> {
|
||||
set(value: A): void
|
||||
update(f: (value: A) => A): void
|
||||
}
|
||||
|
||||
export namespace State {
|
||||
export function make<A>(initial: A): Writable<A> {
|
||||
const node: StateNode<A> = {
|
||||
flags: Flags.Mutable,
|
||||
value: initial,
|
||||
pending: initial,
|
||||
deps: undefined,
|
||||
depsTail: undefined,
|
||||
subs: undefined,
|
||||
subsTail: undefined,
|
||||
}
|
||||
// Methods are shared this-based functions rather than per-instance
|
||||
// closures: one callable and one node per state, and every call site
|
||||
// stays monomorphic on the shared method identity.
|
||||
const read = (() => readState(node)) as Writable<A> & Handle<StateNode<A>>
|
||||
read.node = node
|
||||
read.set = stateSet
|
||||
read.update = stateUpdate
|
||||
read.subscribe = sharedSubscribe
|
||||
return read
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Computed {
|
||||
export function make<A>(evaluate: (previous: A | undefined) => A): Readable<A> {
|
||||
const node: ComputedNode<A> = {
|
||||
flags: Flags.None,
|
||||
value: undefined,
|
||||
evaluate,
|
||||
deps: undefined,
|
||||
depsTail: undefined,
|
||||
subs: undefined,
|
||||
subsTail: undefined,
|
||||
}
|
||||
const read = (() => readComputed(node)) as Readable<A> & Handle<ComputedNode<A>>
|
||||
read.node = node
|
||||
read.subscribe = sharedSubscribe
|
||||
return read
|
||||
}
|
||||
}
|
||||
|
||||
interface Handle<Node> {
|
||||
node: Node
|
||||
}
|
||||
|
||||
function stateSet<A>(this: Handle<StateNode<A>>, value: A): void {
|
||||
writeState(this.node, value)
|
||||
}
|
||||
|
||||
function stateUpdate<A>(this: Handle<StateNode<A>>, f: (value: A) => A): void {
|
||||
// Read untracked: calling update inside a tracked evaluation must not
|
||||
// make the caller depend on (and re-trigger from) this state.
|
||||
const previous = swapActiveSub(undefined)
|
||||
try {
|
||||
writeState(this.node, f(readState(this.node)))
|
||||
} finally {
|
||||
activeSub = previous
|
||||
}
|
||||
}
|
||||
|
||||
function sharedSubscribe<A>(this: (() => A) & Handle<ReactiveNode>, listener: (value: A) => void): () => void {
|
||||
return subscribeNode(this.node, this, listener)
|
||||
}
|
||||
|
||||
export namespace Transaction {
|
||||
export function run<A>(f: () => A): A {
|
||||
batchDepth++
|
||||
try {
|
||||
return f()
|
||||
} finally {
|
||||
if (!--batchDepth) flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Internal reactive kernel.
|
||||
//
|
||||
// The dependency graph representation (intrusive doubly-linked dependency and
|
||||
// subscriber links with versioned in-place reuse) and the iterative
|
||||
// `propagate`/`checkDirty` walks are adapted from alien-signals 3.2.1
|
||||
// (https://github.com/stackblitz/alien-signals, MIT). See
|
||||
// THIRD_PARTY_NOTICES.md. Quark departs from the reference in three ways:
|
||||
// subscribers are fixed single-dependency watchers instead of general
|
||||
// effects, orphaned computeds are detached with an iterative work queue
|
||||
// instead of recursive unwatch callbacks, and reading a computed that is
|
||||
// currently evaluating throws a stable cycle error instead of looping
|
||||
// (stackblitz/alien-signals#118, #123).
|
||||
|
||||
const enum Flags {
|
||||
None = 0,
|
||||
/** The node can produce a new value: states always, computeds once evaluated. */
|
||||
Mutable = 1,
|
||||
/** The node is a subscription watcher delivering values to a listener. */
|
||||
Watching = 2,
|
||||
/** The computed is currently evaluating; reading it again is a cycle. */
|
||||
Computing = 4,
|
||||
/** The watcher is queued for the next flush. */
|
||||
Queued = 8,
|
||||
/** The node's value is known stale. */
|
||||
Dirty = 16,
|
||||
/** The node's value is possibly stale pending dependency revalidation. */
|
||||
Pending = 32,
|
||||
}
|
||||
|
||||
interface ReactiveNode {
|
||||
flags: Flags
|
||||
deps?: Link
|
||||
depsTail?: Link
|
||||
subs?: Link
|
||||
subsTail?: Link
|
||||
}
|
||||
|
||||
interface StateNode<A = unknown> extends ReactiveNode {
|
||||
value: A
|
||||
pending: A
|
||||
}
|
||||
|
||||
interface ComputedNode<A = unknown> extends ReactiveNode {
|
||||
value: A | undefined
|
||||
evaluate: (previous: A | undefined) => A
|
||||
}
|
||||
|
||||
interface WatcherNode extends ReactiveNode {
|
||||
read: () => unknown
|
||||
listener: (value: unknown) => void
|
||||
}
|
||||
|
||||
interface Link {
|
||||
version: number
|
||||
dep: ReactiveNode
|
||||
sub: ReactiveNode
|
||||
prevSub: Link | undefined
|
||||
nextSub: Link | undefined
|
||||
prevDep: Link | undefined
|
||||
nextDep: Link | undefined
|
||||
}
|
||||
|
||||
interface Stack<A> {
|
||||
value: A
|
||||
prev: Stack<A> | undefined
|
||||
}
|
||||
|
||||
let activeSub: ReactiveNode | undefined
|
||||
let batchDepth = 0
|
||||
let version = 0
|
||||
let flushIndex = 0
|
||||
let queueLength = 0
|
||||
const queue: Array<WatcherNode | undefined> = []
|
||||
const orphans: Array<ReactiveNode> = []
|
||||
|
||||
function swapActiveSub(sub: ReactiveNode | undefined): ReactiveNode | undefined {
|
||||
const previous = activeSub
|
||||
activeSub = sub
|
||||
return previous
|
||||
}
|
||||
|
||||
function readState<A>(node: StateNode<A>): A {
|
||||
if (node.flags & Flags.Dirty && updateState(node) && node.subs !== undefined) {
|
||||
shallowPropagate(node.subs)
|
||||
}
|
||||
if (activeSub !== undefined) link(node, activeSub, version)
|
||||
return node.value
|
||||
}
|
||||
|
||||
function writeState<A>(node: StateNode<A>, value: A): void {
|
||||
if (node.pending === (node.pending = value)) return
|
||||
node.flags = Flags.Mutable | Flags.Dirty
|
||||
if (node.subs !== undefined) {
|
||||
propagate(node.subs)
|
||||
if (!batchDepth) flush()
|
||||
}
|
||||
}
|
||||
|
||||
function readComputed<A>(node: ComputedNode<A>): A {
|
||||
const flags = node.flags
|
||||
if (flags & Flags.Computing) {
|
||||
throw new Error("Reactive cycle detected: a computed depends on its own value")
|
||||
}
|
||||
if (
|
||||
flags & Flags.Dirty ||
|
||||
(flags & Flags.Pending && (checkDirty(node.deps!, node) || ((node.flags = flags & ~Flags.Pending), false))) ||
|
||||
!flags
|
||||
) {
|
||||
if (updateComputed(node) && node.subs !== undefined) {
|
||||
shallowPropagate(node.subs)
|
||||
}
|
||||
}
|
||||
if (activeSub !== undefined) link(node, activeSub, version)
|
||||
return node.value!
|
||||
}
|
||||
|
||||
function updateState(node: StateNode): boolean {
|
||||
node.flags = Flags.Mutable
|
||||
return node.value !== (node.value = node.pending)
|
||||
}
|
||||
|
||||
function updateComputed<A>(node: ComputedNode<A>): boolean {
|
||||
node.depsTail = undefined
|
||||
node.flags = Flags.Mutable | Flags.Computing
|
||||
const previous = swapActiveSub(node)
|
||||
version++
|
||||
let completed = false
|
||||
try {
|
||||
const oldValue = node.value
|
||||
const changed = oldValue !== (node.value = node.evaluate(oldValue))
|
||||
completed = true
|
||||
return changed
|
||||
} finally {
|
||||
activeSub = previous
|
||||
node.flags &= ~Flags.Computing
|
||||
// A throwing evaluation stays dirty so the next read retries instead of
|
||||
// serving a stale value with no dependency links.
|
||||
if (!completed) node.flags |= Flags.Dirty
|
||||
purgeDeps(node)
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeNode<A>(node: ReactiveNode, read: () => A, listener: (value: A) => void): () => void {
|
||||
// Evaluate untracked before linking so a throwing computed leaves no
|
||||
// partially initialized watcher behind.
|
||||
const previous = swapActiveSub(undefined)
|
||||
try {
|
||||
read()
|
||||
} finally {
|
||||
activeSub = previous
|
||||
}
|
||||
const watcher: WatcherNode = {
|
||||
flags: Flags.Watching,
|
||||
read,
|
||||
listener: listener as (value: unknown) => void,
|
||||
deps: undefined,
|
||||
depsTail: undefined,
|
||||
}
|
||||
link(node, watcher, ++version)
|
||||
return () => {
|
||||
if (!(watcher.flags & Flags.Watching)) return
|
||||
watcher.flags &= ~(Flags.Watching | Flags.Dirty | Flags.Pending)
|
||||
unlink(watcher.deps!, watcher)
|
||||
drainOrphans()
|
||||
}
|
||||
}
|
||||
|
||||
function runWatcher(watcher: WatcherNode): void {
|
||||
const flags = watcher.flags
|
||||
watcher.flags = flags & ~(Flags.Queued | Flags.Dirty | Flags.Pending)
|
||||
if (!(flags & Flags.Watching)) return
|
||||
const dirty = !!(flags & Flags.Dirty) || (!!(flags & Flags.Pending) && checkDirty(watcher.deps!, watcher))
|
||||
// Revalidation runs user code in computed evaluations, which may dispose
|
||||
// this watcher; a disposed watcher must not deliver.
|
||||
if (!dirty || !(watcher.flags & Flags.Watching)) return
|
||||
// Listener reads stay untracked and listeners may dispose subscriptions,
|
||||
// including their own.
|
||||
const previous = swapActiveSub(undefined)
|
||||
try {
|
||||
watcher.listener(watcher.read())
|
||||
} finally {
|
||||
activeSub = previous
|
||||
}
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
try {
|
||||
while (flushIndex < queueLength) {
|
||||
const watcher = queue[flushIndex]!
|
||||
queue[flushIndex++] = undefined
|
||||
runWatcher(watcher)
|
||||
}
|
||||
} finally {
|
||||
// A throwing listener aborts this flush; the remaining watchers keep
|
||||
// their Dirty/Pending flags and requeue on the next propagation.
|
||||
while (flushIndex < queueLength) {
|
||||
const watcher = queue[flushIndex]!
|
||||
queue[flushIndex++] = undefined
|
||||
watcher.flags &= ~Flags.Queued
|
||||
}
|
||||
flushIndex = 0
|
||||
queueLength = 0
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(watcher: WatcherNode): void {
|
||||
watcher.flags |= Flags.Queued
|
||||
queue[queueLength++] = watcher
|
||||
}
|
||||
|
||||
function link(dep: ReactiveNode, sub: ReactiveNode, linkVersion: number): void {
|
||||
const prevDep = sub.depsTail
|
||||
if (prevDep !== undefined && prevDep.dep === dep) return
|
||||
const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps
|
||||
if (nextDep !== undefined && nextDep.dep === dep) {
|
||||
nextDep.version = linkVersion
|
||||
sub.depsTail = nextDep
|
||||
return
|
||||
}
|
||||
const prevSub = dep.subsTail
|
||||
if (prevSub !== undefined && prevSub.version === linkVersion && prevSub.sub === sub) return
|
||||
const newLink: Link = {
|
||||
version: linkVersion,
|
||||
dep,
|
||||
sub,
|
||||
prevDep,
|
||||
nextDep,
|
||||
prevSub,
|
||||
nextSub: undefined,
|
||||
}
|
||||
sub.depsTail = newLink
|
||||
dep.subsTail = newLink
|
||||
if (nextDep !== undefined) nextDep.prevDep = newLink
|
||||
if (prevDep !== undefined) prevDep.nextDep = newLink
|
||||
else sub.deps = newLink
|
||||
if (prevSub !== undefined) prevSub.nextSub = newLink
|
||||
else dep.subs = newLink
|
||||
}
|
||||
|
||||
function unlink(current: Link, sub: ReactiveNode): Link | undefined {
|
||||
const dep = current.dep
|
||||
const prevDep = current.prevDep
|
||||
const nextDep = current.nextDep
|
||||
const nextSub = current.nextSub
|
||||
const prevSub = current.prevSub
|
||||
if (nextDep !== undefined) nextDep.prevDep = prevDep
|
||||
else sub.depsTail = prevDep
|
||||
if (prevDep !== undefined) prevDep.nextDep = nextDep
|
||||
else sub.deps = nextDep
|
||||
if (nextSub !== undefined) nextSub.prevSub = prevSub
|
||||
else dep.subsTail = prevSub
|
||||
if (prevSub !== undefined) prevSub.nextSub = nextSub
|
||||
else if ((dep.subs = nextSub) === undefined && dep.deps !== undefined) orphans.push(dep)
|
||||
return nextDep
|
||||
}
|
||||
|
||||
// Detaches computeds that lost their last subscriber. Iterative on purpose:
|
||||
// a recursive unwatch cascade overflows the stack on deep chains. Only nodes
|
||||
// with dependencies enter the queue (states and dep-less computeds have
|
||||
// nothing to detach). As in the reference, a computed that is read but never
|
||||
// subscribed stays linked to its sources until they are collected; create
|
||||
// long-lived computeds rather than per-operation ones.
|
||||
function drainOrphans(): void {
|
||||
while (orphans.length > 0) {
|
||||
const node = orphans.pop()!
|
||||
if (!("evaluate" in node) || node.depsTail === undefined) continue
|
||||
node.flags = Flags.Mutable | Flags.Dirty
|
||||
let current = node.depsTail as Link | undefined
|
||||
while (current !== undefined) {
|
||||
const prev = current.prevDep
|
||||
unlink(current, node)
|
||||
current = prev
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function purgeDeps(sub: ReactiveNode): void {
|
||||
const depsTail = sub.depsTail
|
||||
let dep = depsTail !== undefined ? depsTail.nextDep : sub.deps
|
||||
while (dep !== undefined) {
|
||||
dep = unlink(dep, sub)
|
||||
}
|
||||
drainOrphans()
|
||||
}
|
||||
|
||||
function propagate(current: Link): void {
|
||||
let next = current.nextSub
|
||||
let stack: Stack<Link | undefined> | undefined
|
||||
|
||||
top: do {
|
||||
const sub = current.sub
|
||||
const flags = sub.flags
|
||||
const unmarked = !(flags & (Flags.Dirty | Flags.Pending))
|
||||
if (unmarked) sub.flags = flags | Flags.Pending
|
||||
if (flags & Flags.Watching && !(flags & Flags.Queued)) enqueue(sub as WatcherNode)
|
||||
|
||||
if (unmarked && flags & Flags.Mutable) {
|
||||
const subSubs = sub.subs
|
||||
if (subSubs !== undefined) {
|
||||
current = subSubs
|
||||
const nextSub = subSubs.nextSub
|
||||
if (nextSub !== undefined) {
|
||||
stack = { value: next, prev: stack }
|
||||
next = nextSub
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (next !== undefined) {
|
||||
current = next
|
||||
next = current.nextSub
|
||||
continue
|
||||
}
|
||||
|
||||
while (stack !== undefined) {
|
||||
const continuation = stack.value
|
||||
stack = stack.prev
|
||||
if (continuation !== undefined) {
|
||||
current = continuation
|
||||
next = current.nextSub
|
||||
continue top
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
} while (true)
|
||||
}
|
||||
|
||||
function shallowPropagate(current: Link): void {
|
||||
let iterator: Link | undefined = current
|
||||
do {
|
||||
const sub = iterator.sub
|
||||
const flags = sub.flags
|
||||
if ((flags & (Flags.Pending | Flags.Dirty)) === Flags.Pending) {
|
||||
sub.flags = flags | Flags.Dirty
|
||||
if (flags & Flags.Watching && !(flags & Flags.Queued)) enqueue(sub as WatcherNode)
|
||||
}
|
||||
iterator = iterator.nextSub
|
||||
} while (iterator !== undefined)
|
||||
}
|
||||
|
||||
function updateNode(node: ReactiveNode): boolean {
|
||||
if ("evaluate" in node) return updateComputed(node as ComputedNode)
|
||||
return updateState(node as StateNode)
|
||||
}
|
||||
|
||||
function checkDirty(current: Link, sub: ReactiveNode): boolean {
|
||||
let stack: Stack<Link> | undefined
|
||||
let checkDepth = 0
|
||||
let dirty = false
|
||||
|
||||
top: do {
|
||||
const dep = current.dep
|
||||
const flags = dep.flags
|
||||
|
||||
if (sub.flags & Flags.Dirty) {
|
||||
dirty = true
|
||||
} else if ((flags & (Flags.Mutable | Flags.Dirty)) === (Flags.Mutable | Flags.Dirty)) {
|
||||
const subs = dep.subs!
|
||||
if (updateNode(dep)) {
|
||||
if (subs.nextSub !== undefined) shallowPropagate(subs)
|
||||
dirty = true
|
||||
}
|
||||
} else if ((flags & (Flags.Mutable | Flags.Pending)) === (Flags.Mutable | Flags.Pending)) {
|
||||
stack = { value: current, prev: stack }
|
||||
current = dep.deps!
|
||||
sub = dep
|
||||
checkDepth++
|
||||
continue
|
||||
}
|
||||
|
||||
if (!dirty) {
|
||||
const nextDep = current.nextDep
|
||||
if (nextDep !== undefined) {
|
||||
current = nextDep
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
while (checkDepth--) {
|
||||
current = stack!.value
|
||||
stack = stack!.prev
|
||||
if (dirty) {
|
||||
const subs = sub.subs!
|
||||
if (updateNode(sub)) {
|
||||
if (subs.nextSub !== undefined) shallowPropagate(subs)
|
||||
sub = current.sub
|
||||
continue
|
||||
}
|
||||
dirty = false
|
||||
} else {
|
||||
sub.flags &= ~Flags.Pending
|
||||
}
|
||||
sub = current.sub
|
||||
const nextDep = current.nextDep
|
||||
if (nextDep !== undefined) {
|
||||
current = nextDep
|
||||
continue top
|
||||
}
|
||||
}
|
||||
|
||||
return dirty
|
||||
} while (true)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createMemo, For, from, type Accessor, type JSX } from "solid-js"
|
||||
import type { Keyed } from "./keyed"
|
||||
import type { Readable } from "./reactivity"
|
||||
|
||||
export function useValue<A>(readable: Readable<A>): Accessor<A> {
|
||||
return from(readable, readable())
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive accessor for one keyed slot. Structural changes re-resolve the
|
||||
* slot, while memo equality prevents an unchanged slot from propagating to the
|
||||
* consumer. Value changes flow through the slot itself.
|
||||
*
|
||||
* The key is usually constant and may be passed plainly; pass an accessor when
|
||||
* the key itself is reactive. (A key that is itself a function value is
|
||||
* indistinguishable from an accessor and must be wrapped.)
|
||||
*/
|
||||
export function useSlot<A, Key>(keyed: Keyed.ReadOnly<A, Key>, key: Key | (() => Key)): Accessor<A | undefined> {
|
||||
const resolve = typeof key === "function" ? (key as () => Key) : () => key
|
||||
const structure = useValue(keyed.slots)
|
||||
const slot = createMemo(() => {
|
||||
structure()
|
||||
return keyed.get(resolve())
|
||||
})
|
||||
const value = createMemo(() => {
|
||||
const current = slot()
|
||||
return current ? useValue(current) : undefined
|
||||
})
|
||||
return () => value()?.()
|
||||
}
|
||||
|
||||
export function KeyedFor<A>(props: {
|
||||
readonly each: Accessor<readonly Readable<A>[]>
|
||||
readonly fallback?: JSX.Element
|
||||
readonly children: (value: Accessor<A>, index: Accessor<number>) => JSX.Element
|
||||
}) {
|
||||
return For({
|
||||
get each() {
|
||||
return props.each()
|
||||
},
|
||||
get fallback() {
|
||||
return props.fallback
|
||||
},
|
||||
children: (slot, index) => props.children(useValue(slot), index),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { Keyed } from "../src/keyed"
|
||||
import { Computed } from "../src/reactivity"
|
||||
|
||||
interface Item {
|
||||
readonly id: number
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
const item = (id: number, label: string): Item => ({ id, label })
|
||||
|
||||
describe("Keyed", () => {
|
||||
it("keeps slot identity across value updates and reorders", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two"), item(3, "three")])
|
||||
const [one, two, three] = keyed.slots()
|
||||
|
||||
keyed.set([item(3, "THREE"), item(1, "ONE"), item(2, "TWO")])
|
||||
|
||||
expect(keyed.slots()).toEqual([three, one, two])
|
||||
expect(keyed.slots()[0]).toBe(three)
|
||||
expect(keyed.slots()[1]).toBe(one)
|
||||
expect(keyed.slots()[2]).toBe(two)
|
||||
expect(one()).toEqual(item(1, "ONE"))
|
||||
expect(two()).toEqual(item(2, "TWO"))
|
||||
expect(three()).toEqual(item(3, "THREE"))
|
||||
})
|
||||
|
||||
it("does not publish structural changes for value-only updates or identical sets", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two")])
|
||||
const initial = keyed.slots()
|
||||
const structures: Array<readonly number[]> = []
|
||||
const dispose = keyed.slots.subscribe((slots) => structures.push(slots.map((slot) => slot().id)))
|
||||
|
||||
keyed.set([item(1, "ONE"), item(2, "TWO")])
|
||||
expect(keyed.slots()).toBe(initial)
|
||||
|
||||
keyed.set([item(1, "ONE"), item(2, "TWO")])
|
||||
expect(keyed.slots()).toBe(initial)
|
||||
|
||||
keyed.set([item(2, "TWO"), item(1, "ONE")])
|
||||
|
||||
expect(structures).toEqual([[2, 1]])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("reacts to aggregate value updates while preserving unchanged aggregate snapshots", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
const one = item(1, "one")
|
||||
const two = item(2, "two")
|
||||
keyed.set([one, two])
|
||||
const initial = keyed.values()
|
||||
const snapshots: Array<readonly Item[]> = []
|
||||
const dispose = keyed.values.subscribe((values) => snapshots.push(values))
|
||||
|
||||
keyed.set([one, item(2, "TWO")])
|
||||
const updated = keyed.values()
|
||||
expect(updated).not.toBe(initial)
|
||||
expect(updated).toEqual([one, item(2, "TWO")])
|
||||
|
||||
keyed.set([one, updated[1]])
|
||||
|
||||
expect(keyed.values()).toBe(updated)
|
||||
expect(snapshots).toEqual([[one, item(2, "TWO")]])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("uses custom equivalence to cut off slot and aggregate updates", () => {
|
||||
const keyed = Keyed.make<Item, number>({
|
||||
key: (value) => value.id,
|
||||
equivalent: (left, right) => left.id === right.id && left.label.toLowerCase() === right.label.toLowerCase(),
|
||||
})
|
||||
const original = item(1, "one")
|
||||
keyed.set([original])
|
||||
const slot = keyed.slots()[0]
|
||||
const aggregate = keyed.values()
|
||||
const slotValues: Item[] = []
|
||||
const aggregateValues: Array<readonly Item[]> = []
|
||||
const disposeSlot = slot.subscribe((value) => slotValues.push(value))
|
||||
const disposeAggregate = keyed.values.subscribe((values) => aggregateValues.push(values))
|
||||
|
||||
keyed.set([item(1, "ONE")])
|
||||
|
||||
expect(slot()).toBe(original)
|
||||
expect(keyed.values()).toBe(aggregate)
|
||||
expect(slotValues).toEqual([])
|
||||
expect(aggregateValues).toEqual([])
|
||||
|
||||
keyed.set([item(1, "changed")])
|
||||
|
||||
expect(slot()).toEqual(item(1, "changed"))
|
||||
expect(slotValues).toEqual([item(1, "changed")])
|
||||
expect(aggregateValues).toEqual([[item(1, "changed")]])
|
||||
disposeSlot()
|
||||
disposeAggregate()
|
||||
})
|
||||
|
||||
it("uses SameValueZero semantics for primitive slot values", () => {
|
||||
const keyed = Keyed.make<number, string>({ key: () => "value" })
|
||||
keyed.set([-0])
|
||||
|
||||
expect(keyed.update(0)).toBe(false)
|
||||
expect(Object.is(keyed.get("value")?.(), -0)).toBe(true)
|
||||
})
|
||||
|
||||
it("creates slots for inserts and fresh slots after remove and reinsert", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one")])
|
||||
const removed = keyed.slots()[0]
|
||||
const removedValues: Item[] = []
|
||||
const dispose = removed.subscribe((value) => removedValues.push(value))
|
||||
|
||||
keyed.set([item(2, "two")])
|
||||
const inserted = keyed.slots()[0]
|
||||
keyed.set([item(1, "new one"), item(2, "new two")])
|
||||
const [reinserted, retained] = keyed.slots()
|
||||
|
||||
expect(reinserted).not.toBe(removed)
|
||||
expect(reinserted()).toEqual(item(1, "new one"))
|
||||
expect(retained).toBe(inserted)
|
||||
expect(retained()).toEqual(item(2, "new two"))
|
||||
expect(removed()).toEqual(item(1, "one"))
|
||||
expect(removedValues).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("rejects duplicate keys before making any partial update", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two")])
|
||||
const slots = keyed.slots()
|
||||
const values = keyed.values()
|
||||
const notifications: string[] = []
|
||||
const disposeOne = slots[0].subscribe(() => notifications.push("one"))
|
||||
const disposeTwo = slots[1].subscribe(() => notifications.push("two"))
|
||||
const disposeSlots = keyed.slots.subscribe(() => notifications.push("slots"))
|
||||
const disposeValues = keyed.values.subscribe(() => notifications.push("values"))
|
||||
|
||||
expect(() => keyed.set([item(1, "changed"), item(1, "duplicate")])).toThrow("Keyed values must have unique keys")
|
||||
|
||||
expect(keyed.slots()).toBe(slots)
|
||||
expect(keyed.values()).toBe(values)
|
||||
expect(keyed.values()).toEqual([item(1, "one"), item(2, "two")])
|
||||
expect(notifications).toEqual([])
|
||||
disposeOne()
|
||||
disposeTwo()
|
||||
disposeSlots()
|
||||
disposeValues()
|
||||
})
|
||||
|
||||
it("uses SameValueZero equality for zero keys", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(-0, "negative zero")])
|
||||
const zero = keyed.slots()[0]
|
||||
|
||||
keyed.set([item(0, "zero")])
|
||||
|
||||
expect(keyed.slots()[0]).toBe(zero)
|
||||
expect(zero()).toEqual(item(0, "zero"))
|
||||
expect(() => keyed.set([item(0, "zero"), item(-0, "negative zero")])).toThrow("Keyed values must have unique keys")
|
||||
expect(keyed.slots()).toEqual([zero])
|
||||
expect(keyed.values()).toEqual([item(0, "zero")])
|
||||
})
|
||||
|
||||
it("uses SameValueZero equality for NaN keys", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(Number.NaN, "first")])
|
||||
const nan = keyed.slots()[0]
|
||||
|
||||
keyed.set([item(Number.NaN, "updated")])
|
||||
|
||||
expect(keyed.slots()[0]).toBe(nan)
|
||||
expect(nan()).toEqual(item(Number.NaN, "updated"))
|
||||
expect(() => keyed.set([item(Number.NaN, "one"), item(Number.NaN, "two")])).toThrow(
|
||||
"Keyed values must have unique keys",
|
||||
)
|
||||
expect(keyed.slots()).toEqual([nan])
|
||||
expect(keyed.values()).toEqual([item(Number.NaN, "updated")])
|
||||
})
|
||||
|
||||
it("publishes one glitch-free aggregate when slots and structure change together", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two"), item(3, "three")])
|
||||
const observations: string[] = []
|
||||
const summary = Computed.make(() => {
|
||||
const slotKeys = keyed
|
||||
.slots()
|
||||
.map((slot) => slot().id)
|
||||
.join(",")
|
||||
const values = keyed
|
||||
.values()
|
||||
.map((value) => `${value.id}:${value.label}`)
|
||||
.join(",")
|
||||
return `${slotKeys}|${values}`
|
||||
})
|
||||
const dispose = summary.subscribe((value) => observations.push(value))
|
||||
|
||||
keyed.set([item(3, "THREE"), item(2, "TWO"), item(4, "four")])
|
||||
|
||||
expect(observations).toEqual(["3,2,4|3:THREE,2:TWO,4:four"])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("stops slot, structural, and aggregate notifications after disposal", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one")])
|
||||
const notifications: string[] = []
|
||||
const disposeSlot = keyed.slots()[0].subscribe(() => notifications.push("slot"))
|
||||
const disposeSlots = keyed.slots.subscribe(() => notifications.push("slots"))
|
||||
const disposeValues = keyed.values.subscribe(() => notifications.push("values"))
|
||||
|
||||
disposeSlot()
|
||||
disposeSlots()
|
||||
disposeValues()
|
||||
keyed.set([item(2, "two")])
|
||||
keyed.set([item(2, "TWO")])
|
||||
|
||||
expect(notifications).toEqual([])
|
||||
})
|
||||
|
||||
it("supports empty sets and repeated clears without redundant publication", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
expect(keyed.slots()).toEqual([])
|
||||
expect(keyed.values()).toEqual([])
|
||||
const structures: Array<readonly unknown[]> = []
|
||||
const aggregates: Array<readonly Item[]> = []
|
||||
const disposeSlots = keyed.slots.subscribe((slots) => structures.push(slots))
|
||||
const disposeValues = keyed.values.subscribe((values) => aggregates.push(values))
|
||||
|
||||
keyed.set([])
|
||||
keyed.set([item(1, "one")])
|
||||
keyed.set([])
|
||||
const clearedSlots = keyed.slots()
|
||||
const clearedValues = keyed.values()
|
||||
keyed.set([])
|
||||
|
||||
expect(keyed.slots()).toBe(clearedSlots)
|
||||
expect(keyed.values()).toBe(clearedValues)
|
||||
expect(structures.map((slots) => slots.length)).toEqual([1, 0])
|
||||
expect(aggregates).toEqual([[item(1, "one")], []])
|
||||
disposeSlots()
|
||||
disposeValues()
|
||||
})
|
||||
|
||||
it("updates one existing slot without publishing structure", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two")])
|
||||
const structure = keyed.slots()
|
||||
const two = keyed.slots()[1]
|
||||
const structures: Array<readonly unknown[]> = []
|
||||
const dispose = keyed.slots.subscribe((slots) => structures.push(slots))
|
||||
|
||||
const updated = item(2, "TWO")
|
||||
expect(keyed.update(updated)).toBe(true)
|
||||
expect(keyed.update(updated)).toBe(false)
|
||||
|
||||
expect(keyed.slots()).toBe(structure)
|
||||
expect(keyed.slots()[1]).toBe(two)
|
||||
expect(two()).toEqual(item(2, "TWO"))
|
||||
expect(structures).toEqual([])
|
||||
expect(keyed.update(item(3, "three"))).toBe(false)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("modifies one existing slot while preserving its key", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one")])
|
||||
const slot = keyed.slots()[0]
|
||||
|
||||
expect(keyed.modify(1, (value) => ({ ...value, label: "ONE" }))).toBe(true)
|
||||
expect(keyed.modify(1, (value) => value)).toBe(false)
|
||||
|
||||
expect(keyed.slots()[0]).toBe(slot)
|
||||
expect(slot()).toEqual(item(1, "ONE"))
|
||||
expect(() => keyed.modify(1, (value) => ({ ...value, id: 2 }))).toThrow("Keyed modify must preserve the value key")
|
||||
expect(keyed.modify(2, (value) => value)).toBe(false)
|
||||
})
|
||||
|
||||
it("checks key membership without reading the aggregate", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one")])
|
||||
|
||||
expect(keyed.has(1)).toBe(true)
|
||||
expect(keyed.has(2)).toBe(false)
|
||||
expect(keyed.get(1)).toBe(keyed.slots()[0])
|
||||
expect(keyed.get(2)).toBeUndefined()
|
||||
expect(keyed.before(1)).toBeUndefined()
|
||||
expect(keyed.after(1)).toBeUndefined()
|
||||
keyed.remove(1)
|
||||
expect(keyed.has(1)).toBe(false)
|
||||
expect(keyed.get(1)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("inserts, removes, and moves stable slots", () => {
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(3, "three")])
|
||||
const one = keyed.slots()[0]
|
||||
const three = keyed.slots()[1]
|
||||
|
||||
const two = keyed.insert(item(2, "two"), { before: 3 })
|
||||
expect(keyed.slots()).toEqual([one, two, three])
|
||||
const four = keyed.insert(item(4, "four"), { after: 3 })
|
||||
expect(keyed.slots()).toEqual([one, two, three, four])
|
||||
expect(keyed.before(3)).toBe(two)
|
||||
expect(keyed.after(3)).toBe(four)
|
||||
expect(keyed.move(3, { before: 1 })).toBe(true)
|
||||
expect(keyed.slots()).toEqual([three, one, two, four])
|
||||
expect(keyed.move(3, { before: 1 })).toBe(false)
|
||||
expect(keyed.move(3, { after: 2 })).toBe(true)
|
||||
expect(keyed.slots()).toEqual([one, two, three, four])
|
||||
expect(keyed.move(3, "end")).toBe(true)
|
||||
expect(keyed.slots()).toEqual([one, two, four, three])
|
||||
expect(keyed.remove(2)).toBe(true)
|
||||
expect(keyed.remove(2)).toBe(false)
|
||||
expect(keyed.slots()).toEqual([one, four, three])
|
||||
expect(() => keyed.insert(item(1, "duplicate"))).toThrow("Keyed value already exists: 1")
|
||||
expect(() => keyed.insert(item(2, "two"), { before: 5 })).toThrow("Keyed value does not exist: 5")
|
||||
expect(keyed.move(5)).toBe(false)
|
||||
})
|
||||
|
||||
it("counts publications and equivalence suppressions when instrumented", () => {
|
||||
const metrics = Keyed.metrics()
|
||||
const keyed = Keyed.make<Item, number>({ key: (value) => value.id, metrics })
|
||||
|
||||
keyed.set([item(1, "one")])
|
||||
keyed.update(item(1, "ONE"))
|
||||
const current = keyed.slots()[0]()
|
||||
keyed.update(current)
|
||||
keyed.insert(item(2, "two"))
|
||||
keyed.move(2, { before: 1 })
|
||||
keyed.remove(2)
|
||||
|
||||
expect(metrics).toEqual({
|
||||
slotPublications: 1,
|
||||
structuralPublications: 4,
|
||||
equivalenceSuppressions: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,561 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { Layout } from "../src"
|
||||
|
||||
const Item = Layout.struct({
|
||||
id: Layout.key(Layout.number),
|
||||
label: Layout.string,
|
||||
})
|
||||
|
||||
const Job = Layout.struct({
|
||||
id: Layout.key(Layout.string),
|
||||
labels: Layout.array(Layout.string),
|
||||
status: Layout.string,
|
||||
})
|
||||
|
||||
const Jobs = Layout.collection(Job, ({ members, first }) => ({
|
||||
labels: members(["labels"], (job) => job.labels),
|
||||
nextRetry: first(["status"], (job) => job.status === "retrying"),
|
||||
}))
|
||||
|
||||
describe("Layout", () => {
|
||||
it("compiles a keyed collection from trusted structural metadata", () => {
|
||||
const plan = Layout.compile(Item)
|
||||
const original = { id: 1, label: "one" }
|
||||
const values = plan.make([original])
|
||||
const slot = values.slots()[0]
|
||||
|
||||
expect(plan.key).toBe("id")
|
||||
expect(plan.fields).toBe(Item.fields)
|
||||
expect(values.update({ id: 1, label: "one" })).toBe(false)
|
||||
expect(slot()).toBe(original)
|
||||
expect(values.update({ id: 1, label: "ONE" })).toBe(true)
|
||||
expect(slot()).toEqual({ id: 1, label: "ONE" })
|
||||
})
|
||||
|
||||
it("requires exactly one key field", () => {
|
||||
expect(() => Layout.compile(Layout.struct({ value: Layout.number }))).toThrow(
|
||||
"Keyed layout must declare exactly one key field",
|
||||
)
|
||||
expect(() =>
|
||||
Layout.compile(
|
||||
Layout.struct({
|
||||
left: Layout.key(Layout.number),
|
||||
right: Layout.key(Layout.number),
|
||||
}),
|
||||
),
|
||||
).toThrow("Keyed layout must declare exactly one key field")
|
||||
})
|
||||
|
||||
it("generates the same trusted equivalence as the closure backend", () => {
|
||||
const closure = Layout.compile(Item, { backend: "closure" })
|
||||
const generated = Layout.compile(Item, { backend: "generated" })
|
||||
const values = [
|
||||
{ id: 1, label: "one" },
|
||||
{ id: 1, label: "ONE" },
|
||||
{ id: 2, label: "one" },
|
||||
]
|
||||
|
||||
values.forEach((left) => {
|
||||
values.forEach((right) => {
|
||||
expect(generated.equivalent(left, right)).toBe(closure.equivalent(left, right))
|
||||
expect(generated.diff(left, right) === 0).toBe(generated.equivalent(left, right))
|
||||
expect(closure.diff(left, right) === 0).toBe(closure.equivalent(left, right))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("honors custom primitive comparators in generated plans", () => {
|
||||
const CaseInsensitive = {
|
||||
...Layout.string,
|
||||
foldCase: true,
|
||||
equivalent(left: string, right: string) {
|
||||
return this.foldCase ? left.toLowerCase() === right.toLowerCase() : left === right
|
||||
},
|
||||
}
|
||||
const Value = Layout.struct({ id: Layout.key(Layout.number), value: CaseInsensitive })
|
||||
const closure = Layout.compile(Value, { backend: "closure" })
|
||||
const generated = Layout.compile(Value, { backend: "generated" })
|
||||
const left = { id: 1, value: "same" }
|
||||
const right = { id: 1, value: "SAME" }
|
||||
|
||||
expect(closure.equivalent(left, right)).toBe(true)
|
||||
expect(generated.equivalent(left, right)).toBe(true)
|
||||
expect(generated.diff(left, right)).toBe(0)
|
||||
})
|
||||
|
||||
it("keeps generated nested keyed unions independent when they share variants", () => {
|
||||
const variants = {
|
||||
yes: Layout.struct({ value: Layout.string }),
|
||||
no: Layout.struct({ value: Layout.string }),
|
||||
}
|
||||
const Left = Layout.keyedUnion({
|
||||
key: Layout.key("leftID", Layout.number),
|
||||
tag: "leftType",
|
||||
variants,
|
||||
})
|
||||
const Right = Layout.keyedUnion({
|
||||
key: Layout.key("rightID", Layout.number),
|
||||
tag: "rightType",
|
||||
variants,
|
||||
})
|
||||
const Root = Layout.struct({
|
||||
id: Layout.key(Layout.number),
|
||||
left: Left,
|
||||
right: Right,
|
||||
})
|
||||
const value: Layout.Type<typeof Root> = {
|
||||
id: 1,
|
||||
left: { leftID: 1, leftType: "yes", value: "same" },
|
||||
right: { rightID: 1, rightType: "yes", value: "same" },
|
||||
}
|
||||
const copy = structuredClone(value)
|
||||
const closure = Layout.compile(Root, { backend: "closure" })
|
||||
const generated = Layout.compile(Root, { backend: "generated" })
|
||||
|
||||
expect(closure.equivalent(value, copy)).toBe(true)
|
||||
expect(generated.equivalent(value, copy)).toBe(true)
|
||||
expect(generated.diff(value, copy)).toBe(0)
|
||||
})
|
||||
|
||||
it("compiles nested discriminated unions and skips immutable fields", () => {
|
||||
const Ref = Layout.struct({
|
||||
messageID: Layout.string,
|
||||
partID: Layout.string,
|
||||
})
|
||||
const Row = Layout.keyedUnion({
|
||||
key: Layout.key("id", Layout.string),
|
||||
tag: "type",
|
||||
variants: {
|
||||
message: Layout.struct({ messageID: Layout.string }),
|
||||
group: Layout.union({
|
||||
tag: "kind",
|
||||
variants: {
|
||||
reasoning: Layout.struct({
|
||||
origin: Layout.immutable(Ref),
|
||||
refs: Layout.array(Ref),
|
||||
completed: Layout.boolean,
|
||||
}),
|
||||
exploration: Layout.struct({
|
||||
origin: Layout.immutable(Ref),
|
||||
refs: Layout.array(Ref),
|
||||
pending: Layout.array(Ref),
|
||||
completed: Layout.boolean,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
const plan = Layout.compile(Row, { backend: "closure" })
|
||||
const generated = Layout.compile(Row, { backend: "generated" })
|
||||
const group = {
|
||||
id: "group-1",
|
||||
type: "group" as const,
|
||||
kind: "exploration" as const,
|
||||
origin: { messageID: "assistant-1", partID: "read-1" },
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
pending: [] as Array<{ messageID: string; partID: string }>,
|
||||
completed: false,
|
||||
}
|
||||
|
||||
expect(
|
||||
plan.equivalent(group, {
|
||||
...group,
|
||||
origin: { messageID: "ignored", partID: "ignored" },
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(plan.equivalent(group, { ...group, completed: true })).toBe(false)
|
||||
expect(
|
||||
plan.equivalent(group, {
|
||||
...group,
|
||||
pending: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
plan.equivalent(group, {
|
||||
id: "group-1",
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
origin: group.origin,
|
||||
refs: group.refs,
|
||||
completed: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
const candidates = [
|
||||
group,
|
||||
{ ...group, origin: { messageID: "ignored", partID: "ignored" } },
|
||||
{ ...group, completed: true },
|
||||
{ ...group, pending: [{ messageID: "assistant-1", partID: "read-1" }] },
|
||||
{
|
||||
id: "group-1",
|
||||
type: "group" as const,
|
||||
kind: "reasoning" as const,
|
||||
origin: group.origin,
|
||||
refs: group.refs,
|
||||
completed: false,
|
||||
},
|
||||
]
|
||||
candidates.forEach((left) => {
|
||||
candidates.forEach((right) => expect(generated.equivalent(left, right)).toBe(plan.equivalent(left, right)))
|
||||
})
|
||||
})
|
||||
|
||||
it("includes nested keys in structural equivalence", () => {
|
||||
const Child = Layout.struct({
|
||||
id: Layout.key(Layout.number),
|
||||
value: Layout.string,
|
||||
})
|
||||
const Parent = Layout.struct({
|
||||
id: Layout.key(Layout.number),
|
||||
child: Child,
|
||||
})
|
||||
const parent = Layout.compile(Parent)
|
||||
|
||||
expect(
|
||||
parent.equivalent({ id: 1, child: { id: 1, value: "same" } }, { id: 1, child: { id: 2, value: "same" } }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("composes indexed collections without domain-specific behavior", () => {
|
||||
const jobs = Jobs.make([
|
||||
{ id: "one", labels: ["billing", "urgent"], status: "running" },
|
||||
{ id: "two", labels: ["billing"], status: "retrying" },
|
||||
{ id: "three", labels: [], status: "retrying" },
|
||||
])
|
||||
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(true)
|
||||
expect(jobs.hasMember("labels", "missing")).toBe(false)
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("two")
|
||||
expect(jobs.before("two")?.().id).toBe("one")
|
||||
expect(jobs.after("two")?.().id).toBe("three")
|
||||
|
||||
jobs.modify("one", (job) => ({ ...job, labels: [], status: "retrying" }))
|
||||
expect(jobs.hasMember("labels", "urgent")).toBe(false)
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(true)
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("one")
|
||||
|
||||
jobs.remove("two")
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(false)
|
||||
jobs.move("three", { before: "one" })
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("three")
|
||||
})
|
||||
|
||||
it("publishes first-index changes to subscribers", () => {
|
||||
const jobs = Jobs.make([
|
||||
{ id: "one", labels: [], status: "running" },
|
||||
{ id: "two", labels: [], status: "retrying" },
|
||||
])
|
||||
const seen: (string | undefined)[] = []
|
||||
const dispose = jobs.first("nextRetry").subscribe((job) => seen.push(job?.id))
|
||||
|
||||
// Identity change: a match earlier in slot order becomes the first.
|
||||
jobs.modify("one", (job) => ({ ...job, status: "retrying" }))
|
||||
// Value change of the current first match publishes through the slot.
|
||||
jobs.modify("one", (job) => ({ ...job, labels: ["late"] }))
|
||||
// The first match stops matching; the next one takes over.
|
||||
jobs.modify("one", (job) => ({ ...job, status: "done" }))
|
||||
// No match left.
|
||||
jobs.remove("two")
|
||||
|
||||
expect(seen).toEqual(["one", "one", "two", undefined])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("keeps indexes synchronized across inserts, updates, and replacement", () => {
|
||||
const jobs = Jobs.make([{ id: "one", labels: ["one"], status: "running" }])
|
||||
|
||||
jobs.insert({ id: "three", labels: ["shared"], status: "retrying" })
|
||||
jobs.insert({ id: "two", labels: ["shared"], status: "retrying" }, { before: "three" })
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("two")
|
||||
|
||||
jobs.update({ id: "two", labels: [], status: "done" })
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("three")
|
||||
expect(jobs.hasMember("labels", "shared")).toBe(true)
|
||||
|
||||
jobs.remove("three")
|
||||
expect(jobs.first("nextRetry")()).toBeUndefined()
|
||||
expect(jobs.hasMember("labels", "shared")).toBe(false)
|
||||
|
||||
jobs.set([
|
||||
{ id: "four", labels: ["replacement"], status: "retrying" },
|
||||
{ id: "five", labels: [], status: "running" },
|
||||
])
|
||||
expect(jobs.hasMember("labels", "replacement")).toBe(true)
|
||||
expect(jobs.hasMember("labels", "one")).toBe(false)
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("four")
|
||||
})
|
||||
|
||||
it("skips index projection when the change is disjoint from its declared fields", () => {
|
||||
let extractions = 0
|
||||
let matchChecks = 0
|
||||
const IndexedJobs = Layout.collection(Job, ({ members, first }) => ({
|
||||
labels: members(["labels"], (job) => {
|
||||
extractions++
|
||||
return job.labels
|
||||
}),
|
||||
nextRetry: first(["status"], (job) => {
|
||||
matchChecks++
|
||||
return job.status === "retrying"
|
||||
}),
|
||||
}))
|
||||
const jobs = IndexedJobs.make([{ id: "one", labels: ["billing"], status: "running" }])
|
||||
const labels = jobs.get("one")!().labels
|
||||
const baseline = { extractions, matchChecks }
|
||||
|
||||
// Status-only change: labels extraction reads only `labels`, so it skips.
|
||||
jobs.update({ id: "one", labels, status: "retrying" })
|
||||
expect(extractions).toBe(baseline.extractions)
|
||||
expect(matchChecks).toBe(baseline.matchChecks + 1)
|
||||
expect(jobs.first("nextRetry")()?.id).toBe("one")
|
||||
|
||||
// Labels-only change: the first-match check reads only `status`, so it skips.
|
||||
jobs.update({ id: "one", labels: ["urgent"], status: "retrying" })
|
||||
expect(extractions).toBe(baseline.extractions + 1)
|
||||
expect(matchChecks).toBe(baseline.matchChecks + 1)
|
||||
expect(jobs.hasMember("labels", "urgent")).toBe(true)
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(false)
|
||||
|
||||
// Equivalent update: nothing runs.
|
||||
jobs.update({ id: "one", labels: ["urgent"], status: "retrying" })
|
||||
expect(extractions).toBe(baseline.extractions + 1)
|
||||
expect(matchChecks).toBe(baseline.matchChecks + 1)
|
||||
})
|
||||
|
||||
it("normalizes numeric dependency names", () => {
|
||||
let extractions = 0
|
||||
const Numeric = Layout.collection(
|
||||
Layout.struct({ id: Layout.key(Layout.string), 0: Layout.string, other: Layout.string }),
|
||||
({ members }) => ({
|
||||
zero: members([0], (value) => {
|
||||
extractions++
|
||||
return [value[0]]
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const values = Numeric.make([{ id: "one", 0: "zero", other: "before" }])
|
||||
const baseline = extractions
|
||||
|
||||
values.update({ id: "one", 0: "zero", other: "after" })
|
||||
|
||||
expect(extractions).toBe(baseline)
|
||||
expect(values.hasMember("zero", "zero")).toBe(true)
|
||||
})
|
||||
|
||||
it("reuses precomputed field diffs during direct collection mutations", () => {
|
||||
let comparisons = 0
|
||||
const counted: Layout.Field<string> = {
|
||||
equivalent(left, right) {
|
||||
comparisons++
|
||||
return left === right
|
||||
},
|
||||
}
|
||||
const CountedJob = Layout.struct({
|
||||
id: Layout.key(Layout.string),
|
||||
value: counted,
|
||||
status: Layout.string,
|
||||
})
|
||||
const CountedJobs = Layout.collection(CountedJob, ({ first }) => ({
|
||||
changed: first(["value"], (job) => job.value === "after"),
|
||||
}))
|
||||
const one = { id: "one", value: "before", status: "idle" }
|
||||
const two = { id: "two", value: "before", status: "idle" }
|
||||
const jobs = CountedJobs.make([one, two])
|
||||
comparisons = 0
|
||||
|
||||
jobs.set([{ ...one }, { ...two, value: "after" }])
|
||||
|
||||
expect(comparisons).toBe(4)
|
||||
expect(jobs.get("one")?.()).toBe(one)
|
||||
expect(jobs.first("changed")()?.id).toBe("two")
|
||||
|
||||
comparisons = 0
|
||||
jobs.update({ ...jobs.get("one")!(), status: "busy" })
|
||||
expect(comparisons).toBe(1)
|
||||
|
||||
comparisons = 0
|
||||
jobs.modify("two", (job) => ({ ...job, value: "done" }))
|
||||
expect(comparisons).toBe(1)
|
||||
expect(jobs.first("changed")()).toBeUndefined()
|
||||
})
|
||||
|
||||
it("refreshes indexes that read a changed union discriminant", () => {
|
||||
const Row = Layout.keyedUnion({
|
||||
key: Layout.key("id", Layout.number),
|
||||
tag: "type",
|
||||
variants: {
|
||||
waiting: Layout.struct({ value: Layout.string }),
|
||||
ready: Layout.struct({ value: Layout.string }),
|
||||
},
|
||||
})
|
||||
const Rows = Layout.collection(Row, ({ members, first }) => ({
|
||||
types: members(["type"], (row) => [row.type]),
|
||||
firstReady: first(["type"], (row) => row.type === "ready"),
|
||||
}))
|
||||
const rows = Rows.make([{ id: 1, type: "waiting", value: "same" }])
|
||||
|
||||
rows.update({ id: 1, type: "ready", value: "same" })
|
||||
|
||||
expect(rows.get(1)?.().type).toBe("ready")
|
||||
expect(rows.hasMember("types", "waiting")).toBe(false)
|
||||
expect(rows.hasMember("types", "ready")).toBe(true)
|
||||
expect(rows.first("firstReady")()?.type).toBe("ready")
|
||||
})
|
||||
|
||||
it("tracks lazy member iterables while they are consumed", () => {
|
||||
const LazyJobs = Layout.collection(Job, ({ members }) => ({
|
||||
statuses: members(function* (job) {
|
||||
yield job.status
|
||||
}),
|
||||
}))
|
||||
const jobs = LazyJobs.make([{ id: "one", labels: [], status: "running" }])
|
||||
|
||||
expect(jobs.hasMember("statuses", "running")).toBe(true)
|
||||
jobs.update({ id: "one", labels: [], status: "retrying" })
|
||||
expect(jobs.hasMember("statuses", "running")).toBe(false)
|
||||
expect(jobs.hasMember("statuses", "retrying")).toBe(true)
|
||||
})
|
||||
|
||||
it("passes actual frozen values to identity and reflection projections", () => {
|
||||
const actual = Object.freeze({ id: "one", labels: [] as readonly string[], status: "running" })
|
||||
const accepted = new WeakSet<object>([actual])
|
||||
const EnumeratedJobs = Layout.collection(Job, ({ members, first }) => ({
|
||||
properties: members((job) => Object.keys(job)),
|
||||
selves: members((job) => [job]),
|
||||
frozen: members((job) => [Object.isFrozen(job)]),
|
||||
accepted: first((job) => accepted.has(job)),
|
||||
}))
|
||||
const jobs = EnumeratedJobs.make([actual])
|
||||
|
||||
expect(jobs.hasMember("properties", "id")).toBe(true)
|
||||
expect(jobs.hasMember("properties", "labels")).toBe(true)
|
||||
expect(jobs.hasMember("properties", "status")).toBe(true)
|
||||
expect(jobs.hasMember("selves", actual)).toBe(true)
|
||||
expect(jobs.hasMember("frozen", true)).toBe(true)
|
||||
expect(jobs.first("accepted")?.()).toBe(actual)
|
||||
})
|
||||
|
||||
it("applies explicit member deltas without re-extracting unchanged membership", () => {
|
||||
let extractions = 0
|
||||
const IndexedJobs = Layout.collection(Job, ({ members }) => ({
|
||||
labels: members((job) => {
|
||||
extractions++
|
||||
return job.labels
|
||||
}),
|
||||
}))
|
||||
const jobs = IndexedJobs.make([
|
||||
{ id: "one", labels: ["billing"], status: "running" },
|
||||
{ id: "two", labels: ["billing"], status: "running" },
|
||||
])
|
||||
const before = extractions
|
||||
|
||||
jobs.modify("one", (job) => ({ ...job, labels: ["urgent"] }), {
|
||||
members: { labels: { add: ["urgent"], remove: ["billing"] } },
|
||||
})
|
||||
|
||||
expect(extractions).toBe(before)
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(true)
|
||||
expect(jobs.hasMember("labels", "urgent")).toBe(true)
|
||||
|
||||
jobs.modify("two", (job) => ({ ...job, labels: [] }), {
|
||||
members: { labels: { remove: ["billing"] } },
|
||||
})
|
||||
expect(jobs.hasMember("labels", "billing")).toBe(false)
|
||||
|
||||
if (false) {
|
||||
jobs.modify("one", (job) => job, {
|
||||
members: {
|
||||
labels: {
|
||||
// @ts-expect-error Member deltas require arrays so strings are not split into characters.
|
||||
add: "urgent",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("does not commit mutations when an index callback throws", () => {
|
||||
const ThrowingJobs = Layout.collection(Job, ({ members, first }) => ({
|
||||
labels: members((job) => {
|
||||
if (job.labels.includes("boom")) throw new Error("boom")
|
||||
return job.labels
|
||||
}),
|
||||
nextRetry: first((job) => job.status === "retrying"),
|
||||
}))
|
||||
const jobs = ThrowingJobs.make([{ id: "one", labels: ["safe"], status: "running" }])
|
||||
|
||||
expect(() => jobs.update({ id: "one", labels: ["boom"], status: "retrying" })).toThrow("boom")
|
||||
expect(() => jobs.set([{ id: "two", labels: ["boom"], status: "retrying" }])).toThrow("boom")
|
||||
|
||||
expect(jobs.values()).toEqual([{ id: "one", labels: ["safe"], status: "running" }])
|
||||
expect(jobs.hasMember("labels", "safe")).toBe(true)
|
||||
expect(jobs.hasMember("labels", "boom")).toBe(false)
|
||||
expect(jobs.first("nextRetry")()).toBeUndefined()
|
||||
|
||||
expect(() => jobs.insert({ id: "two", labels: ["boom"], status: "retrying" }, { before: "missing" })).toThrow(
|
||||
"Keyed value does not exist: missing",
|
||||
)
|
||||
})
|
||||
|
||||
it("restores pending comparison state when publication throws", () => {
|
||||
const LabeledJob = Layout.struct({
|
||||
id: Layout.key(Layout.string),
|
||||
label: Layout.string,
|
||||
status: Layout.string,
|
||||
})
|
||||
const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({
|
||||
retry: first(["status"], (job) => job.status === "retrying"),
|
||||
}))
|
||||
const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }])
|
||||
const slot = jobs.get("one")!
|
||||
const dispose = slot.subscribe(() => {
|
||||
throw new Error("listener failed")
|
||||
})
|
||||
|
||||
expect(() => jobs.update({ id: "one", label: "after", status: "running" })).toThrow("listener failed")
|
||||
dispose()
|
||||
const equivalent = { ...slot() }
|
||||
|
||||
expect(jobs.set([equivalent])).toBe(false)
|
||||
expect(slot()).not.toBe(equivalent)
|
||||
})
|
||||
|
||||
it("does not leak a pending comparison into reentrant publication", () => {
|
||||
const LabeledJob = Layout.struct({
|
||||
id: Layout.key(Layout.string),
|
||||
label: Layout.string,
|
||||
status: Layout.string,
|
||||
})
|
||||
const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({
|
||||
retry: first(["status"], (job) => job.status === "retrying"),
|
||||
}))
|
||||
const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }])
|
||||
const slot = jobs.get("one")!
|
||||
let reentered = false
|
||||
let changed: boolean | undefined
|
||||
const dispose = slot.subscribe(() => {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
changed = jobs.set([{ ...slot() }])
|
||||
})
|
||||
|
||||
jobs.update({ id: "one", label: "after", status: "running" })
|
||||
|
||||
expect(changed).toBe(false)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("tracks first matches whose key is undefined", () => {
|
||||
const undefinedField: Layout.Field<undefined> = { equivalent: Object.is }
|
||||
const OptionalJobs = Layout.collection(
|
||||
Layout.struct({ id: Layout.key(undefinedField), status: Layout.string }),
|
||||
({ first }) => ({ retry: first(["status"], (job) => job.status === "retrying") }),
|
||||
)
|
||||
const jobs = OptionalJobs.make([{ id: undefined, status: "running" }])
|
||||
|
||||
jobs.update({ id: undefined, status: "retrying" })
|
||||
|
||||
expect(jobs.first("retry")?.()).toEqual({
|
||||
id: undefined,
|
||||
status: "retrying",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,427 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { Computed, State, Transaction, type Readable } from "../src/reactivity"
|
||||
|
||||
describe("reactivity", () => {
|
||||
it("keeps computed values lazy", () => {
|
||||
const source = State.make(1)
|
||||
let evaluations = 0
|
||||
const doubled = Computed.make(() => {
|
||||
evaluations++
|
||||
return source() * 2
|
||||
})
|
||||
|
||||
expect(evaluations).toBe(0)
|
||||
expect(doubled()).toBe(2)
|
||||
expect(doubled()).toBe(2)
|
||||
expect(evaluations).toBe(1)
|
||||
|
||||
source.set(2)
|
||||
expect(evaluations).toBe(1)
|
||||
expect(doubled()).toBe(4)
|
||||
expect(evaluations).toBe(2)
|
||||
})
|
||||
|
||||
it("propagates a diamond without glitches", () => {
|
||||
const source = State.make(1)
|
||||
const left = Computed.make(() => source() * 2)
|
||||
const right = Computed.make(() => source() * 3)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: Array<number> = []
|
||||
const dispose = total.subscribe((value) => values.push(value))
|
||||
|
||||
source.set(2)
|
||||
|
||||
expect(values).toEqual([10])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("tracks dynamic dependencies", () => {
|
||||
const enabled = State.make(true)
|
||||
const left = State.make(1)
|
||||
const right = State.make(10)
|
||||
const selected = Computed.make(() => (enabled() ? left() : right()))
|
||||
const values: Array<number> = []
|
||||
const dispose = selected.subscribe((value) => values.push(value))
|
||||
|
||||
left.set(2)
|
||||
enabled.set(false)
|
||||
left.set(3)
|
||||
right.set(11)
|
||||
|
||||
expect(values).toEqual([2, 10, 11])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("batches transactions", () => {
|
||||
const left = State.make(1)
|
||||
const right = State.make(2)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: Array<number> = []
|
||||
const dispose = total.subscribe((value) => values.push(value))
|
||||
|
||||
Transaction.run(() => {
|
||||
left.set(2)
|
||||
right.set(3)
|
||||
})
|
||||
|
||||
expect(values).toEqual([5])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("cuts off unchanged derived values", () => {
|
||||
const source = State.make(1)
|
||||
let parityEvaluations = 0
|
||||
let labelEvaluations = 0
|
||||
const parity = Computed.make(() => {
|
||||
parityEvaluations++
|
||||
return source() % 2
|
||||
})
|
||||
const label = Computed.make(() => {
|
||||
labelEvaluations++
|
||||
return parity() === 0 ? "even" : "odd"
|
||||
})
|
||||
const dispose = label.subscribe(() => {})
|
||||
|
||||
source.set(3)
|
||||
|
||||
expect(parityEvaluations).toBe(2)
|
||||
expect(labelEvaluations).toBe(1)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("disposes subscriptions", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const dispose = source.subscribe((value) => values.push(value))
|
||||
|
||||
source.set(2)
|
||||
dispose()
|
||||
source.set(3)
|
||||
|
||||
expect(values).toEqual([2])
|
||||
})
|
||||
|
||||
it("does not track state read by subscription listeners", () => {
|
||||
const source = State.make(1)
|
||||
const unrelated = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const dispose = source.subscribe((value) => {
|
||||
unrelated()
|
||||
values.push(value)
|
||||
})
|
||||
|
||||
source.set(2)
|
||||
unrelated.set(2)
|
||||
|
||||
expect(values).toEqual([2])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("does not track the state read by update", () => {
|
||||
const trigger = State.make(0)
|
||||
const updated = State.make(0)
|
||||
let runs = 0
|
||||
const dispose = trigger.subscribe(() => {
|
||||
runs++
|
||||
updated.update((value) => value + 1)
|
||||
})
|
||||
|
||||
trigger.set(1)
|
||||
updated.set(10)
|
||||
|
||||
expect(runs).toBe(1)
|
||||
expect(updated()).toBe(10)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("delivers the previous value to computed evaluation", () => {
|
||||
const source = State.make(1)
|
||||
const previousValues: Array<number | undefined> = []
|
||||
const running = Computed.make<number>((previous) => {
|
||||
previousValues.push(previous)
|
||||
return (previous ?? 0) + source()
|
||||
})
|
||||
|
||||
expect(running()).toBe(1)
|
||||
source.set(2)
|
||||
expect(running()).toBe(3)
|
||||
source.set(3)
|
||||
expect(running()).toBe(6)
|
||||
expect(previousValues).toEqual([undefined, 1, 3])
|
||||
})
|
||||
|
||||
it("detaches and reattaches dynamic dependencies", () => {
|
||||
const enabled = State.make(true)
|
||||
const left = State.make(1)
|
||||
const right = State.make(10)
|
||||
let evaluations = 0
|
||||
const selected = Computed.make(() => {
|
||||
evaluations++
|
||||
return enabled() ? left() : right()
|
||||
})
|
||||
const dispose = selected.subscribe(() => {})
|
||||
|
||||
expect(evaluations).toBe(1)
|
||||
enabled.set(false)
|
||||
expect(evaluations).toBe(2)
|
||||
|
||||
// Detached: left writes must not re-evaluate the computed.
|
||||
left.set(2)
|
||||
left.set(3)
|
||||
expect(evaluations).toBe(2)
|
||||
|
||||
// Reattached: left writes re-evaluate, right writes no longer do.
|
||||
enabled.set(true)
|
||||
expect(evaluations).toBe(3)
|
||||
expect(selected()).toBe(3)
|
||||
right.set(11)
|
||||
expect(evaluations).toBe(3)
|
||||
left.set(4)
|
||||
expect(evaluations).toBe(4)
|
||||
expect(selected()).toBe(4)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("notifies a diamond subscriber exactly once per write", () => {
|
||||
const source = State.make(1)
|
||||
const left = Computed.make(() => source() * 2)
|
||||
const right = Computed.make(() => source() * 3)
|
||||
const total = Computed.make(() => left() + right())
|
||||
let notifications = 0
|
||||
const dispose = total.subscribe(() => notifications++)
|
||||
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
|
||||
expect(notifications).toBe(2)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("batches nested transactions until the outermost ends", () => {
|
||||
const left = State.make(1)
|
||||
const right = State.make(2)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: Array<number> = []
|
||||
const dispose = total.subscribe((value) => values.push(value))
|
||||
|
||||
Transaction.run(() => {
|
||||
left.set(2)
|
||||
Transaction.run(() => {
|
||||
right.set(3)
|
||||
})
|
||||
expect(values).toEqual([])
|
||||
left.set(3)
|
||||
})
|
||||
|
||||
expect(values).toEqual([6])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("restores batch state when a transaction throws", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const dispose = source.subscribe((value) => values.push(value))
|
||||
|
||||
expect(() =>
|
||||
Transaction.run(() => {
|
||||
source.set(2)
|
||||
throw new Error("boom")
|
||||
}),
|
||||
).toThrow("boom")
|
||||
|
||||
// The write that happened before the throw still flushes once the
|
||||
// transaction unwinds, and later writes are not batched.
|
||||
expect(values).toEqual([2])
|
||||
source.set(3)
|
||||
expect(values).toEqual([2, 3])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("supports disposing another subscription during notification", () => {
|
||||
const source = State.make(1)
|
||||
const first: Array<number> = []
|
||||
const second: Array<number> = []
|
||||
let disposeSecond = () => {}
|
||||
const disposeFirst = source.subscribe((value) => {
|
||||
first.push(value)
|
||||
disposeSecond()
|
||||
})
|
||||
disposeSecond = source.subscribe((value) => second.push(value))
|
||||
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
|
||||
expect(first).toEqual([2, 3])
|
||||
expect(second).toEqual([])
|
||||
disposeFirst()
|
||||
})
|
||||
|
||||
it("does not deliver to a watcher disposed during revalidation", () => {
|
||||
const source = State.make(0)
|
||||
let dispose = () => {}
|
||||
const gate = Computed.make(() => {
|
||||
const value = source()
|
||||
if (value > 0) dispose()
|
||||
return value
|
||||
})
|
||||
const values: Array<number> = []
|
||||
dispose = gate.subscribe((value) => values.push(value))
|
||||
|
||||
// Revalidating the watcher re-evaluates gate, whose evaluation disposes
|
||||
// the subscription before the value could be delivered.
|
||||
source.set(1)
|
||||
source.set(2)
|
||||
|
||||
expect(values).toEqual([])
|
||||
})
|
||||
|
||||
it("supports a subscription disposing itself during notification", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
let dispose = () => {}
|
||||
dispose = source.subscribe((value) => {
|
||||
values.push(value)
|
||||
dispose()
|
||||
})
|
||||
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
|
||||
expect(values).toEqual([2])
|
||||
})
|
||||
|
||||
it("does not track State.update reading its own value", () => {
|
||||
const counter = State.make(0)
|
||||
const source = State.make(1)
|
||||
let evaluations = 0
|
||||
const tracked = Computed.make(() => {
|
||||
evaluations++
|
||||
counter.update((value) => value)
|
||||
return source()
|
||||
})
|
||||
const dispose = tracked.subscribe(() => {})
|
||||
|
||||
expect(evaluations).toBe(1)
|
||||
counter.set(5)
|
||||
expect(evaluations).toBe(1)
|
||||
source.set(2)
|
||||
expect(evaluations).toBe(2)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("recovers tracking after a computed evaluation throws", () => {
|
||||
const shouldThrow = State.make(true)
|
||||
const source = State.make(1)
|
||||
const throwing = Computed.make(() => {
|
||||
if (shouldThrow()) throw new Error("computed boom")
|
||||
return source()
|
||||
})
|
||||
|
||||
expect(() => throwing()).toThrow("computed boom")
|
||||
|
||||
// The failed evaluation must restore the active observer so unrelated
|
||||
// graphs keep tracking correctly afterwards.
|
||||
const other = State.make(1)
|
||||
const doubled = Computed.make(() => other() * 2)
|
||||
const values: Array<number> = []
|
||||
const dispose = doubled.subscribe((value) => values.push(value))
|
||||
other.set(2)
|
||||
expect(values).toEqual([4])
|
||||
dispose()
|
||||
|
||||
shouldThrow.set(false)
|
||||
expect(throwing()).toBe(1)
|
||||
source.set(2)
|
||||
expect(throwing()).toBe(2)
|
||||
})
|
||||
|
||||
it("keeps notifying other subscribers after a listener throws", () => {
|
||||
const source = State.make(1)
|
||||
const values: Array<number> = []
|
||||
const disposeThrowing = source.subscribe(() => {
|
||||
throw new Error("listener boom")
|
||||
})
|
||||
const dispose = source.subscribe((value) => values.push(value))
|
||||
|
||||
expect(() => source.set(2)).toThrow("listener boom")
|
||||
disposeThrowing()
|
||||
source.set(3)
|
||||
|
||||
expect(values).toContain(3)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("leaves no dependency links behind when subscription initialization fails", () => {
|
||||
const source = State.make(1)
|
||||
const throwing = Computed.make(() => {
|
||||
if (source() === 1) throw new Error("init boom")
|
||||
return source()
|
||||
})
|
||||
const values: Array<number> = []
|
||||
|
||||
expect(() => throwing.subscribe((value) => values.push(value))).toThrow("init boom")
|
||||
|
||||
// The failed subscription must not stay linked to the graph.
|
||||
source.set(2)
|
||||
source.set(3)
|
||||
expect(values).toEqual([])
|
||||
|
||||
// The computed itself remains usable.
|
||||
expect(throwing()).toBe(3)
|
||||
})
|
||||
|
||||
it("propagates deep chains without recursive stack growth", () => {
|
||||
// A cold pull of an unevaluated chain necessarily nests user getter
|
||||
// frames, so each layer is evaluated as it is built. The kernel-owned
|
||||
// paths under test are dirty propagation and revalidation, which must
|
||||
// walk the full depth iteratively.
|
||||
const depth = 100_000
|
||||
const source = State.make(0)
|
||||
const chain = Array.from({ length: depth }).reduce<Readable<number>>((current) => {
|
||||
const next = Computed.make(() => current() + 1)
|
||||
next()
|
||||
return next
|
||||
}, source)
|
||||
|
||||
expect(chain()).toBe(depth)
|
||||
const values: Array<number> = []
|
||||
const dispose = chain.subscribe((value) => values.push(value))
|
||||
source.set(1)
|
||||
expect(values).toEqual([depth + 1])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("propagates wide fan-out without recursive stack growth", () => {
|
||||
const width = 50_000
|
||||
const source = State.make(0)
|
||||
const nodes = Array.from({ length: width }, () => Computed.make(() => source() + 1))
|
||||
const total = Computed.make(() => nodes.reduce((sum, node) => sum + node(), 0))
|
||||
let sink = 0
|
||||
const dispose = total.subscribe((value) => {
|
||||
sink = value
|
||||
})
|
||||
|
||||
source.set(1)
|
||||
expect(sink).toBe(width * 2)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("fails deterministically on cyclic computed dependencies", () => {
|
||||
// Regression for stackblitz/alien-signals#123: mutually dependent
|
||||
// computed graphs must throw a stable error instead of looping or
|
||||
// exhausting memory.
|
||||
const fieldA = State.make(false)
|
||||
const fieldB = State.make(false)
|
||||
const a: Readable<boolean | null> = Computed.make(() => (b() !== true ? fieldA() : null))
|
||||
const b: Readable<boolean | null> = Computed.make(() => (a() !== true ? fieldB() : null))
|
||||
|
||||
// Every read that reaches the cycle fails the same way; a failed
|
||||
// evaluation stays dirty and retries instead of serving a stale value.
|
||||
expect(() => a()).toThrow(/cycle/i)
|
||||
expect(() => b()).toThrow(/cycle/i)
|
||||
|
||||
fieldA.set(true)
|
||||
|
||||
expect(() => a()).toThrow(/cycle/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { createComputed, createRoot, createSignal } from "solid-js"
|
||||
import { Computed, Keyed, State, Transaction, type Readable } from "../src"
|
||||
import { useSlot, useValue } from "../src/solid"
|
||||
|
||||
describe("Solid adapter", () => {
|
||||
it("bridges Quark values into Solid ownership", () => {
|
||||
const left = State.make(1)
|
||||
const right = State.make(2)
|
||||
const total = Computed.make(() => left() + right())
|
||||
const values: number[] = []
|
||||
|
||||
createRoot((dispose) => {
|
||||
const value = useValue(total)
|
||||
createComputed(() => values.push(value()))
|
||||
|
||||
Transaction.run(() => {
|
||||
left.set(2)
|
||||
right.set(3)
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
left.set(4)
|
||||
expect(values).toEqual([3, 5])
|
||||
})
|
||||
|
||||
it("preserves function values", () => {
|
||||
const first = () => 1
|
||||
const second = () => 2
|
||||
const state = State.make(first)
|
||||
let value = first
|
||||
|
||||
createRoot((dispose) => {
|
||||
const current = useValue(state)
|
||||
createComputed(() => (value = current()))
|
||||
state.set(second)
|
||||
dispose()
|
||||
})
|
||||
|
||||
expect(value).toBe(second)
|
||||
})
|
||||
|
||||
it("accepts a plain constant key", () => {
|
||||
const keyed = Keyed.make<{ readonly id: number; readonly value: string }, number>({ key: (value) => value.id })
|
||||
keyed.set([{ id: 1, value: "one" }])
|
||||
const values: Array<string | undefined> = []
|
||||
|
||||
createRoot((dispose) => {
|
||||
const value = useSlot(keyed, 1)
|
||||
createComputed(() => values.push(value()?.value))
|
||||
keyed.modify(1, (item) => ({ ...item, value: "ONE" }))
|
||||
dispose()
|
||||
})
|
||||
|
||||
expect(values).toEqual(["one", "ONE"])
|
||||
})
|
||||
|
||||
it("switches keyed slots and releases obsolete subscriptions", () => {
|
||||
const keyed = Keyed.make<{ readonly id: number; readonly value: string }, number>({ key: (value) => value.id })
|
||||
keyed.set([
|
||||
{ id: 1, value: "one" },
|
||||
{ id: 2, value: "two" },
|
||||
])
|
||||
const active = [0, 0]
|
||||
track(keyed.get(1)!, 0)
|
||||
track(keyed.get(2)!, 1)
|
||||
const values: Array<string | undefined> = []
|
||||
|
||||
createRoot((dispose) => {
|
||||
const [key, setKey] = createSignal(1)
|
||||
const value = useSlot(keyed, key)
|
||||
createComputed(() => values.push(value()?.value))
|
||||
|
||||
keyed.modify(1, (item) => ({ ...item, value: "ONE" }))
|
||||
keyed.insert({ id: 3, value: "three" })
|
||||
setKey(2)
|
||||
keyed.modify(1, (item) => ({ ...item, value: "ignored" }))
|
||||
keyed.modify(2, (item) => ({ ...item, value: "TWO" }))
|
||||
|
||||
expect(active).toEqual([0, 1])
|
||||
dispose()
|
||||
})
|
||||
|
||||
expect(active).toEqual([0, 0])
|
||||
expect(values).toEqual(["one", "ONE", "two", "TWO"])
|
||||
|
||||
function track(slot: Readable<{ readonly id: number; readonly value: string }>, index: number) {
|
||||
const subscribe = slot.subscribe.bind(slot)
|
||||
slot.subscribe = (listener) => {
|
||||
active[index]++
|
||||
const dispose = subscribe(listener)
|
||||
return () => {
|
||||
active[index]--
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Keyed } from "@opencode-ai/quark"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { SessionTimeline, type PartRef } from "../src/routes/session/timeline"
|
||||
import { createHarness, type Workload } from "../../quark/bench/harness"
|
||||
|
||||
type Group = {
|
||||
readonly id: "group"
|
||||
readonly type: "group"
|
||||
readonly refs: readonly PartRef[]
|
||||
}
|
||||
|
||||
const bench = createHarness({ warmup: 500 })
|
||||
|
||||
function timelineAppend(): Workload {
|
||||
const timeline = SessionTimeline.make()
|
||||
let ordinal = 0
|
||||
return {
|
||||
run() {
|
||||
timeline.appendPart({ messageID: "assistant", partID: `reasoning:${ordinal++}` }, { type: "reasoning" })
|
||||
},
|
||||
consume: () => {
|
||||
const row = timeline.values()[0]
|
||||
return row?.type === "group" ? row.refs.length : 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function keyedAppend(): Workload {
|
||||
const seen = new Set<string>()
|
||||
const rows = Keyed.make<Group, Group["id"]>({
|
||||
key: (row) => row.id,
|
||||
equivalent: (left, right) =>
|
||||
left.refs.length === right.refs.length &&
|
||||
left.refs.every(
|
||||
(ref, index) => ref.messageID === right.refs[index].messageID && ref.partID === right.refs[index].partID,
|
||||
),
|
||||
})
|
||||
rows.set([{ id: "group", type: "group", refs: [] }])
|
||||
let ordinal = 0
|
||||
return {
|
||||
run() {
|
||||
const ref = { messageID: "assistant", partID: `reasoning:${ordinal++}` }
|
||||
if (seen.has(ref.partID)) return
|
||||
rows.modify("group", (group) => ({ ...group, refs: [...group.refs, ref] }))
|
||||
seen.add(ref.partID)
|
||||
},
|
||||
consume: () => rows.get("group")!().refs.length,
|
||||
}
|
||||
}
|
||||
|
||||
function solidAppend(): Workload {
|
||||
const [rows, setRows] = createStore<Array<{ type: "group"; refs: PartRef[] }>>([{ type: "group", refs: [] }])
|
||||
let ordinal = 0
|
||||
return {
|
||||
run() {
|
||||
const ref = { messageID: "assistant", partID: `reasoning:${ordinal++}` }
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft[0].refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)) return
|
||||
draft[0].refs.push(ref)
|
||||
}),
|
||||
)
|
||||
},
|
||||
consume: () => rows[0].refs.length,
|
||||
}
|
||||
}
|
||||
|
||||
function timelineDuplicate(size: number): Workload {
|
||||
const timeline = SessionTimeline.make()
|
||||
Array.from({ length: size }, (_, ordinal) =>
|
||||
timeline.appendPart({ messageID: "assistant", partID: `reasoning:${ordinal}` }, { type: "reasoning" }),
|
||||
)
|
||||
const duplicate = { messageID: "assistant", partID: `reasoning:${size - 1}` }
|
||||
return {
|
||||
run: () => timeline.appendPart(duplicate, { type: "reasoning" }),
|
||||
consume: () => timeline.values().length,
|
||||
}
|
||||
}
|
||||
|
||||
function solidDuplicate(size: number): Workload {
|
||||
const refs = Array.from(
|
||||
{ length: size },
|
||||
(_, ordinal): PartRef => ({ messageID: "assistant", partID: `reasoning:${ordinal}` }),
|
||||
)
|
||||
const [rows, setRows] = createStore([{ type: "group" as const, refs }])
|
||||
const duplicate = refs.at(-1)!
|
||||
return {
|
||||
run() {
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft[0].refs.some((item) => item.messageID === duplicate.messageID && item.partID === duplicate.partID))
|
||||
return
|
||||
draft[0].refs.push(duplicate)
|
||||
}),
|
||||
)
|
||||
},
|
||||
consume: () => rows.length,
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Session timeline benchmark (${bench.samples} samples)\n`)
|
||||
|
||||
const append = bench.compare(2_000, [
|
||||
{ name: "SessionTimeline grouped append", make: timelineAppend },
|
||||
{ name: "Handwritten Keyed + Set append", make: keyedAppend },
|
||||
{ name: "Solid Store produce append", make: solidAppend },
|
||||
])
|
||||
const duplicate = bench.compare(10_000, [
|
||||
{ name: "SessionTimeline duplicate 1000", make: () => timelineDuplicate(1_000) },
|
||||
{ name: "Solid Store duplicate 1000", make: () => solidDuplicate(1_000) },
|
||||
])
|
||||
|
||||
console.log("\nRatios (lower is faster)")
|
||||
console.log(`Timeline / handwritten append: ${append.ratio(0, 1).toFixed(3)}x`)
|
||||
console.log(`Timeline / Solid append: ${append.ratio(0, 2).toFixed(3)}x`)
|
||||
console.log(`Timeline / Solid duplicate: ${duplicate.ratio(0, 1).toFixed(3)}x`)
|
||||
console.log(`METRIC timeline_handwritten_append_ratio=${append.ratio(0, 1).toFixed(6)}`)
|
||||
console.log(`METRIC timeline_solid_append_ratio=${append.ratio(0, 2).toFixed(6)}`)
|
||||
console.log(`METRIC timeline_solid_duplicate_ratio=${duplicate.ratio(0, 1).toFixed(6)}`)
|
||||
bench.finish()
|
||||
@@ -6,6 +6,7 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"bench:timeline": "bun --conditions=browser bench/session-timeline.ts",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
@@ -92,6 +93,7 @@
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"@opencode-ai/quark": "workspace:*",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
"open": "10.1.2",
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useEvent } from "./context/event"
|
||||
import { ClientProvider, useClient } from "./context/client"
|
||||
import { StartupLoading } from "./component/startup-loading"
|
||||
import { DevToolsSidebar } from "./component/devtools-sidebar"
|
||||
import { PerformanceDevTools } from "./component/performance-devtools"
|
||||
import { DevTools } from "./devtools"
|
||||
import { Reconnecting } from "./component/reconnecting"
|
||||
import { DataProvider, useData } from "./context/data"
|
||||
@@ -1135,7 +1136,10 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={devtools()}>
|
||||
<DevToolsSidebar />
|
||||
<>
|
||||
<PerformanceDevTools />
|
||||
<DevToolsSidebar />
|
||||
</>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={!startup.skipInitialLoading}>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { DevTools } from "../devtools"
|
||||
|
||||
const sampleInterval = 1_000
|
||||
const eventLoopInterval = 100
|
||||
|
||||
export function PerformanceDevTools() {
|
||||
const renderer = useRenderer()
|
||||
const runtime = DevTools.register({ id: "runtime-performance", title: "Runtime performance" })
|
||||
const rendering = DevTools.register({ id: "renderer-performance", title: "Renderer performance" })
|
||||
let previousTime = performance.now()
|
||||
let previousCpu = process.cpuUsage()
|
||||
let eventLoopTime = previousTime
|
||||
let eventLoopLag = 0
|
||||
|
||||
renderer.resetStats()
|
||||
renderer.setGatherStats(true)
|
||||
|
||||
const eventLoopTimer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
eventLoopLag = Math.max(eventLoopLag, now - eventLoopTime - eventLoopInterval)
|
||||
eventLoopTime = now
|
||||
}, eventLoopInterval)
|
||||
|
||||
const sample = setInterval(() => {
|
||||
const now = performance.now()
|
||||
const cpu = process.cpuUsage()
|
||||
const elapsed = now - previousTime
|
||||
const memory = process.memoryUsage()
|
||||
const stats = renderer.getStats()
|
||||
const scheduler = renderer.getSchedulerState()
|
||||
const frameTimes = stats.frameTimes.toSorted((left, right) => left - right)
|
||||
const frameP95 = frameTimes[Math.ceil(frameTimes.length * 0.95) - 1]
|
||||
|
||||
runtime.setAll([
|
||||
{
|
||||
key: "TUI CPU",
|
||||
value: `${(((cpu.user - previousCpu.user + cpu.system - previousCpu.system) / (elapsed * 1_000)) * 100).toFixed(1)}%`,
|
||||
},
|
||||
{ key: "Event loop max", value: `${Math.max(0, eventLoopLag).toFixed(1)} ms` },
|
||||
{ key: "RSS", value: megabytes(memory.rss) },
|
||||
{ key: "Heap used", value: megabytes(memory.heapUsed) },
|
||||
{ key: "Array buffers", value: megabytes(memory.arrayBuffers) },
|
||||
])
|
||||
rendering.setAll([
|
||||
{ key: "FPS", value: stats.fps },
|
||||
{ key: "Frame p95", value: milliseconds(frameP95) },
|
||||
{ key: "Frame max", value: milliseconds(stats.maxFrameTime || undefined) },
|
||||
{ key: "Native render", value: microseconds(stats.nativeRenderTime) },
|
||||
{ key: "Terminal write", value: microseconds(stats.nativeStdoutWriteTime) },
|
||||
{ key: "Frame callbacks", value: milliseconds(stats.frameCallbackTime) },
|
||||
{ key: "Cells updated", value: stats.cellsUpdated },
|
||||
{ key: "Cells average", value: stats.averageCellsUpdated },
|
||||
{ key: "Target FPS", value: renderer.targetFps },
|
||||
{
|
||||
key: "Scheduler",
|
||||
value: scheduler.isRendering
|
||||
? "rendering"
|
||||
: scheduler.hasScheduledRender
|
||||
? "scheduled"
|
||||
: scheduler.isRunning
|
||||
? "live"
|
||||
: "idle",
|
||||
},
|
||||
{ key: "Terminal", value: `${renderer.width} x ${renderer.height}` },
|
||||
])
|
||||
|
||||
previousTime = now
|
||||
previousCpu = cpu
|
||||
eventLoopLag = 0
|
||||
}, sampleInterval)
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(eventLoopTimer)
|
||||
clearInterval(sample)
|
||||
renderer.setGatherStats(false)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function megabytes(bytes: number) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function milliseconds(value: number | undefined) {
|
||||
return value === undefined ? "n/a" : `${value.toFixed(2)} ms`
|
||||
}
|
||||
|
||||
function microseconds(value: number | undefined) {
|
||||
return value === undefined ? "n/a" : milliseconds(value / 1_000)
|
||||
}
|
||||
@@ -125,10 +125,16 @@ export const Info = Schema.Struct({
|
||||
mini: Schema.optional(
|
||||
Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide model reasoning in Mini",
|
||||
description: "Show or hide model reasoning",
|
||||
}),
|
||||
shell_output: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide raw shell tool output in Mini",
|
||||
description: "Show or hide raw shell tool output",
|
||||
}),
|
||||
turn_summary: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide the agent, model, and duration summary in scrollback",
|
||||
}),
|
||||
mono: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Use monochrome ASCII output",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Mini transcript presentation settings" }),
|
||||
|
||||
+241
-185
@@ -1,7 +1,7 @@
|
||||
// Client data layer: apply server events and cache API reads into a Solid store.
|
||||
// Prefer straightforward projection. Do not add generation counters, stale-response
|
||||
// merges, live/history overlays, or other race machinery here—last write wins.
|
||||
// Reconnect invalidates cached reads; active UI owners decide what to sync again.
|
||||
// Prefer straightforward projection. API reads replace cached state, except admitted
|
||||
// inputs survive history refresh until the server projects them. Reconnect invalidates
|
||||
// cached reads; active UI owners decide what to sync again.
|
||||
|
||||
import type {
|
||||
AgentInfo,
|
||||
@@ -19,9 +19,6 @@ import type {
|
||||
ReferenceInfo,
|
||||
SessionMessageInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
SessionInfo,
|
||||
SessionPendingInfo,
|
||||
ShellInfo,
|
||||
@@ -33,10 +30,11 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { SessionContent } from "../routes/session/content"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
export const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
|
||||
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
|
||||
// server cannot recover their Location when settling them. Preserve the event Location
|
||||
@@ -71,7 +69,6 @@ type Store = {
|
||||
active: Record<string, DataSessionStatus>
|
||||
message: Record<string, SessionMessageInfo[]>
|
||||
pending: Record<string, SessionPendingInfo[]>
|
||||
input: Record<string, string[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
||||
form: Record<string, FormWithLocation[]>
|
||||
@@ -82,6 +79,11 @@ type Store = {
|
||||
location: Record<string, LocationData>
|
||||
}
|
||||
|
||||
type PendingOperation =
|
||||
| { type: "admitted"; item: SessionPendingInfo }
|
||||
| { type: "promoted"; inputID: string }
|
||||
| { type: "reverted"; to: string }
|
||||
|
||||
function locationKey(location: LocationRef) {
|
||||
return JSON.stringify([location.directory, location.workspaceID])
|
||||
}
|
||||
@@ -131,7 +133,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
active: {},
|
||||
message: {},
|
||||
pending: {},
|
||||
input: {},
|
||||
permission: {},
|
||||
form: {},
|
||||
},
|
||||
@@ -146,19 +147,63 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
directory: process.cwd(),
|
||||
})
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
// Assistant content lives in per-message keyed part slots, not the Solid
|
||||
// store: streaming deltas publish one slot instead of reconciling arrays.
|
||||
const content = SessionContent.make()
|
||||
// Variant-scoped slot operations: each owns its part address scheme and
|
||||
// type guard so the streaming handlers below read as pure transforms.
|
||||
// A missing collection or key is a normal straggler race and no-ops.
|
||||
const modifyText = (
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number },
|
||||
f: (part: Extract<SessionContent.Part, { type: "text" }>) => SessionContent.Part,
|
||||
) =>
|
||||
content
|
||||
.get(data.sessionID, data.assistantMessageID)
|
||||
?.modify(SessionContent.textID(data.ordinal), (part) => (part.type === "text" ? f(part) : part))
|
||||
const modifyReasoning = (
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number },
|
||||
f: (part: Extract<SessionContent.Part, { type: "reasoning" }>) => SessionContent.Part,
|
||||
) =>
|
||||
content
|
||||
.get(data.sessionID, data.assistantMessageID)
|
||||
?.modify(SessionContent.reasoningID(data.ordinal), (part) => (part.type === "reasoning" ? f(part) : part))
|
||||
const modifyTool = (
|
||||
data: { sessionID: string; assistantMessageID: string; callID: string },
|
||||
f: (part: Extract<SessionContent.Part, { type: "tool" }>) => SessionContent.Part,
|
||||
) =>
|
||||
content
|
||||
.get(data.sessionID, data.assistantMessageID)
|
||||
?.modify(data.callID, (part) => (part.type === "tool" ? f(part) : part))
|
||||
const insertPart = (data: { sessionID: string; assistantMessageID: string }, part: SessionContent.Part) => {
|
||||
const parts = content.ensure(data.sessionID, data.assistantMessageID)
|
||||
if (!parts.has(part.partID)) parts.insert(part)
|
||||
}
|
||||
// Shared settlement trailer for tool success and failure.
|
||||
const settled = (
|
||||
part: Extract<SessionContent.Part, { type: "tool" }>,
|
||||
data: { executed: boolean; resultState?: Extract<SessionContent.Part, { type: "tool" }>["providerResultState"] },
|
||||
completed: number,
|
||||
) => ({
|
||||
executed: data.executed || part.executed === true,
|
||||
providerResultState: data.resultState,
|
||||
time: { ...part.time, completed },
|
||||
})
|
||||
const sync = createSync()
|
||||
const pendingOperations = new Map<string, PendingOperation[]>()
|
||||
|
||||
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
||||
setStore("session", "active", sessionID, status)
|
||||
}
|
||||
|
||||
function addPending(item: SessionPendingInfo) {
|
||||
pendingOperations.get(item.sessionID)?.push({ type: "admitted", item })
|
||||
if (store.session.pending[item.sessionID]?.some((pending) => pending.id === item.id)) return
|
||||
setStore("session", "pending", item.sessionID, [...(store.session.pending[item.sessionID] ?? []), item])
|
||||
}
|
||||
|
||||
function removePending(sessionID: string, inputID?: string) {
|
||||
if (!inputID) return
|
||||
pendingOperations.get(sessionID)?.push({ type: "promoted", inputID })
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
@@ -167,6 +212,36 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
}
|
||||
|
||||
function pendingInputs(sessionID: string) {
|
||||
return (store.session.pending[sessionID] ?? []).filter((item) => item.type !== "compaction")
|
||||
}
|
||||
|
||||
function syncPending(sessionID: string) {
|
||||
return sync.run(`session.pending:${sessionID}`, async () => {
|
||||
const operations = pendingOperations.get(sessionID) ?? []
|
||||
pendingOperations.set(sessionID, operations)
|
||||
try {
|
||||
const pending = new Map((await client.api.session.pending.list({ sessionID })).map((item) => [item.id, item]))
|
||||
operations.forEach((operation) => {
|
||||
if (operation.type === "admitted") {
|
||||
pending.set(operation.item.id, operation.item)
|
||||
return
|
||||
}
|
||||
if (operation.type === "promoted") {
|
||||
pending.delete(operation.inputID)
|
||||
return
|
||||
}
|
||||
pending.forEach((_, id) => {
|
||||
if (id >= operation.to) pending.delete(id)
|
||||
})
|
||||
})
|
||||
setStore("session", "pending", sessionID, reconcile([...pending.values()]))
|
||||
} finally {
|
||||
if (pendingOperations.get(sessionID) === operations) pendingOperations.delete(sessionID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const message = {
|
||||
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
||||
setStore(
|
||||
@@ -199,19 +274,32 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
|
||||
return item?.type === "compaction" ? item : undefined
|
||||
},
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool =>
|
||||
item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
},
|
||||
latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
},
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
fromPending(item: SessionPendingInfo): SessionMessageInfo {
|
||||
if (item.type === "user")
|
||||
return {
|
||||
id: item.id,
|
||||
type: "user",
|
||||
...item.data,
|
||||
time: { created: item.timeCreated },
|
||||
}
|
||||
if (item.type === "synthetic")
|
||||
return {
|
||||
id: item.id,
|
||||
type: "synthetic",
|
||||
...item.data,
|
||||
time: { created: item.timeCreated },
|
||||
}
|
||||
// Placeholder row until the server projects the real compaction;
|
||||
// pending compactions carry no reason, so "manual" is a stand-in.
|
||||
return {
|
||||
id: item.id,
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: "manual",
|
||||
summary: "",
|
||||
recent: "",
|
||||
time: { created: item.timeCreated },
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -269,6 +357,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
messageIndex.delete(sessionID)
|
||||
content.drop(sessionID)
|
||||
pendingOperations.delete(sessionID)
|
||||
sync.invalidate(`session:${sessionID}`)
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
@@ -281,7 +371,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
delete draft.active[sessionID]
|
||||
delete draft.message[sessionID]
|
||||
delete draft.pending[sessionID]
|
||||
delete draft.input[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
delete draft.form[sessionID]
|
||||
for (const [rootID, family] of Object.entries(draft.family)) {
|
||||
@@ -353,6 +442,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
void client.api.session
|
||||
.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
.then((item) => {
|
||||
if (item.type === "assistant") content.seed(event.data.sessionID, item.id, item.content)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(item.id)
|
||||
if (position === undefined) return message.append(draft, index, item)
|
||||
@@ -374,59 +464,35 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
break
|
||||
case "session.input.promoted": {
|
||||
const pending = store.session.pending[event.data.sessionID]?.some((item) => item.id === event.data.inputID)
|
||||
removePending(event.data.sessionID, event.data.inputID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
if (position === undefined) return
|
||||
const existing = draft[position]
|
||||
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
|
||||
if (!existing || !pending) return
|
||||
existing.time.created = event.created
|
||||
draft.splice(position, 1)
|
||||
draft.push(existing)
|
||||
index.clear()
|
||||
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
|
||||
})
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "session.input.admitted":
|
||||
addPending({
|
||||
case "session.input.admitted": {
|
||||
const pending: SessionPendingInfo = {
|
||||
id: event.data.inputID,
|
||||
sessionID: event.data.sessionID,
|
||||
admittedSeq: event.durable.seq,
|
||||
timeCreated: event.created,
|
||||
...event.data.input,
|
||||
})
|
||||
if (!store.session.input[event.data.sessionID]?.includes(event.data.inputID))
|
||||
setStore("session", "input", event.data.sessionID, [
|
||||
...(store.session.input[event.data.sessionID] ?? []),
|
||||
event.data.inputID,
|
||||
])
|
||||
}
|
||||
addPending(pending)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(
|
||||
draft,
|
||||
index,
|
||||
event.data.input.type === "user"
|
||||
? {
|
||||
id: event.data.inputID,
|
||||
type: "user",
|
||||
...event.data.input.data,
|
||||
time: { created: event.created },
|
||||
}
|
||||
: {
|
||||
id: event.data.inputID,
|
||||
type: "synthetic",
|
||||
...event.data.input.data,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
message.append(draft, index, message.fromPending(pending))
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.instructions.updated":
|
||||
const instructions = event.metadata?.instructions
|
||||
if (
|
||||
@@ -541,142 +607,109 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.text.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
insertPart(event.data, { type: "text", text: "", partID: SessionContent.textID(event.data.ordinal) })
|
||||
break
|
||||
case "session.text.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
modifyText(event.data, (part) => ({ ...part, text: part.text + event.data.delta }))
|
||||
break
|
||||
case "session.text.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text = event.data.text
|
||||
})
|
||||
modifyText(event.data, (part) => ({ ...part, text: event.data.text }))
|
||||
break
|
||||
case "session.tool.input.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.created },
|
||||
state: { status: "streaming", input: "" },
|
||||
})
|
||||
insertPart(event.data, {
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.created },
|
||||
state: { status: "streaming", input: "" },
|
||||
partID: event.data.callID,
|
||||
})
|
||||
break
|
||||
case "session.tool.input.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input += event.data.delta
|
||||
})
|
||||
modifyTool(event.data, (part) =>
|
||||
part.state.status !== "streaming"
|
||||
? part
|
||||
: { ...part, state: { ...part.state, input: part.state.input + event.data.delta } },
|
||||
)
|
||||
break
|
||||
case "session.tool.input.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input = event.data.text
|
||||
})
|
||||
modifyTool(event.data, (part) =>
|
||||
part.state.status !== "streaming" ? part : { ...part, state: { ...part.state, input: event.data.text } },
|
||||
)
|
||||
break
|
||||
case "session.tool.called":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (!match) return
|
||||
match.time.ran = event.created
|
||||
match.executed = event.data.executed
|
||||
match.providerState = event.data.state
|
||||
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
|
||||
})
|
||||
modifyTool(event.data, (part) => ({
|
||||
...part,
|
||||
time: { ...part.time, ran: event.created },
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||
}))
|
||||
break
|
||||
case "session.tool.progress":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.structured = event.data.structured
|
||||
match.state.content = [...event.data.content]
|
||||
})
|
||||
modifyTool(event.data, (part) =>
|
||||
part.state.status !== "running"
|
||||
? part
|
||||
: {
|
||||
...part,
|
||||
state: { ...part.state, structured: event.data.structured, content: [...event.data.content] },
|
||||
},
|
||||
)
|
||||
break
|
||||
case "session.tool.success":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
result: event.data.result,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
})
|
||||
modifyTool(event.data, (part) =>
|
||||
part.state.status !== "running"
|
||||
? part
|
||||
: {
|
||||
...part,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
result: event.data.result,
|
||||
},
|
||||
...settled(part, event.data, event.created),
|
||||
},
|
||||
)
|
||||
break
|
||||
case "session.tool.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
||||
structured: match.state.status === "running" ? match.state.structured : {},
|
||||
content: match.state.status === "running" ? match.state.content : [],
|
||||
result: event.data.result,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
})
|
||||
modifyTool(event.data, (part) =>
|
||||
part.state.status !== "streaming" && part.state.status !== "running"
|
||||
? part
|
||||
: {
|
||||
...part,
|
||||
state: {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof part.state.input === "string" ? {} : part.state.input,
|
||||
structured: part.state.status === "running" ? part.state.structured : {},
|
||||
content: part.state.status === "running" ? part.state.content : [],
|
||||
result: event.data.result,
|
||||
},
|
||||
...settled(part, event.data, event.created),
|
||||
},
|
||||
)
|
||||
break
|
||||
case "session.reasoning.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
})
|
||||
insertPart(event.data, {
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
partID: SessionContent.reasoningID(event.data.ordinal),
|
||||
})
|
||||
break
|
||||
case "session.reasoning.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
modifyReasoning(event.data, (part) => ({ ...part, text: part.text + event.data.delta }))
|
||||
break
|
||||
case "session.reasoning.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.state !== undefined) match.state = event.data.state
|
||||
}
|
||||
})
|
||||
modifyReasoning(event.data, (part) => ({
|
||||
...part,
|
||||
text: event.data.text,
|
||||
time: { created: part.time?.created ?? event.created, completed: event.created },
|
||||
state: event.data.state !== undefined ? event.data.state : part.state,
|
||||
}))
|
||||
break
|
||||
case "session.retry.scheduled":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
@@ -736,16 +769,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
}
|
||||
pendingOperations.get(event.data.sessionID)?.push({ type: "reverted", to: event.data.to })
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
"pending",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
|
||||
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
|
||||
)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||
if (position === -1) return
|
||||
for (const item of draft.splice(position)) index.delete(item.id)
|
||||
for (const item of draft.splice(position)) {
|
||||
index.delete(item.id)
|
||||
content.drop(event.data.sessionID, item.id)
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.compaction.delta":
|
||||
@@ -914,10 +951,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
input: {
|
||||
list(sessionID: string) {
|
||||
return store.session.input[sessionID] ?? []
|
||||
return pendingInputs(sessionID).map((item) => item.id)
|
||||
},
|
||||
has(sessionID: string, inputID: string) {
|
||||
return store.session.input[sessionID]?.includes(inputID) ?? false
|
||||
return pendingInputs(sessionID).some((item) => item.id === inputID)
|
||||
},
|
||||
},
|
||||
pending: {
|
||||
@@ -925,16 +962,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.session.pending[sessionID] ?? []
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session.pending:${sessionID}`, async () => {
|
||||
const pending = await client.api.session.pending.list({ sessionID })
|
||||
setStore("session", "pending", sessionID, reconcile(pending))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
sessionID,
|
||||
reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||
)
|
||||
})
|
||||
return syncPending(sessionID)
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
@@ -960,13 +988,41 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session.message:${sessionID}`, async () => {
|
||||
const messages = (
|
||||
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
||||
).data.toReversed()
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
await syncPending(sessionID)
|
||||
const inputIDs = () => pendingInputs(sessionID).map((item) => item.id)
|
||||
// Snapshot pending state before the fetch: inputs admitted before
|
||||
// the fetch must survive even if the server promotes them while
|
||||
// the list request is in flight. The post-fetch snapshot covers
|
||||
// inputs admitted during the fetch.
|
||||
const localInputs = inputIDs()
|
||||
const pendingMessages = (store.session.pending[sessionID] ?? []).map(message.fromPending)
|
||||
const projected = await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
||||
const next = projected.data.toReversed()
|
||||
const index = new Map(next.map((message, index) => [message.id, index]))
|
||||
const localInputIDs = new Set([...localInputs, ...inputIDs()])
|
||||
localInputIDs.forEach((messageID) => {
|
||||
const position = messageIndex.get(sessionID)?.get(messageID)
|
||||
const item = position === undefined ? undefined : store.session.message[sessionID]?.[position]
|
||||
if (item) message.append(next, index, item)
|
||||
})
|
||||
pendingMessages.forEach((item) => message.append(next, index, item))
|
||||
messageIndex.set(sessionID, index)
|
||||
// Reseed part collections in place from the final hydrated list;
|
||||
// prune only collections whose messages are truly gone.
|
||||
content.prune(sessionID, new Set(next.map((message) => message.id)))
|
||||
for (const item of next) {
|
||||
if (item.type === "assistant") content.seed(sessionID, item.id, item.content)
|
||||
}
|
||||
setStore("session", "message", sessionID, reconcile(next))
|
||||
})
|
||||
},
|
||||
parts(sessionID: string, messageID: string) {
|
||||
// Deliberately ensure-on-read: consumers capture the collection
|
||||
// for their lifetime (see useSlot), so identity per (session,
|
||||
// message) must be stable even when reads precede streaming.
|
||||
// Stray collections are reclaimed by prune on the next sync.
|
||||
return content.ensure(sessionID, messageID)
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
},
|
||||
|
||||
@@ -4,10 +4,12 @@ import { createSignal } from "solid-js"
|
||||
|
||||
export type Value = string | number | boolean | null
|
||||
|
||||
export type Entry = Readonly<{ key: string; value: Value }>
|
||||
|
||||
export type Group = Readonly<{
|
||||
id: string
|
||||
title: string
|
||||
entries: readonly Readonly<{ key: string; value: Value }>[]
|
||||
entries: readonly Entry[]
|
||||
}>
|
||||
|
||||
const [groups, setGroups] = createSignal<readonly Group[]>([])
|
||||
@@ -35,6 +37,16 @@ export function register(input: { id: string; title: string }) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
setAll(entries: readonly Entry[]) {
|
||||
setGroups((groups) =>
|
||||
groups.map((group) => {
|
||||
if (group.id !== input.id) return group
|
||||
const next = new Map(group.entries.map((entry) => [entry.key, entry]))
|
||||
entries.forEach((entry) => next.set(entry.key, entry))
|
||||
return { ...group, entries: [...next.values()] }
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { toolEntryBody } from "./tool"
|
||||
import { monoPrefix, monoToolText } from "./mono"
|
||||
import type { RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
|
||||
|
||||
export type EntryFlags = {
|
||||
@@ -48,17 +49,17 @@ function markdownBody(content: string): RunEntryBody {
|
||||
}
|
||||
}
|
||||
|
||||
function userBody(raw: string): RunEntryBody {
|
||||
function userBody(raw: string, mono: boolean): RunEntryBody {
|
||||
if (!raw.trim()) {
|
||||
return RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
const lead = raw.match(/^\n+/)?.[0] ?? ""
|
||||
const body = lead ? raw.slice(lead.length) : raw
|
||||
return textBody(`${lead}› ${body}`)
|
||||
return textBody(`${lead}${mono ? ">" : "›"} ${body}`)
|
||||
}
|
||||
|
||||
function reasoningBody(raw: string): RunEntryBody {
|
||||
function reasoningBody(raw: string, mono: boolean): RunEntryBody {
|
||||
const clean = raw.replace(/\[REDACTED\]/g, "")
|
||||
if (!clean) {
|
||||
return RUN_ENTRY_NONE
|
||||
@@ -68,16 +69,39 @@ function reasoningBody(raw: string): RunEntryBody {
|
||||
const body = lead ? clean.slice(lead.length) : clean
|
||||
const mark = "Thinking:"
|
||||
if (body.startsWith(mark)) {
|
||||
if (mono) return textBody(`${lead}${mark} ${body.slice(mark.length).trimStart()}`)
|
||||
return codeBody(`${lead}_Thinking:_ ${body.slice(mark.length).trimStart()}`, "markdown")
|
||||
}
|
||||
|
||||
return codeBody(clean, "markdown")
|
||||
return mono ? textBody(clean) : codeBody(clean, "markdown")
|
||||
}
|
||||
|
||||
function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody {
|
||||
return textBody(phase === "progress" ? raw : raw.trim())
|
||||
}
|
||||
|
||||
function monoBody(body: RunEntryBody): RunEntryBody {
|
||||
if (body.type === "none" || body.type === "text") return body
|
||||
if (body.type === "code" || body.type === "markdown") return textBody(body.content)
|
||||
const snapshot = body.snapshot
|
||||
if (snapshot.kind === "code") return textBody(`${snapshot.title}\n${snapshot.content}`)
|
||||
if (snapshot.kind === "diff") {
|
||||
return textBody(
|
||||
snapshot.items
|
||||
.map((item) => `${item.title}\n${item.diff.trim() || `-${item.deletions ?? 0} lines`}`)
|
||||
.join("\n\n"),
|
||||
)
|
||||
}
|
||||
if (snapshot.kind === "task") {
|
||||
return textBody([snapshot.title, ...snapshot.rows, snapshot.tail].filter(Boolean).join("\n"))
|
||||
}
|
||||
return textBody(
|
||||
["# Questions", ...snapshot.items.flatMap((item) => [item.question, item.answer]), snapshot.tail]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
export function entryFlags(commit: StreamCommit): EntryFlags {
|
||||
if (commit.summary) {
|
||||
return {
|
||||
@@ -168,13 +192,17 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
|
||||
}
|
||||
|
||||
const raw = cleanRunText(commit.text)
|
||||
const mono = options?.mono === true
|
||||
|
||||
if (commit.kind === "user") {
|
||||
return userBody(raw)
|
||||
return userBody(raw, mono)
|
||||
}
|
||||
|
||||
if (commit.kind === "tool") {
|
||||
return toolEntryBody(commit, raw, options) ?? RUN_ENTRY_NONE
|
||||
const body = toolEntryBody(commit, raw, options) ?? RUN_ENTRY_NONE
|
||||
const result = mono ? monoBody(body) : body
|
||||
if (!mono || body.type !== "text" || result.type !== "text" || commit.phase === "progress") return result
|
||||
return textBody(monoToolText(result.content, true))
|
||||
}
|
||||
|
||||
if (commit.kind === "assistant") {
|
||||
@@ -186,7 +214,7 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
|
||||
return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return markdownBody(raw)
|
||||
return mono ? textBody(raw) : markdownBody(raw)
|
||||
}
|
||||
|
||||
if (commit.kind === "reasoning") {
|
||||
@@ -198,8 +226,10 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
|
||||
return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE
|
||||
}
|
||||
|
||||
return reasoningBody(raw)
|
||||
return reasoningBody(raw, mono)
|
||||
}
|
||||
|
||||
return systemBody(raw, commit.phase)
|
||||
const body = systemBody(raw, commit.phase)
|
||||
if (!mono || body.type !== "text") return body
|
||||
return textBody(monoPrefix(body.content, true))
|
||||
}
|
||||
|
||||
@@ -62,29 +62,13 @@ type SettingEntry = PanelEntry & {
|
||||
}
|
||||
|
||||
const PANEL_PAD = 2
|
||||
const panelPad = (mono?: boolean) => (mono ? 1 : PANEL_PAD)
|
||||
const PANEL_LIST_ROWS = 10
|
||||
const PANEL_FRAME_ROWS = 6
|
||||
export const RUN_COMMAND_PANEL_ROWS = PANEL_LIST_ROWS + PANEL_FRAME_ROWS
|
||||
const SUBAGENT_LIST_ROWS = 12
|
||||
export const RUN_SUBAGENT_PANEL_ROWS = SUBAGENT_LIST_ROWS + PANEL_FRAME_ROWS
|
||||
const PANEL_PAGE = PANEL_LIST_ROWS - 1
|
||||
const PANEL_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "┃",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: " ",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
}
|
||||
const PANEL_BOTTOM_BORDER = {
|
||||
...PANEL_BORDER,
|
||||
vertical: "╹",
|
||||
}
|
||||
const HALF_BLOCK_BORDER = {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
@@ -280,19 +264,17 @@ function PanelShell(props: {
|
||||
onQuery: (query: string) => void
|
||||
children: JSX.Element
|
||||
hint?: string
|
||||
dark?: boolean
|
||||
chrome?: "default" | "minimal"
|
||||
mono?: boolean
|
||||
}) {
|
||||
const background = () => (props.dark ? props.theme().shade : props.theme().surface)
|
||||
const minimal = () => props.chrome === "minimal"
|
||||
const background = () => props.theme().shade
|
||||
const content = (
|
||||
<>
|
||||
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
@@ -308,15 +290,15 @@ function PanelShell(props: {
|
||||
) : null}
|
||||
<box flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
<text fg={props.theme().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.hint ? `${props.hint} · ` : ""}esc
|
||||
{props.hint ? `${props.hint} ${props.mono ? "-" : "·"} ` : ""}esc
|
||||
</text>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} backgroundColor={background()} />
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
flexShrink={0}
|
||||
backgroundColor={background()}
|
||||
>
|
||||
@@ -347,25 +329,11 @@ function PanelShell(props: {
|
||||
)
|
||||
return (
|
||||
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
|
||||
{minimal() ? (
|
||||
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
|
||||
{content}
|
||||
</box>
|
||||
) : (
|
||||
<box
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
border={["left"]}
|
||||
borderColor={props.theme().highlight}
|
||||
backgroundColor="transparent"
|
||||
customBorderChars={PANEL_BORDER}
|
||||
flexShrink={0}
|
||||
>
|
||||
{content}
|
||||
</box>
|
||||
)}
|
||||
{minimal() ? (
|
||||
<box width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
|
||||
<box width="100%" flexDirection="column" border={false} backgroundColor="transparent" flexShrink={0}>
|
||||
{content}
|
||||
</box>
|
||||
<box width="100%" height={1} border={false} backgroundColor="transparent" flexShrink={0}>
|
||||
{props.mono ? null : (
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
@@ -374,27 +342,8 @@ function PanelShell(props: {
|
||||
backgroundColor="transparent"
|
||||
customBorderChars={HALF_BLOCK_BORDER}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["left"]}
|
||||
borderColor={props.theme().highlight}
|
||||
backgroundColor="transparent"
|
||||
customBorderChars={PANEL_BOTTOM_BORDER}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["bottom"]}
|
||||
borderColor={background()}
|
||||
backgroundColor="transparent"
|
||||
customBorderChars={HALF_BLOCK_BORDER}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -418,6 +367,7 @@ export function RunCommandMenuBody(props: {
|
||||
onCommand: (name: string) => void
|
||||
onNew: () => void
|
||||
onExit: () => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
|
||||
@@ -433,18 +383,18 @@ export function RunCommandMenuBody(props: {
|
||||
},
|
||||
...(props.subagents().length > 0
|
||||
? [
|
||||
{
|
||||
action: "subagent" as const,
|
||||
category: "Session",
|
||||
display: "View subagents",
|
||||
footer:
|
||||
activeSubagentCount() > 0 ? `${activeSubagentCount()} active` : `${props.subagents().length} recent`,
|
||||
keywords: props
|
||||
.subagents()
|
||||
.map((item) => `${item.label} ${item.description} ${item.title ?? ""}`)
|
||||
.join(" "),
|
||||
},
|
||||
]
|
||||
{
|
||||
action: "subagent" as const,
|
||||
category: "Session",
|
||||
display: "View subagents",
|
||||
footer:
|
||||
activeSubagentCount() > 0 ? `${activeSubagentCount()} active` : `${props.subagents().length} recent`,
|
||||
keywords: props
|
||||
.subagents()
|
||||
.map((item) => `${item.label} ${item.description} ${item.title ?? ""}`)
|
||||
.join(" "),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
action: "slash",
|
||||
@@ -458,16 +408,16 @@ export function RunCommandMenuBody(props: {
|
||||
const prompt: CommandEntry[] =
|
||||
props.commands() === undefined || skills().length > 0
|
||||
? [
|
||||
{
|
||||
action: "skill" as const,
|
||||
category: "Prompt",
|
||||
display: "Skills",
|
||||
footer: "/skills",
|
||||
keywords: `skill skills ${skills()
|
||||
.map((item) => `${item.name} ${item.description ?? ""}`)
|
||||
.join(" ")}`.trim(),
|
||||
},
|
||||
]
|
||||
{
|
||||
action: "skill" as const,
|
||||
category: "Prompt",
|
||||
display: "Skills",
|
||||
footer: "/skills",
|
||||
keywords: `skill skills ${skills()
|
||||
.map((item) => `${item.name} ${item.description ?? ""}`)
|
||||
.join(" ")}`.trim(),
|
||||
},
|
||||
]
|
||||
: []
|
||||
const agent: CommandEntry[] = [
|
||||
{
|
||||
@@ -477,17 +427,17 @@ export function RunCommandMenuBody(props: {
|
||||
},
|
||||
...(props.queued().length > 0
|
||||
? [
|
||||
{
|
||||
action: "queued" as const,
|
||||
category: "Agent",
|
||||
display: "View pending work",
|
||||
footer: `${props.queued().length} pending`,
|
||||
keywords: props
|
||||
.queued()
|
||||
.map((item) => item.prompt.text)
|
||||
.join(" "),
|
||||
},
|
||||
]
|
||||
{
|
||||
action: "queued" as const,
|
||||
category: "Agent",
|
||||
display: "View pending work",
|
||||
footer: `${props.queued().length} pending`,
|
||||
keywords: props
|
||||
.queued()
|
||||
.map((item) => item.prompt.text)
|
||||
.join(" "),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
action: "variant.cycle",
|
||||
@@ -498,13 +448,13 @@ export function RunCommandMenuBody(props: {
|
||||
},
|
||||
...(props.variants().length > 0
|
||||
? [
|
||||
{
|
||||
action: "variant.list" as const,
|
||||
category: "Agent",
|
||||
display: "Switch model variant",
|
||||
keywords: `variant variants ${props.variants().join(" ")}`,
|
||||
},
|
||||
]
|
||||
{
|
||||
action: "variant.list" as const,
|
||||
category: "Agent",
|
||||
display: "Switch model variant",
|
||||
keywords: `variant variants ${props.variants().join(" ")}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const commands = (props.commands() ?? [])
|
||||
@@ -533,7 +483,7 @@ export function RunCommandMenuBody(props: {
|
||||
{
|
||||
action: "settings",
|
||||
category: "System",
|
||||
display: "Open settings",
|
||||
display: "Settings",
|
||||
footer: "/settings",
|
||||
keywords: "/settings settings preferences configuration",
|
||||
},
|
||||
@@ -611,8 +561,7 @@ export function RunCommandMenuBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
@@ -623,11 +572,12 @@ export function RunCommandMenuBody(props: {
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty="No results found"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
grouped={!controller.query().trim()}
|
||||
background
|
||||
headerColor={props.theme().muted}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
@@ -638,32 +588,48 @@ export function RunSettingsBody(props: {
|
||||
settings: Accessor<MiniSettings>
|
||||
onClose: () => void
|
||||
onChange: (change: MiniSettingChange) => void | Promise<void>
|
||||
mono?: boolean
|
||||
}) {
|
||||
const [saving, setSaving] = createSignal<keyof MiniSettings>()
|
||||
const entries = createMemo<SettingEntry[]>(() => [
|
||||
{
|
||||
category: "Transcript",
|
||||
display: "Thinking",
|
||||
description: "future sessions",
|
||||
footer: saving() === "thinking" ? "saving" : props.settings().thinking,
|
||||
keywords: `thinking reasoning ${props.settings().thinking}`,
|
||||
key: "thinking",
|
||||
},
|
||||
{
|
||||
category: "Transcript",
|
||||
display: "Shell tool output",
|
||||
description: "model-issued commands",
|
||||
display: "Shell",
|
||||
footer: saving() === "shell_output" ? "saving" : props.settings().shell_output,
|
||||
keywords: `shell tool command output ${props.settings().shell_output}`,
|
||||
key: "shell_output",
|
||||
},
|
||||
{
|
||||
category: "Transcript",
|
||||
display: "Turn summary",
|
||||
footer: saving() === "turn_summary" ? "saving" : props.settings().turn_summary,
|
||||
keywords: `turn summary agent model duration ${props.settings().turn_summary}`,
|
||||
key: "turn_summary",
|
||||
},
|
||||
{
|
||||
category: "Terminal",
|
||||
display: "Monochrome UI",
|
||||
footer: saving() === "mono" ? "saving" : props.settings().mono ? "on" : "off",
|
||||
keywords: `mono monochrome ascii legacy compat terminal ${props.settings().mono ? "on" : "off"}`,
|
||||
key: "mono",
|
||||
},
|
||||
])
|
||||
const change = (item: SettingEntry) => {
|
||||
if (saving()) return
|
||||
const current = props.settings()[item.key]
|
||||
const next: MiniSettingChange =
|
||||
item.key === "mono"
|
||||
? { key: "mono", value: !props.settings().mono }
|
||||
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
|
||||
setSaving(item.key)
|
||||
void Promise.resolve(props.onChange({ key: item.key, value: current === "show" ? "hide" : "show" }))
|
||||
.catch(() => {})
|
||||
void Promise.resolve(props.onChange(next))
|
||||
.catch(() => { })
|
||||
.finally(() => setSaving())
|
||||
}
|
||||
const controller = createSearchablePanelController({
|
||||
@@ -692,8 +658,7 @@ export function RunSettingsBody(props: {
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
hint="left/right change"
|
||||
dark
|
||||
chrome="minimal"
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
@@ -704,11 +669,12 @@ export function RunSettingsBody(props: {
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty="No settings found"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
grouped={!controller.query().trim()}
|
||||
background
|
||||
headerColor={props.theme().muted}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
@@ -721,6 +687,7 @@ export function RunSubagentSelectBody(props: {
|
||||
onClose: () => void
|
||||
onSelect: (sessionID: string) => void
|
||||
onRows?: (rows: number) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const entries = createMemo<SubagentEntry[]>(() =>
|
||||
props.tabs().map((item) => {
|
||||
@@ -756,8 +723,7 @@ export function RunSubagentSelectBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
@@ -768,10 +734,11 @@ export function RunSubagentSelectBody(props: {
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No subagents found"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
grouped={false}
|
||||
background
|
||||
mono={props.mono}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
@@ -782,6 +749,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
prompts: Accessor<FooterQueuedPrompt[]>
|
||||
onClose: () => void
|
||||
onRows?: (rows: number) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const entries = createMemo<QueuedEntry[]>(() =>
|
||||
props.prompts().map((prompt) => ({
|
||||
@@ -810,8 +778,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
@@ -822,10 +789,11 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No pending work"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
grouped={false}
|
||||
background
|
||||
mono={props.mono}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
@@ -836,6 +804,7 @@ export function RunSkillSelectBody(props: {
|
||||
commands: Accessor<RunCommand[] | undefined>
|
||||
onClose: () => void
|
||||
onSelect: (name: string) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const entries = createMemo<SkillEntry[]>(() =>
|
||||
(props.commands() ?? [])
|
||||
@@ -866,8 +835,7 @@ export function RunSkillSelectBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
@@ -878,10 +846,11 @@ export function RunSkillSelectBody(props: {
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty={props.commands() ? "No skills found" : "Skills loading"}
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
grouped={false}
|
||||
background
|
||||
mono={props.mono}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
@@ -893,6 +862,7 @@ export function RunVariantSelectBody(props: {
|
||||
current: Accessor<string | undefined>
|
||||
onClose: () => void
|
||||
onSelect: (variant: string | undefined) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const entries = createMemo<VariantEntry[]>(() => [
|
||||
{
|
||||
@@ -930,8 +900,7 @@ export function RunVariantSelectBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
@@ -942,10 +911,11 @@ export function RunVariantSelectBody(props: {
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty="No results found"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
grouped={false}
|
||||
background
|
||||
mono={props.mono}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
@@ -957,6 +927,7 @@ export function RunModelSelectBody(props: {
|
||||
current: Accessor<RunInput["model"]>
|
||||
onClose: () => void
|
||||
onSelect: (model: NonNullable<RunInput["model"]>) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const entries = createMemo<ModelEntry[]>(() =>
|
||||
(props.providers() ?? [])
|
||||
@@ -1017,8 +988,7 @@ export function RunModelSelectBody(props: {
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
mono={props.mono}
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
@@ -1029,11 +999,12 @@ export function RunModelSelectBody(props: {
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty={props.providers() ? "No results found" : "Models loading"}
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
paddingLeft={panelPad(props.mono)}
|
||||
paddingRight={panelPad(props.mono)}
|
||||
grouped={!controller.query().trim()}
|
||||
background
|
||||
headerColor={props.theme().muted}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</PanelShell>
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ export function RunFormBody(props: {
|
||||
openExternal?: (url: string) => Promise<unknown>
|
||||
state?: FormBodyState
|
||||
onState?: (state: FormBodyState) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const [state, setLocalState] = createSignal(props.state ?? createFormBodyState(props.request))
|
||||
const setState = (next: FormBodyState | ((previous: FormBodyState) => FormBodyState)) => {
|
||||
@@ -258,7 +259,7 @@ export function RunFormBody(props: {
|
||||
<box width="100%" height="100%" flexDirection="column" backgroundColor={props.theme.surface}>
|
||||
<box flexDirection="column" gap={1} paddingLeft={2} paddingRight={3} paddingTop={1} flexGrow={1} flexShrink={1}>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={unsupported() ? props.theme.warning : props.theme.highlight}>◆</text>
|
||||
<text fg={unsupported() ? props.theme.warning : props.theme.highlight}>{props.mono ? "*" : "◆"}</text>
|
||||
<text fg={props.theme.text}>{props.request.title}</text>
|
||||
<Show when={!unsupported() && !formSingle(props.request)}>
|
||||
<text fg={props.theme.muted}>
|
||||
@@ -352,7 +353,9 @@ export function RunFormBody(props: {
|
||||
onMouseOver={() => setState((previous) => formSetSelected(previous, index()))}
|
||||
onMouseUp={() => choose(index())}
|
||||
>
|
||||
<text fg={active() ? props.theme.highlight : props.theme.muted}>{index() + 1}.</text>
|
||||
<text fg={active() ? props.theme.highlight : props.theme.muted}>
|
||||
{props.mono ? `${active() ? ">" : " "}${index() + 1}.` : `${index() + 1}.`}
|
||||
</text>
|
||||
<text fg={active() ? props.theme.text : props.theme.muted}>
|
||||
{multiple() ? `[${picked() ? "x" : " "}] ` : ""}
|
||||
{row.label}
|
||||
@@ -368,7 +371,9 @@ export function RunFormBody(props: {
|
||||
<Show when={custom()}>
|
||||
<box flexDirection="row" gap={1} onMouseUp={() => choose(rows().length)}>
|
||||
<text fg={state().selected === rows().length ? props.theme.highlight : props.theme.muted}>
|
||||
{rows().length + 1}.
|
||||
{props.mono
|
||||
? `${state().selected === rows().length ? ">" : " "}${rows().length + 1}.`
|
||||
: `${rows().length + 1}.`}
|
||||
</text>
|
||||
<text fg={state().selected === rows().length ? props.theme.text : props.theme.muted}>
|
||||
Type your own answer
|
||||
@@ -413,7 +418,9 @@ export function RunFormBody(props: {
|
||||
? "enter submit esc dismiss"
|
||||
: textual() || state().editing
|
||||
? "enter save esc dismiss"
|
||||
: "↑↓ select enter choose tab next esc dismiss"}
|
||||
: props.mono
|
||||
? "up/down select enter choose tab next esc dismiss"
|
||||
: "↑↓ select enter choose tab next esc dismiss"}
|
||||
</text>
|
||||
<Show when={state().error}>
|
||||
<text fg={props.theme.error} wrapMode="none" truncate>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { transparent, type RunFooterTheme } from "./theme"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { moveSelection, moveSelectionOffset, reconcileSelection, revealSelectionOffset } from "../ui/select-controller"
|
||||
import { monoTruncate } from "./mono"
|
||||
|
||||
export const FOOTER_MENU_ROWS = 8
|
||||
|
||||
@@ -77,6 +78,7 @@ export function RunFooterMenu(props: {
|
||||
grouped?: boolean
|
||||
background?: boolean
|
||||
headerColor?: ColorInput
|
||||
mono?: boolean
|
||||
}) {
|
||||
const term = useTerminalDimensions()
|
||||
const limit = () => props.limit ?? FOOTER_MENU_ROWS
|
||||
@@ -170,7 +172,8 @@ export function RunFooterMenu(props: {
|
||||
descriptionColumn() -
|
||||
footerWidth -
|
||||
4
|
||||
return Locale.truncate(item.description, Math.max(12, available))
|
||||
const width = Math.max(12, available)
|
||||
return props.mono ? monoTruncate(item.description, width, true) : Locale.truncate(item.description, width)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
@@ -187,7 +190,7 @@ export function RunFooterMenu(props: {
|
||||
>
|
||||
{border() ? (
|
||||
<text fg={props.theme().border} wrapMode="none">
|
||||
┃
|
||||
{props.mono ? "|" : "┃"}
|
||||
</text>
|
||||
) : undefined}
|
||||
<box
|
||||
@@ -224,6 +227,8 @@ export function RunFooterMenu(props: {
|
||||
}
|
||||
|
||||
const active = () => row.index === props.selected()
|
||||
const attributes = () =>
|
||||
active() ? TextAttributes.BOLD | (props.mono ? TextAttributes.INVERSE : 0) : undefined
|
||||
const background = () =>
|
||||
active()
|
||||
? props.background
|
||||
@@ -236,7 +241,7 @@ export function RunFooterMenu(props: {
|
||||
<box paddingRight={0} flexDirection="row" backgroundColor={background()}>
|
||||
{border() ? (
|
||||
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
|
||||
{active() ? "▌" : " "}
|
||||
{active() ? (props.mono ? ">" : "▌") : " "}
|
||||
</text>
|
||||
) : undefined}
|
||||
<box
|
||||
@@ -250,7 +255,7 @@ export function RunFooterMenu(props: {
|
||||
<box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().text}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
attributes={attributes()}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={0}
|
||||
@@ -281,7 +286,7 @@ export function RunFooterMenu(props: {
|
||||
{row.item.footer ? (
|
||||
<text
|
||||
fg={active() ? props.theme().selectedText : props.theme().muted}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
attributes={attributes()}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={0}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// The diff view (when available) uses the same diff component as scrollback
|
||||
// tool snapshots.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { TextAttributes, type TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import {
|
||||
@@ -40,6 +40,7 @@ function buttons(
|
||||
disabled: boolean,
|
||||
onHover: (option: PermissionOption) => void,
|
||||
onSelect: (option: PermissionOption) => void,
|
||||
mono: boolean,
|
||||
) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
@@ -56,7 +57,12 @@ function buttons(
|
||||
if (!disabled) onSelect(option)
|
||||
}}
|
||||
>
|
||||
<text fg={option === selected ? theme.surface : theme.muted}>{permissionLabel(option)}</text>
|
||||
<text
|
||||
fg={option === selected ? theme.surface : theme.muted}
|
||||
attributes={option === selected && mono ? TextAttributes.INVERSE : undefined}
|
||||
>
|
||||
{permissionLabel(option)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
@@ -134,12 +140,20 @@ export function RunPermissionBody(props: {
|
||||
theme: RunFooterTheme
|
||||
block: RunBlockTheme
|
||||
onReply: (input: PermissionReply) => void | Promise<void>
|
||||
mono?: boolean
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [state, setState] = createSignal(createPermissionBodyState(props.request))
|
||||
const info = createMemo(() => permissionInfo(props.request, props.directory?.()))
|
||||
const info = createMemo(() => permissionInfo(props.request, props.directory?.(), props.mono))
|
||||
const ft = createMemo(() => toolFiletype(info().file))
|
||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||
const scrollbar = createMemo(() => ({
|
||||
visible: !props.mono,
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}))
|
||||
const opts = createMemo(() =>
|
||||
permissionOptions(state().stage).filter((option) => option !== "always" || (props.request.save?.length ?? 0) > 0),
|
||||
)
|
||||
@@ -269,7 +283,9 @@ export function RunPermissionBody(props: {
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>△</text>
|
||||
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>
|
||||
{props.mono ? "!" : "△"}
|
||||
</text>
|
||||
<text fg={props.theme.text}>{title()}</text>
|
||||
</box>
|
||||
<Switch>
|
||||
@@ -346,16 +362,7 @@ export function RunPermissionBody(props: {
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} paddingRight={3} paddingBottom={1}>
|
||||
<Switch>
|
||||
<Match when={state().stage === "permission"}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<Show
|
||||
when={info().diff}
|
||||
@@ -427,16 +434,7 @@ export function RunPermissionBody(props: {
|
||||
</scrollbox>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line) => (
|
||||
@@ -472,6 +470,7 @@ export function RunPermissionBody(props: {
|
||||
setState((prev) => permissionHover(prev, option))
|
||||
},
|
||||
run,
|
||||
props.mono ?? false,
|
||||
)}
|
||||
<Show
|
||||
when={!busy()}
|
||||
@@ -483,7 +482,7 @@ export function RunPermissionBody(props: {
|
||||
>
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<text fg={props.theme.text}>
|
||||
{"⇆"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
{props.mono ? "left/right" : "⇆"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>confirm</span>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { parseFileLineRange, parseSlashHead, stripFileLineRange } from "../prompt/parse"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||
import { monoTruncateMiddle } from "./mono"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types"
|
||||
@@ -70,6 +71,7 @@ type PromptInput = {
|
||||
prompt: Accessor<boolean>
|
||||
width: Accessor<number>
|
||||
theme: Accessor<RunFooterTheme>
|
||||
mono: Accessor<boolean>
|
||||
history?: Accessor<RunPrompt[]>
|
||||
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
|
||||
onCycle: () => void
|
||||
@@ -302,7 +304,9 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
const references = createMemo<Auto[]>(() => {
|
||||
return input.references().map((item) => ({
|
||||
kind: "mention",
|
||||
display: Locale.truncateMiddle("@" + item.name, width()),
|
||||
display: input.mono()
|
||||
? monoTruncateMiddle("@" + item.name, width(), true)
|
||||
: Locale.truncateMiddle("@" + item.name, width()),
|
||||
value: item.name,
|
||||
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
|
||||
part: {
|
||||
@@ -344,7 +348,9 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
return {
|
||||
kind: "mention",
|
||||
display: Locale.truncateMiddle("@" + filename, width()),
|
||||
display: input.mono()
|
||||
? monoTruncateMiddle("@" + filename, width(), true)
|
||||
: Locale.truncateMiddle("@" + filename, width()),
|
||||
value: filename,
|
||||
directory: item.endsWith("/"),
|
||||
part: {
|
||||
|
||||
@@ -28,20 +28,20 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"])
|
||||
return theme.highlight
|
||||
}
|
||||
|
||||
function statusIcon(status: FooterSubagentTab["status"]) {
|
||||
function statusIcon(status: FooterSubagentTab["status"], mono: boolean) {
|
||||
if (status === "completed") {
|
||||
return "●"
|
||||
return mono ? "*" : "●"
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
return "○"
|
||||
return mono ? "-" : "○"
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return "◍"
|
||||
return mono ? "!" : "◍"
|
||||
}
|
||||
|
||||
return "◔"
|
||||
return mono ? "." : "◔"
|
||||
}
|
||||
|
||||
export function RunFooterSubagentBody(props: {
|
||||
@@ -57,6 +57,7 @@ export function RunFooterSubagentBody(props: {
|
||||
// command itself is dispatched through the keymap in footer.view.
|
||||
interrupt?: () => string | undefined
|
||||
shellOutput?: () => boolean
|
||||
mono?: boolean
|
||||
}) {
|
||||
const theme = createMemo(() => props.theme())
|
||||
const footer = createMemo(() => theme().footer)
|
||||
@@ -67,6 +68,7 @@ export function RunFooterSubagentBody(props: {
|
||||
backgroundColor: footer().surface,
|
||||
foregroundColor: footer().line,
|
||||
},
|
||||
visible: !props.mono,
|
||||
}))
|
||||
const title = createMemo(() => {
|
||||
const current = tab()
|
||||
@@ -87,7 +89,11 @@ export function RunFooterSubagentBody(props: {
|
||||
const rows = indexArray(commits, (commit, index) => (
|
||||
<box flexDirection="column" gap={0} flexShrink={0}>
|
||||
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
|
||||
<RunEntryContent commit={commit()} theme={theme()} opts={{ shellOutput: props.shellOutput?.() ?? true }} />
|
||||
<RunEntryContent
|
||||
commit={commit()}
|
||||
theme={theme()}
|
||||
opts={{ shellOutput: props.shellOutput?.() ?? true, mono: props.mono }}
|
||||
/>
|
||||
</box>
|
||||
))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
@@ -134,11 +140,15 @@ export function RunFooterSubagentBody(props: {
|
||||
<box width="100%" flexDirection="row" gap={1} paddingBottom={1} flexShrink={0}>
|
||||
{current().status === "running" ? (
|
||||
<box flexShrink={0}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(footer(), current().status)} />
|
||||
<spinner
|
||||
frames={props.mono ? ["-", "\\", "|", "/"] : SPINNER_FRAMES}
|
||||
interval={props.mono ? 160 : 80}
|
||||
color={statusColor(footer(), current().status)}
|
||||
/>
|
||||
</box>
|
||||
) : (
|
||||
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
|
||||
{statusIcon(current().status)}
|
||||
{statusIcon(current().status, props.mono ?? false)}
|
||||
</text>
|
||||
)}
|
||||
<text fg={footer().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
|
||||
@@ -82,6 +82,7 @@ type RunFooterOptions = {
|
||||
first: boolean
|
||||
history?: RunPrompt[]
|
||||
theme: RunTheme
|
||||
mono: boolean
|
||||
tuiConfig: RunTuiConfig
|
||||
miniSettings: {
|
||||
current: MiniSettings
|
||||
@@ -202,7 +203,7 @@ export class RunFooter implements FooterApi {
|
||||
private paletteRefreshRunning = false
|
||||
private paletteRefreshQueued = false
|
||||
private themeRefreshTimeouts: NodeJS.Timeout[] = []
|
||||
private unsubscribeThemeSignal: () => void
|
||||
private unsubscribeThemeSignal = () => {}
|
||||
|
||||
private createScrollback(wrote: boolean): RunScrollbackStream {
|
||||
return new RunScrollbackStream(this.renderer, this.theme(), {
|
||||
@@ -214,6 +215,7 @@ export class RunFooter implements FooterApi {
|
||||
.finally(() => this.destroyTheme(theme))
|
||||
},
|
||||
shellOutput: () => this.miniSettings().shell_output === "show",
|
||||
mono: this.options.mono,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -281,10 +283,12 @@ export class RunFooter implements FooterApi {
|
||||
this.scrollback = this.createScrollback(options.wrote ?? false)
|
||||
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
|
||||
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
|
||||
this.renderer.prependInputHandler(this.handleThemeNotification)
|
||||
this.unsubscribeThemeSignal = options.subscribeThemeSignal(this.handleThemeSignal)
|
||||
if (!options.mono) {
|
||||
this.renderer.on(CliRenderEvents.PALETTE, this.handlePalette)
|
||||
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleThemeRefresh)
|
||||
this.renderer.prependInputHandler(this.handleThemeNotification)
|
||||
this.unsubscribeThemeSignal = options.subscribeThemeSignal(this.handleThemeSignal)
|
||||
}
|
||||
|
||||
const footer = this
|
||||
void render(
|
||||
@@ -307,6 +311,7 @@ export class RunFooter implements FooterApi {
|
||||
variants: footer.variants,
|
||||
currentVariant: footer.currentVariant,
|
||||
theme: footer.theme,
|
||||
mono: options.mono,
|
||||
tuiConfig: options.tuiConfig,
|
||||
miniSettings: footer.miniSettings,
|
||||
history: footer.history,
|
||||
@@ -384,6 +389,7 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
if (next.type === "turn.duration") {
|
||||
if (this.miniSettings().turn_summary === "hide") return
|
||||
const current = this.currentModel()
|
||||
this.flush()
|
||||
this.flushing = this.flushing
|
||||
@@ -875,7 +881,7 @@ export class RunFooter implements FooterApi {
|
||||
|
||||
try {
|
||||
this.setMiniSettings(await this.options.miniSettings.update(change))
|
||||
this.setNotice("settings updated")
|
||||
this.setNotice(change.key === "mono" ? "Mono applies after restart" : "settings updated")
|
||||
} catch (error) {
|
||||
this.setNotice("failed to save settings")
|
||||
throw error
|
||||
@@ -1017,7 +1023,7 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
private handleThemeRefresh = (): void => {
|
||||
if (this.isGone) {
|
||||
if (this.isGone || this.options.mono) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { createFormBodyState, type FormBodyState } from "./form.shared"
|
||||
import { footerWidthPolicy } from "./footer.width"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { modelInfo } from "./variant.shared"
|
||||
import { monoShortcut } from "./mono"
|
||||
|
||||
import type {
|
||||
FooterPromptRoute,
|
||||
@@ -84,6 +85,7 @@ type RunFooterViewProps = {
|
||||
subagent?: () => FooterSubagentState
|
||||
queuedPrompts?: () => FooterQueuedPrompt[]
|
||||
theme: () => RunTheme
|
||||
mono: boolean
|
||||
tuiConfig: RunTuiConfig
|
||||
miniSettings: () => MiniSettings
|
||||
history?: () => RunPrompt[]
|
||||
@@ -174,14 +176,15 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
|
||||
})
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const command = () => shortcuts.get("command.palette.show") ?? ""
|
||||
const subagentShortcut = () => shortcuts.get("session.child.first") ?? ""
|
||||
const queuedShortcut = () => shortcuts.get("session.queued_prompts") ?? ""
|
||||
const backgroundShortcut = () => shortcuts.get("session.background") ?? ""
|
||||
const subagentInterruptShortcut = () => shortcuts.get("subagent.interrupt") ?? ""
|
||||
const interrupt = () => shortcuts.get("session.interrupt") ?? ""
|
||||
const variantCycle = () => shortcuts.all("variant.cycle") ?? ""
|
||||
const clearShortcut = () => shortcuts.get("prompt.clear") ?? ""
|
||||
const shortcut = (id: string) => monoShortcut(shortcuts.get(id) ?? "", props.mono)
|
||||
const command = () => shortcut("command.palette.show")
|
||||
const subagentShortcut = () => shortcut("session.child.first")
|
||||
const queuedShortcut = () => shortcut("session.queued_prompts")
|
||||
const backgroundShortcut = () => shortcut("session.background")
|
||||
const subagentInterruptShortcut = () => shortcut("subagent.interrupt")
|
||||
const interrupt = () => shortcut("session.interrupt")
|
||||
const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono)
|
||||
const clearShortcut = () => shortcut("prompt.clear")
|
||||
const busy = createMemo(() => props.state().phase === "running")
|
||||
const armed = createMemo(() => props.state().interrupt > 0)
|
||||
const exiting = createMemo(() => props.state().exit > 0)
|
||||
@@ -197,6 +200,12 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const theme = createMemo(() => runTheme().footer)
|
||||
const block = createMemo(() => runTheme().block)
|
||||
const spin = createMemo(() => {
|
||||
if (props.mono) {
|
||||
return {
|
||||
frames: ["-", "\\", "|", "/"],
|
||||
color: theme().text,
|
||||
}
|
||||
}
|
||||
const options = {
|
||||
color: theme().highlight,
|
||||
style: "blocks" as const,
|
||||
@@ -326,6 +335,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
prompt,
|
||||
width,
|
||||
theme,
|
||||
mono: () => props.mono,
|
||||
history: props.history,
|
||||
onSubmit: props.onSubmit,
|
||||
onCycle: props.onCycle,
|
||||
@@ -380,7 +390,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return usage()
|
||||
return props.mono ? usage().replaceAll(" · ", " - ") : usage()
|
||||
})
|
||||
const modelStatus = createMemo(() => {
|
||||
const current = model()
|
||||
@@ -441,7 +451,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
return { key: command(), label: "cmd" }
|
||||
}
|
||||
})
|
||||
const sectionSeparator = () => <span style={{ fg: theme().muted }}>· </span>
|
||||
const sectionSeparator = () => <span style={{ fg: theme().muted }}>{props.mono ? "- " : "· "}</span>
|
||||
|
||||
createEffect(() => {
|
||||
props.onRequestExit?.(composer.requestExit)
|
||||
@@ -619,7 +629,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
? undefined
|
||||
: {
|
||||
...EMPTY_BORDER,
|
||||
vertical: "█",
|
||||
vertical: props.mono ? "|" : "█",
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -654,6 +664,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onClose={closePanel}
|
||||
onSelect={openTab}
|
||||
onRows={setSubagentMenuRows}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={selectingQueued()}>
|
||||
@@ -662,6 +673,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
prompts={queuedPrompts}
|
||||
onClose={closePanel}
|
||||
onRows={setSubagentMenuRows}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={commanding()}>
|
||||
@@ -696,6 +708,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
closePanel()
|
||||
}}
|
||||
onExit={props.onExit}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={skilling()}>
|
||||
@@ -715,6 +728,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
})
|
||||
closePanel()
|
||||
}}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={modeling()}>
|
||||
@@ -727,6 +741,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
props.onModelSelect(model)
|
||||
closePanel()
|
||||
}}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={varianting()}>
|
||||
@@ -739,6 +754,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
props.onVariantSelect(variant)
|
||||
closePanel()
|
||||
}}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={setting()}>
|
||||
@@ -747,6 +763,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
settings={props.miniSettings}
|
||||
onClose={closePanel}
|
||||
onChange={props.onMiniSettingChange}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "permission"}>
|
||||
@@ -756,6 +773,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
theme={theme()}
|
||||
block={block()}
|
||||
onReply={props.onPermissionReply}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "form"}>
|
||||
@@ -779,6 +797,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
settledForms.add(input.formID)
|
||||
formStates.delete(input.formID)
|
||||
}}
|
||||
mono={props.mono}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -800,6 +819,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
limit={FOOTER_MENU_ROWS}
|
||||
border={false}
|
||||
paddingLeft={0}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -814,7 +834,12 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
>
|
||||
<Show when={modeLabel()}>
|
||||
{(label) => (
|
||||
<box paddingLeft={1} paddingRight={1} backgroundColor={theme().statusAccent} flexShrink={0}>
|
||||
<box
|
||||
paddingLeft={props.mono ? 0 : 1}
|
||||
paddingRight={1}
|
||||
backgroundColor={theme().statusAccent}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text wrapMode="none" truncate>
|
||||
<span style={{ fg: modeColor(), bold: true }}>{label()}</span>
|
||||
</text>
|
||||
@@ -828,7 +853,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
minWidth={12}
|
||||
paddingLeft={1}
|
||||
paddingLeft={props.mono ? 0 : 1}
|
||||
paddingRight={1}
|
||||
backgroundColor="transparent"
|
||||
>
|
||||
@@ -914,7 +939,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
borderColor={theme().highlight}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
vertical: props.mono ? "|" : "┃",
|
||||
}}
|
||||
>
|
||||
<RunFooterSubagentBody
|
||||
@@ -928,6 +953,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onClose={closeTab}
|
||||
interrupt={() => subagentInterruptShortcut() || undefined}
|
||||
shellOutput={() => props.miniSettings().shell_output === "show"}
|
||||
mono={props.mono}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const prefixes: Record<number, string> = {
|
||||
0x2192: "->",
|
||||
0x2190: "<-",
|
||||
0x2731: "*",
|
||||
0x2699: "*",
|
||||
0x2716: "!",
|
||||
0x2717: "!",
|
||||
0x25c8: "*",
|
||||
0x25c9: "*",
|
||||
0x27f3: "*",
|
||||
}
|
||||
|
||||
export function monoPrefix(value: string, mono: boolean): string {
|
||||
if (!mono) return value
|
||||
const point = value.codePointAt(0)
|
||||
if (point === undefined) return value
|
||||
const prefix = prefixes[point]
|
||||
if (!prefix) return value
|
||||
return prefix + value.slice(point > 0xffff ? 2 : 1)
|
||||
}
|
||||
|
||||
export function monoToolText(value: string, mono: boolean): string {
|
||||
const result = monoPrefix(value, mono)
|
||||
if (!mono) return result
|
||||
const separator = ` ${String.fromCodePoint(0xb7)} `
|
||||
const index = result.lastIndexOf(separator)
|
||||
if (index === -1) return result
|
||||
const head = result.slice(0, index)
|
||||
if (!head.includes(" completed") && head !== "patch" && !/^\d+ questions$/.test(head)) return result
|
||||
return result.slice(0, index) + " - " + result.slice(index + separator.length)
|
||||
}
|
||||
|
||||
export function monoShortcut(value: string, mono: boolean): string {
|
||||
if (!mono) return value
|
||||
return value
|
||||
.replaceAll(String.fromCodePoint(0x2192), "right")
|
||||
.replaceAll(String.fromCodePoint(0x2190), "left")
|
||||
.replaceAll(String.fromCodePoint(0x2191), "up")
|
||||
.replaceAll(String.fromCodePoint(0x2193), "down")
|
||||
}
|
||||
|
||||
export function monoTruncate(value: string, width: number, mono: boolean): string {
|
||||
if (!mono || value.length <= width) return value
|
||||
if (width <= 3) return ".".repeat(Math.max(0, width))
|
||||
return value.slice(0, width - 3) + "..."
|
||||
}
|
||||
|
||||
export function monoTruncateMiddle(value: string, width: number, mono: boolean): string {
|
||||
if (!mono || value.length <= width) return value
|
||||
if (width <= 3) return ".".repeat(Math.max(0, width))
|
||||
const available = width - 3
|
||||
const left = Math.ceil(available / 2)
|
||||
return value.slice(0, left) + "..." + value.slice(value.length - (available - left))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MiniPermissionRequest, PermissionReply } from "./types"
|
||||
import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation } from "../util/permission"
|
||||
import { toolPath } from "./tool"
|
||||
import { monoPrefix } from "./mono"
|
||||
|
||||
export type PermissionStage = "permission" | "always" | "reject"
|
||||
export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
|
||||
@@ -44,9 +45,9 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
||||
return []
|
||||
}
|
||||
|
||||
export function permissionInfo(request: MiniPermissionRequest, directory?: string) {
|
||||
export function permissionInfo(request: MiniPermissionRequest, directory?: string, mono = false) {
|
||||
const state = request.tool?.state
|
||||
return permissionPresentation(
|
||||
const info = permissionPresentation(
|
||||
{
|
||||
action: request.action,
|
||||
resources: request.resources,
|
||||
@@ -56,6 +57,17 @@ export function permissionInfo(request: MiniPermissionRequest, directory?: strin
|
||||
},
|
||||
(value) => toolPath(value, { home: true, directory }),
|
||||
)
|
||||
if (!mono) return info
|
||||
return {
|
||||
...info,
|
||||
icon: monoPrefix(info.icon, true),
|
||||
lines: [
|
||||
...(info.diff || info.patch ? [info.diff ?? info.patch!] : []),
|
||||
...info.lines.map((line) => monoPrefix(line, true)),
|
||||
],
|
||||
diff: undefined,
|
||||
patch: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionLabel(option: PermissionOption): string {
|
||||
|
||||
@@ -88,5 +88,7 @@ export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }):
|
||||
return {
|
||||
thinking: config?.mini?.thinking ?? "hide",
|
||||
shell_output: config?.mini?.shell_output ?? "hide",
|
||||
turn_summary: config?.mini?.turn_summary ?? "show",
|
||||
mono: config?.mini?.mono ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,9 @@ function queueSplash(
|
||||
// the entry splash, RunFooter takes over the footer region.
|
||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
||||
const footerTask = import("./footer")
|
||||
const tuiConfig = await input.tuiConfig
|
||||
const miniSettings = resolveMiniSettings(tuiConfig)
|
||||
const mono = miniSettings.mono
|
||||
const renderer = await createCliRenderer({
|
||||
stdin: input.host.terminal.stdin,
|
||||
targetFps: 30,
|
||||
@@ -172,15 +175,16 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: input.host.platform === "win32" },
|
||||
useKittyKeyboard: mono
|
||||
? { disambiguate: false, alternateKeys: false, events: false, allKeysAsEscapes: false, reportText: false }
|
||||
: { events: input.host.platform === "win32" },
|
||||
screenMode: "split-footer",
|
||||
footerHeight: FOOTER_HEIGHT,
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
const tuiConfig = await input.tuiConfig
|
||||
const theme = await resolveRunTheme(renderer, tuiConfig.theme)
|
||||
const theme = await resolveRunTheme(renderer, tuiConfig.theme, mono)
|
||||
renderer.setBackgroundColor(theme.background)
|
||||
const state: SplashState = {
|
||||
entry: false,
|
||||
@@ -190,6 +194,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
const meta = splashMeta({
|
||||
title: splash.title,
|
||||
session_id: input.sessionID,
|
||||
mono,
|
||||
})
|
||||
const labels = footerLabels({
|
||||
agent: input.agent,
|
||||
@@ -205,6 +210,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
theme: theme.splash,
|
||||
showSession: splash.showSession,
|
||||
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
|
||||
mono,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
@@ -226,10 +232,11 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
first: input.first,
|
||||
history: input.history,
|
||||
theme,
|
||||
mono,
|
||||
wrote,
|
||||
tuiConfig,
|
||||
miniSettings: {
|
||||
current: resolveMiniSettings(tuiConfig),
|
||||
current: miniSettings,
|
||||
update: input.onMiniSettingChange,
|
||||
},
|
||||
onPermissionReply: input.onPermissionReply,
|
||||
@@ -319,8 +326,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
...splashMeta({
|
||||
title: splash.title,
|
||||
session_id: sessionID,
|
||||
mono,
|
||||
}),
|
||||
theme: footer.currentTheme().splash,
|
||||
mono,
|
||||
}),
|
||||
)
|
||||
await renderer.idle().catch(() => {})
|
||||
@@ -374,10 +383,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
...splashMeta({
|
||||
title: splash.title,
|
||||
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
|
||||
mono,
|
||||
}),
|
||||
theme: footer.currentTheme().splash,
|
||||
showSession: splash.showSession,
|
||||
detail: directoryLabel(input.getDirectory(), input.host.paths.home),
|
||||
mono,
|
||||
}),
|
||||
)
|
||||
renderer.requestRender()
|
||||
|
||||
@@ -89,6 +89,7 @@ export class RunScrollbackStream {
|
||||
private treeSitterClient: TreeSitterClient | undefined
|
||||
private wrote: boolean
|
||||
private shellOutput: () => boolean
|
||||
private mono: boolean
|
||||
private pendingThemes: RunTheme[] = []
|
||||
|
||||
constructor(
|
||||
@@ -99,11 +100,13 @@ export class RunScrollbackStream {
|
||||
treeSitterClient?: TreeSitterClient
|
||||
onThemeRelease?: (theme: RunTheme) => void
|
||||
shellOutput?: () => boolean
|
||||
mono?: boolean
|
||||
} = {},
|
||||
) {
|
||||
this.treeSitterClient = options.treeSitterClient
|
||||
this.wrote = options.wrote ?? false
|
||||
this.shellOutput = options.shellOutput ?? (() => true)
|
||||
this.mono = options.mono ?? false
|
||||
this.onThemeRelease = options.onThemeRelease
|
||||
}
|
||||
|
||||
@@ -351,13 +354,13 @@ export class RunScrollbackStream {
|
||||
|
||||
if (commit.summary) {
|
||||
this.writeSpacer(1)
|
||||
this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme }))
|
||||
this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme, mono: this.mono }))
|
||||
this.markRendered(commit)
|
||||
this.tail = commit
|
||||
return
|
||||
}
|
||||
|
||||
const body = entryBody(commit, { shellOutput: this.shellOutput() })
|
||||
const body = entryBody(commit, { shellOutput: this.shellOutput(), mono: this.mono })
|
||||
if (body.type === "none") {
|
||||
if (entryDone(commit)) {
|
||||
this.markRendered(await this.finishActive(false))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { entryBody, entryFlags } from "./entry.body"
|
||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||
import { toolFiletype, toolStructuredFinal } from "./tool"
|
||||
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
|
||||
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
|
||||
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit, TurnSummary } from "./types"
|
||||
|
||||
export function entryGroupKey(commit: StreamCommit): string | undefined {
|
||||
if (!commit.partID) {
|
||||
@@ -281,7 +281,7 @@ export function spacerWriter(): ScrollbackWriter {
|
||||
})
|
||||
}
|
||||
|
||||
export function turnSummaryWriter(input: { agent: string; model: string; duration: string; theme: RunTheme }) {
|
||||
export function turnSummaryWriter(input: TurnSummary & { theme: RunTheme; mono?: boolean }) {
|
||||
return createScrollbackWriter(
|
||||
() => (
|
||||
<box width="100%" height={1}>
|
||||
@@ -289,7 +289,7 @@ export function turnSummaryWriter(input: { agent: string; model: string; duratio
|
||||
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
|
||||
<span style={{ fg: input.theme.block.muted }}>
|
||||
{" "}
|
||||
· {input.model} · {input.duration}
|
||||
{input.mono ? "-" : "·"} {input.model} {input.mono ? "-" : "·"} {input.duration}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "@opentui/core"
|
||||
import { Locale } from "../util/locale"
|
||||
import { go } from "../logo"
|
||||
import { monoTruncate, monoTruncateMiddle } from "./mono"
|
||||
import type { RunSplashTheme } from "./theme"
|
||||
|
||||
const SPLASH_TITLE_LIMIT = 50
|
||||
@@ -27,6 +28,7 @@ const SPLASH_TITLE_FALLBACK = "Untitled session"
|
||||
type SplashInput = {
|
||||
title: string | undefined
|
||||
session_id: string
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
type SplashWriterInput = SplashInput & {
|
||||
@@ -69,7 +71,7 @@ function cells(line: string): Cell[] {
|
||||
return list
|
||||
}
|
||||
|
||||
function title(text: string | undefined): string {
|
||||
function title(text: string | undefined, mono = false): string {
|
||||
if (!text) {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
@@ -94,7 +96,7 @@ function title(text: string | undefined): string {
|
||||
return SPLASH_TITLE_FALLBACK
|
||||
}
|
||||
|
||||
return Locale.truncate(value, SPLASH_TITLE_LIMIT)
|
||||
return mono ? monoTruncate(value, SPLASH_TITLE_LIMIT, true) : Locale.truncate(value, SPLASH_TITLE_LIMIT)
|
||||
}
|
||||
|
||||
function write(
|
||||
@@ -181,7 +183,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
||||
let height = 1
|
||||
|
||||
if (kind === "entry") {
|
||||
const mark = go.right.slice(1)
|
||||
const mark = input.mono ? ["[O]"] : go.right.slice(1)
|
||||
const top = 1
|
||||
const body_left = (mark[0]?.length ?? 0) + 2
|
||||
|
||||
@@ -200,16 +202,18 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
||||
lines,
|
||||
body_left,
|
||||
top + 1,
|
||||
Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
|
||||
input.mono
|
||||
? monoTruncateMiddle(input.detail, Math.max(1, width - body_left), true)
|
||||
: Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
|
||||
left,
|
||||
undefined,
|
||||
)
|
||||
}
|
||||
height = top + mark.length
|
||||
height = top + Math.max(mark.length, input.detail ? 2 : 1)
|
||||
}
|
||||
|
||||
if (kind === "exit") {
|
||||
const mark = go.right.slice(1)
|
||||
const mark = input.mono ? ["[O]"] : go.right.slice(1)
|
||||
const top = 1
|
||||
const body_left = (mark[0]?.length ?? 0) + 2
|
||||
const session = "Session "
|
||||
@@ -239,7 +243,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
||||
undefined,
|
||||
TextAttributes.BOLD,
|
||||
)
|
||||
height = top + mark.length
|
||||
height = top + Math.max(mark.length, 2)
|
||||
}
|
||||
|
||||
const root = new BoxRenderable(ctx.renderContext, {
|
||||
@@ -266,7 +270,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
|
||||
|
||||
export function splashMeta(input: SplashInput): SplashMeta {
|
||||
return {
|
||||
title: title(input.title),
|
||||
title: title(input.title, input.mono),
|
||||
session_id: input.session_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,7 +506,71 @@ export const RUN_THEME_FALLBACK: RunTheme = {
|
||||
},
|
||||
}
|
||||
|
||||
export async function resolveRunTheme(renderer: CliRenderer, config?: RunTuiConfig["theme"]): Promise<RunTheme> {
|
||||
function monoTheme(mode: "dark" | "light"): RunTheme {
|
||||
const foreground = RGBA.defaultForeground(mode === "light" ? "#000000" : "#ffffff")
|
||||
const background = RGBA.defaultBackground(mode === "light" ? "#ffffff" : "#000000")
|
||||
return {
|
||||
background,
|
||||
footer: {
|
||||
highlight: foreground,
|
||||
selected: background,
|
||||
selectedText: foreground,
|
||||
warning: foreground,
|
||||
error: foreground,
|
||||
muted: foreground,
|
||||
text: foreground,
|
||||
status: background,
|
||||
statusAccent: background,
|
||||
shade: background,
|
||||
surface: background,
|
||||
pane: background,
|
||||
border: foreground,
|
||||
line: background,
|
||||
},
|
||||
entry: {
|
||||
system: tone(foreground),
|
||||
user: tone(foreground),
|
||||
assistant: tone(foreground),
|
||||
reasoning: tone(foreground),
|
||||
tool: tone(foreground),
|
||||
error: tone(foreground),
|
||||
},
|
||||
splash: {
|
||||
left: foreground,
|
||||
right: foreground,
|
||||
leftShadow: background,
|
||||
},
|
||||
block: {
|
||||
text: foreground,
|
||||
muted: foreground,
|
||||
diffRemoved: foreground,
|
||||
diffAddedBg: background,
|
||||
diffRemovedBg: background,
|
||||
diffContextBg: background,
|
||||
diffHighlightAdded: foreground,
|
||||
diffHighlightRemoved: foreground,
|
||||
diffLineNumber: foreground,
|
||||
diffAddedLineNumberBg: background,
|
||||
diffRemovedLineNumberBg: background,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const RUN_THEME_MONO = monoTheme("dark")
|
||||
const RUN_THEME_MONO_LIGHT = monoTheme("light")
|
||||
|
||||
export async function resolveRunTheme(
|
||||
renderer: CliRenderer,
|
||||
config?: RunTuiConfig["theme"],
|
||||
mono = false,
|
||||
): Promise<RunTheme> {
|
||||
if (mono) {
|
||||
const mode =
|
||||
config?.mode === "light" || config?.mode === "dark"
|
||||
? config.mode
|
||||
: (renderer.themeMode ?? (await renderer.waitForThemeMode(300)))
|
||||
return mode === "light" ? RUN_THEME_MONO_LIGHT : RUN_THEME_MONO
|
||||
}
|
||||
try {
|
||||
const colors = await renderer.getPalette({
|
||||
size: 256,
|
||||
|
||||
@@ -185,6 +185,7 @@ export type TurnSummary = {
|
||||
export type ScrollbackOptions = {
|
||||
suppressBackgrounds?: boolean
|
||||
shellOutput?: boolean
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
export type ToolCodeSnapshot = {
|
||||
@@ -396,12 +397,13 @@ export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme"
|
||||
export type MiniSettings = {
|
||||
thinking: "show" | "hide"
|
||||
shell_output: "show" | "hide"
|
||||
turn_summary: "show" | "hide"
|
||||
mono: boolean
|
||||
}
|
||||
|
||||
export type MiniSettingChange = {
|
||||
key: keyof MiniSettings
|
||||
value: "show" | "hide"
|
||||
}
|
||||
[Key in keyof MiniSettings]: { key: Key; value: MiniSettings[Key] }
|
||||
}[keyof MiniSettings]
|
||||
|
||||
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
||||
// appends content (coalesced in the footer queue), "final" closes it.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { SessionMessageAssistant } from "@opencode-ai/client"
|
||||
import { Keyed, Layout } from "@opencode-ai/quark"
|
||||
import { toolDisplay } from "../../util/tool-display"
|
||||
|
||||
/**
|
||||
* Stable per-part reactive slots for assistant message content.
|
||||
*
|
||||
* Streaming events name their part on the wire (text/reasoning by ordinal,
|
||||
* tools by callID); each assistant message owns one keyed collection so a
|
||||
* delta publishes exactly one slot instead of reconciling a content array.
|
||||
* Design record: quark repo, docs/experiments/quark-message-content.md.
|
||||
*/
|
||||
export namespace SessionContent {
|
||||
type ContentPart = SessionMessageAssistant["content"][number]
|
||||
export type Part = ContentPart & { readonly partID: string }
|
||||
export type Parts = ReturnType<typeof PartPlan.make>
|
||||
/** Read surface for view components; mutation stays with the data layer. */
|
||||
export type PartsView = Keyed.ReadOnly<Part, string> & Pick<Parts, "first">
|
||||
|
||||
// Streamed sub-objects are replaced immutably on change, so reference
|
||||
// equality is the correct (and cheapest) field comparator for them.
|
||||
const reference: Layout.Field<unknown> = { equivalent: Object.is }
|
||||
|
||||
const PartLayout = Layout.keyedUnion({
|
||||
key: Layout.key("partID", Layout.string),
|
||||
tag: "type",
|
||||
variants: {
|
||||
text: Layout.struct({ text: Layout.string }),
|
||||
reasoning: Layout.struct({ text: Layout.string, state: reference, time: reference }),
|
||||
tool: Layout.struct({
|
||||
id: Layout.immutable(Layout.string),
|
||||
name: Layout.immutable(Layout.string),
|
||||
executed: reference,
|
||||
providerState: reference,
|
||||
providerResultState: reference,
|
||||
state: reference,
|
||||
time: reference,
|
||||
}),
|
||||
},
|
||||
})
|
||||
// The layout describes the reactive fields of the client content shapes;
|
||||
// collectionOf types the plan against those shapes at this single boundary.
|
||||
const PartPlan = Layout.collectionOf<Part>()(PartLayout, ({ first }) => ({
|
||||
// First running shell/subagent tool; drives the background-work hint
|
||||
// without subscribing views to whole-collection values.
|
||||
backgroundRunning: first(["type", "state"], (part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||
const display = toolDisplay(part.name)
|
||||
return display === "shell" || display === "subagent"
|
||||
}),
|
||||
}))
|
||||
|
||||
export function textID(ordinal: number) {
|
||||
return `text:${ordinal}`
|
||||
}
|
||||
|
||||
export function reasoningID(ordinal: number) {
|
||||
return `reasoning:${ordinal}`
|
||||
}
|
||||
|
||||
/** Assign the synthetic part IDs used across the TUI to a fetched content array. */
|
||||
export function withPartIDs(content: readonly ContentPart[]): Part[] {
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return content.map((part) => {
|
||||
if (part.type === "tool") return { ...part, partID: part.id }
|
||||
const id = part.type === "text" ? textID : reasoningID
|
||||
return { ...part, partID: id(ordinals[part.type]++) }
|
||||
})
|
||||
}
|
||||
|
||||
/** Non-empty text parts of one message, in slot order. */
|
||||
export function textParts(parts: PartsView) {
|
||||
return parts
|
||||
.values()
|
||||
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text" && part.text.trim().length > 0)
|
||||
}
|
||||
|
||||
export function hasText(parts: PartsView) {
|
||||
return textParts(parts).length > 0
|
||||
}
|
||||
|
||||
export function text(parts: PartsView) {
|
||||
return textParts(parts)
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function make(options?: { readonly metrics?: Keyed.Metrics }) {
|
||||
const collections = new Map<string, Parts>()
|
||||
const id = (sessionID: string, messageID: string) => `${sessionID}\u0000${messageID}`
|
||||
|
||||
return {
|
||||
get(sessionID: string, messageID: string) {
|
||||
return collections.get(id(sessionID, messageID))
|
||||
},
|
||||
ensure(sessionID: string, messageID: string) {
|
||||
const key = id(sessionID, messageID)
|
||||
const existing = collections.get(key)
|
||||
if (existing) return existing
|
||||
const created = PartPlan.make([], options)
|
||||
collections.set(key, created)
|
||||
return created
|
||||
},
|
||||
seed(sessionID: string, messageID: string, content: readonly ContentPart[]) {
|
||||
this.ensure(sessionID, messageID).set(withPartIDs(content))
|
||||
},
|
||||
/** Drop one message's collection, or every collection for a session. */
|
||||
drop(sessionID: string, messageID?: string) {
|
||||
if (messageID !== undefined) {
|
||||
collections.delete(id(sessionID, messageID))
|
||||
return
|
||||
}
|
||||
const prefix = `${sessionID}\u0000`
|
||||
for (const key of [...collections.keys()]) {
|
||||
if (key.startsWith(prefix)) collections.delete(key)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Drop collections for messages absent from a refetched snapshot.
|
||||
* Present messages must be reseeded in place, never dropped: mounted
|
||||
* views hold their collection reference for their whole lifetime, so
|
||||
* replacing the object would orphan their subscriptions.
|
||||
*/
|
||||
prune(sessionID: string, keep: ReadonlySet<string>) {
|
||||
const prefix = `${sessionID}\u0000`
|
||||
for (const key of [...collections.keys()]) {
|
||||
if (key.startsWith(prefix) && !keep.has(key.slice(prefix.length))) collections.delete(key)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useToast } from "../../ui/toast"
|
||||
import { useClient } from "../../context/client"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { DialogFork } from "./dialog-fork"
|
||||
import { SessionContent } from "./content"
|
||||
import type { PromptInfo } from "../../prompt/history"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
|
||||
@@ -59,10 +60,7 @@ export function DialogMessage(props: {
|
||||
value.type === "user"
|
||||
? value.text
|
||||
: value.type === "assistant"
|
||||
? value.content
|
||||
.filter((content) => content.type === "text")
|
||||
.map((content) => content.text)
|
||||
.join("\n")
|
||||
? SessionContent.text(data.session.message.parts(props.sessionID, props.messageID))
|
||||
: "text" in value
|
||||
? value.text
|
||||
: ""
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
mapArray,
|
||||
Match,
|
||||
on,
|
||||
onCleanup,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
Switch,
|
||||
useContext,
|
||||
} from "solid-js"
|
||||
import { KeyedFor, useSlot, useValue } from "@opencode-ai/quark/solid"
|
||||
import { SessionContent } from "./content"
|
||||
import path from "node:path"
|
||||
import { EOL, tmpdir } from "node:os"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
@@ -39,9 +42,9 @@ import { useLocal } from "../../context/local"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { FilePath } from "../../ui/file-path"
|
||||
import {
|
||||
canonicalToolName,
|
||||
finiteNumber,
|
||||
primitiveInputSummary,
|
||||
toolDisplay,
|
||||
toolDisplayMetadata,
|
||||
webSearchProviderLabel,
|
||||
} from "../../util/tool-display"
|
||||
@@ -80,7 +83,8 @@ import { PluginSlot } from "../../plugin/context"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||
import { createSessionRows } from "./rows"
|
||||
import { messageBoundaryIDs, type PartRef, type SessionRow } from "./timeline"
|
||||
import { switchLabel } from "../../util/model"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
@@ -213,7 +217,16 @@ export function Session() {
|
||||
})
|
||||
const editor = useEditorContext()
|
||||
const rows = createSessionRows(() => route.sessionID)
|
||||
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
|
||||
const partsOf = (messageID: string) => data.session.message.parts(route.sessionID, messageID)
|
||||
// Slot reads here are deliberately untracked: messageBoundaryIDs depends
|
||||
// only on structurally-immutable row fields (id, type, origin), so this memo
|
||||
// re-runs on structural changes (rows.slots) and message list changes only.
|
||||
const boundaries = createMemo(() =>
|
||||
messageBoundaryIDs(
|
||||
rows.slots().map((slot) => slot()),
|
||||
messages(),
|
||||
),
|
||||
)
|
||||
const [navigationMessage, setNavigationMessage] = createSignal<string>()
|
||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||
|
||||
@@ -313,6 +326,7 @@ export function Session() {
|
||||
direction,
|
||||
children: scroll.getChildren(),
|
||||
messages: messages(),
|
||||
hasText: (messageID) => SessionContent.hasText(partsOf(messageID)),
|
||||
scrollTop: scroll.scrollTop,
|
||||
viewportY: scroll.viewport.y,
|
||||
currentID: navigationMessage(),
|
||||
@@ -680,7 +694,9 @@ export function Session() {
|
||||
return
|
||||
}
|
||||
|
||||
const textParts = lastAssistantMessage.content.filter((part) => part.type === "text")
|
||||
const textParts = partsOf(lastAssistantMessage.id)
|
||||
.values()
|
||||
.filter((part) => part.type === "text")
|
||||
if (textParts.length === 0) {
|
||||
toast.show({ message: "No text parts found in last assistant message", variant: "error" })
|
||||
dialog.clear()
|
||||
@@ -718,7 +734,9 @@ export function Session() {
|
||||
try {
|
||||
const sessionData = session()
|
||||
if (!sessionData) return
|
||||
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
|
||||
const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), (messageID) =>
|
||||
partsOf(messageID).values(),
|
||||
)
|
||||
await clipboard.write?.(transcript)
|
||||
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
||||
} catch {
|
||||
@@ -745,7 +763,9 @@ export function Session() {
|
||||
|
||||
const content =
|
||||
options.format === "markdown"
|
||||
? formatSessionTranscript(sessionData, messages(), options.thinking)
|
||||
? formatSessionTranscript(sessionData, messages(), options.thinking, (messageID) =>
|
||||
partsOf(messageID).values(),
|
||||
)
|
||||
: await (async () => {
|
||||
if (options.debug) {
|
||||
const events: { readonly created: number }[] = []
|
||||
@@ -928,16 +948,17 @@ export function Session() {
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<For each={rows}>
|
||||
<KeyedFor each={rows.slots}>
|
||||
{(row, index) => (
|
||||
<SessionRowView
|
||||
row={row}
|
||||
row={row()}
|
||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||
parts={partsOf}
|
||||
boundaryID={boundaries()[index()]}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
</KeyedFor>
|
||||
<BackgroundToolHint messages={messages()} parts={partsOf} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
count={
|
||||
@@ -1034,6 +1055,7 @@ export function Session() {
|
||||
function SessionRowView(props: {
|
||||
row: SessionRow
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
parts: (messageID: string) => SessionContent.PartsView
|
||||
boundaryID?: string
|
||||
}) {
|
||||
return (
|
||||
@@ -1048,10 +1070,17 @@ function SessionRowView(props: {
|
||||
<CompactionQueued />
|
||||
</Match>
|
||||
<Match when={props.row.type === "part" ? props.row : undefined}>
|
||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} parts={props.parts} />}
|
||||
</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) => (
|
||||
<SessionReasoningGroupView
|
||||
refs={row().refs}
|
||||
completed={row().completed}
|
||||
message={props.message}
|
||||
parts={props.parts}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
@@ -1060,6 +1089,7 @@ function SessionRowView(props: {
|
||||
pending={row().pending}
|
||||
completed={row().completed}
|
||||
message={props.message}
|
||||
parts={props.parts}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
@@ -1079,20 +1109,23 @@ function SessionRowView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
||||
function BackgroundToolHint(props: {
|
||||
messages: SessionMessageInfo[]
|
||||
parts: (messageID: string) => SessionContent.PartsView
|
||||
}) {
|
||||
const { themeV2 } = useTheme()
|
||||
const shortcut = Keymap.useShortcut("session.background")
|
||||
const visible = createMemo(() => {
|
||||
const current = props.messages.findLast(
|
||||
const current = createMemo(() =>
|
||||
props.messages.findLast(
|
||||
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
|
||||
)
|
||||
return (
|
||||
current?.content.some((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||
const display = toolDisplay(part.name)
|
||||
return display === "shell" || display === "subagent"
|
||||
}) ?? false
|
||||
)
|
||||
),
|
||||
)
|
||||
// The collection maintains the first-match index; text and reasoning deltas
|
||||
// never publish it, so this memo re-runs only on background-tool changes.
|
||||
const visible = createMemo(() => {
|
||||
const message = current()
|
||||
if (!message) return false
|
||||
return useValue(props.parts(message.id).first("backgroundRunning"))() !== undefined
|
||||
})
|
||||
return (
|
||||
<Show when={visible() && shortcut()}>
|
||||
@@ -1133,13 +1166,23 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) {
|
||||
// Per-ref slot accessors for group views. mapArray reuses entries by ref
|
||||
// identity, so appending one ref to a group creates one new slot subscription
|
||||
// instead of rebuilding the whole accessor list; value changes flow through
|
||||
// the individual slots.
|
||||
function usePartSlots(parts: (messageID: string) => SessionContent.PartsView, refs: () => readonly PartRef[]) {
|
||||
return mapArray(refs, (ref) => ({ ref, part: useSlot(parts(ref.messageID), ref.partID) }))
|
||||
}
|
||||
|
||||
function SessionPartView(props: {
|
||||
partRef: PartRef
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
parts: (messageID: string) => SessionContent.PartsView
|
||||
}) {
|
||||
const message = createMemo(() => props.message(props.partRef.messageID))
|
||||
const part = createMemo(() => {
|
||||
const item = message()
|
||||
if (item?.type !== "assistant") return
|
||||
return resolvePart(item, props.partRef.partID)
|
||||
})
|
||||
// One reactive slot per part: unrelated deltas in the same message cannot
|
||||
// re-render this row.
|
||||
const part = useSlot(props.parts(props.partRef.messageID), () => props.partRef.partID)
|
||||
return (
|
||||
<Show when={part()}>
|
||||
{(item) => (
|
||||
@@ -1164,20 +1207,22 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
|
||||
}
|
||||
|
||||
function SessionReasoningGroupView(props: {
|
||||
refs: PartRef[]
|
||||
refs: readonly PartRef[]
|
||||
completed: boolean
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
parts: (messageID: string) => SessionContent.PartsView
|
||||
}) {
|
||||
const ctx = use()
|
||||
const { themeV2, syntax } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const accessors = usePartSlots(props.parts, () => props.refs)
|
||||
const parts = createMemo(() =>
|
||||
props.refs.flatMap((ref) => {
|
||||
const message = props.message(ref.messageID)
|
||||
accessors().flatMap((entry) => {
|
||||
const message = props.message(entry.ref.messageID)
|
||||
if (message?.type !== "assistant") return []
|
||||
const part = resolvePart(message, ref.partID)
|
||||
const part = entry.part()
|
||||
if (part?.type !== "reasoning" || !reasoningContent(part)) return []
|
||||
return [{ message, part }]
|
||||
}),
|
||||
@@ -1202,7 +1247,11 @@ function SessionReasoningGroupView(props: {
|
||||
<Show when={parts().length > 0}>
|
||||
<Show
|
||||
when={ctx.thinkingMode() === "hide"}
|
||||
fallback={<For each={props.refs}>{(ref) => <SessionPartView partRef={ref} message={props.message} />}</For>}
|
||||
fallback={
|
||||
<For each={props.refs}>
|
||||
{(ref) => <SessionPartView partRef={ref} message={props.message} parts={props.parts} />}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<box flexDirection="column" flexShrink={0}>
|
||||
<InlineToolRow
|
||||
@@ -1238,15 +1287,14 @@ function SessionReasoningGroupView(props: {
|
||||
<box paddingLeft={3}>
|
||||
<For each={props.refs}>
|
||||
{(ref) => {
|
||||
const message = createMemo(() => {
|
||||
const item = props.message(ref.messageID)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
})
|
||||
const slot = useSlot(props.parts(ref.messageID), ref.partID)
|
||||
const part = createMemo(() => {
|
||||
const item = message()
|
||||
if (!item) return undefined
|
||||
const part = resolvePart(item, ref.partID)
|
||||
return part?.type === "reasoning" ? part : undefined
|
||||
const item = slot()
|
||||
return item?.type === "reasoning" ? item : undefined
|
||||
})
|
||||
const messageCompleted = createMemo(() => {
|
||||
const item = props.message(ref.messageID)
|
||||
return item?.type === "assistant" && item.time.completed !== undefined
|
||||
})
|
||||
const content = createMemo(() => {
|
||||
const item = part()
|
||||
@@ -1264,7 +1312,7 @@ function SessionReasoningGroupView(props: {
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
|
||||
streaming={part()?.time?.completed === undefined && !messageCompleted()}
|
||||
syntaxStyle={syntax()}
|
||||
content={content()}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
@@ -1285,26 +1333,27 @@ function SessionReasoningGroupView(props: {
|
||||
}
|
||||
|
||||
function SessionGroupView(props: {
|
||||
refs: PartRef[]
|
||||
pending: PartRef[]
|
||||
refs: readonly PartRef[]
|
||||
pending: readonly PartRef[]
|
||||
completed: boolean
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
parts: (messageID: string) => SessionContent.PartsView
|
||||
}) {
|
||||
const { themeV2 } = useTheme()
|
||||
const ctx = use()
|
||||
const renderer = useRenderer()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const parts = (refs: PartRef[]) =>
|
||||
refs.flatMap((ref) => {
|
||||
const message = props.message(ref.messageID)
|
||||
if (message?.type !== "assistant") return []
|
||||
const part = resolvePart(message, ref.partID)
|
||||
const groupedSlots = usePartSlots(props.parts, () => props.refs)
|
||||
const pendingSlots = usePartSlots(props.parts, () => props.pending)
|
||||
const parts = (entries: readonly { readonly part: () => SessionContent.Part | undefined }[]) =>
|
||||
entries.flatMap((entry) => {
|
||||
const part = entry.part()
|
||||
if (part?.type !== "tool") return []
|
||||
return [part]
|
||||
})
|
||||
const grouped = createMemo(() => parts(props.refs))
|
||||
const pending = createMemo(() => parts(props.pending))
|
||||
const grouped = createMemo(() => parts(groupedSlots()))
|
||||
const pending = createMemo(() => parts(pendingSlots()))
|
||||
const label = createMemo(() => {
|
||||
const counts = grouped().reduce<Record<string, number>>((result, part) => {
|
||||
const tool = toolDisplay(part.name)
|
||||
@@ -1721,122 +1770,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
|
||||
const ctx = use()
|
||||
const local = useLocal()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const model = createMemo(
|
||||
() =>
|
||||
ctx
|
||||
.models()
|
||||
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
|
||||
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
|
||||
)
|
||||
|
||||
const final = createMemo(() => {
|
||||
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
|
||||
})
|
||||
|
||||
const duration = createMemo(() => {
|
||||
if (!final()) return 0
|
||||
if (!props.message.time.completed) return 0
|
||||
return props.message.time.completed - props.message.time.created
|
||||
})
|
||||
|
||||
const exploration = createMemo(() => {
|
||||
const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
|
||||
if (!ctx.groupExploration()) return grouped
|
||||
const runs = props.message.content
|
||||
.map((part) =>
|
||||
part.type === "tool" &&
|
||||
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
|
||||
part.state.status !== "streaming"
|
||||
? part
|
||||
: undefined,
|
||||
)
|
||||
.reduce<SessionMessageAssistantTool[][]>(
|
||||
(runs, part) => {
|
||||
if (part) runs[runs.length - 1].push(part)
|
||||
if (!part && runs[runs.length - 1].length) runs.push([])
|
||||
return runs
|
||||
},
|
||||
[[]],
|
||||
)
|
||||
.filter((run) => run.length > 0)
|
||||
for (const run of runs) {
|
||||
const summary = {
|
||||
parts: run,
|
||||
active: false,
|
||||
}
|
||||
run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 }))
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<For each={props.message.content}>
|
||||
{(content, index) => (
|
||||
<Switch>
|
||||
<Match when={content.type === "text"}>
|
||||
<TextPart
|
||||
part={content as SessionMessageAssistantText}
|
||||
last={index() === props.message.content.length - 1}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={content.type === "reasoning"}>
|
||||
<ReasoningPart
|
||||
part={content as SessionMessageAssistantReasoning}
|
||||
message={props.message}
|
||||
last={index() === props.message.content.length - 1}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={content.type === "tool"}>
|
||||
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
|
||||
<Show
|
||||
when={exploration().get((content as SessionMessageAssistantTool).id)}
|
||||
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
|
||||
>
|
||||
{(summary) => <ExplorationSummary {...summary()} />}
|
||||
</Show>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.message.error}>
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={themeV2.background.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.text.feedback.error.default}
|
||||
>
|
||||
<text fg={themeV2.text.subdued}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<AssistantRetry retry={props.message.retry} />
|
||||
<Switch>
|
||||
<Match when={props.last || final() || props.message.error}>
|
||||
<box paddingLeft={3}>
|
||||
<text>
|
||||
<span style={{ fg: props.message.error ? themeV2.text.subdued : local.agent.color(props.message.agent) }}>
|
||||
{Locale.titlecase(props.message.agent)}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · {model()}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: themeV2.text.subdued }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
const { themeV2 } = useTheme()
|
||||
return (
|
||||
@@ -3005,39 +2938,23 @@ function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
const toolDisplays = new Set([
|
||||
"shell",
|
||||
"glob",
|
||||
"read",
|
||||
"grep",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"write",
|
||||
"edit",
|
||||
"subagent",
|
||||
"execute",
|
||||
"patch",
|
||||
"question",
|
||||
"skill",
|
||||
])
|
||||
|
||||
export function toolDisplay(tool: string) {
|
||||
const normalized = canonicalToolName(tool)
|
||||
return toolDisplays.has(normalized) ? normalized : "generic"
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {
|
||||
function formatSessionTranscript(
|
||||
session: SessionInfo,
|
||||
messages: SessionMessageInfo[],
|
||||
thinking: boolean,
|
||||
partsOf: (messageID: string) => readonly SessionContent.Part[],
|
||||
) {
|
||||
const body = messages.flatMap((message) => {
|
||||
if (message.type === "user") return [`## User\n\n${message.text}`]
|
||||
if (message.type === "shell")
|
||||
return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``]
|
||||
if (message.type !== "assistant") return []
|
||||
const content = message.content.flatMap((item) => {
|
||||
const content = partsOf(message.id).flatMap((item) => {
|
||||
if (item.type === "text") return [item.text]
|
||||
if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : []
|
||||
const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2)
|
||||
|
||||
@@ -19,6 +19,9 @@ export function findMessageBoundary(input: {
|
||||
direction: "next" | "prev"
|
||||
children: readonly MessageChild[]
|
||||
messages: readonly SessionMessageInfo[]
|
||||
// Assistant text lives in SessionContent slots; the store `content` field is
|
||||
// fetch-time only, so callers must supply the live text predicate.
|
||||
hasText: (messageID: string) => boolean
|
||||
scrollTop: number
|
||||
viewportY: number
|
||||
currentID?: string
|
||||
@@ -35,7 +38,7 @@ export function findMessageBoundary(input: {
|
||||
return [{ id: child.id, y, top: y }]
|
||||
}
|
||||
if (input.userOnly || message.type !== "assistant") return []
|
||||
if (!message.content.some((content) => content.type === "text" && content.text.trim())) return []
|
||||
if (!input.hasText(message.id)) return []
|
||||
const y = input.scrollTop + child.y - input.viewportY
|
||||
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
|
||||
})
|
||||
|
||||
@@ -14,9 +14,36 @@ import { useConfig } from "../../config"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { SimulationSemantics } from "../../simulation/semantics"
|
||||
import { useSlot } from "@opencode-ai/quark/solid"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
|
||||
/**
|
||||
* Resolved tool input for a permission request, once input streaming settles.
|
||||
* Takes an accessor: the prompt stays mounted across queued requests, so the
|
||||
* slot subscription must follow the current request.
|
||||
*/
|
||||
export function usePermissionInput(request: () => PermissionV2Request): () => Record<string, unknown> {
|
||||
const source = usePermissionSource(request)
|
||||
return createMemo(() => source().input ?? {})
|
||||
}
|
||||
|
||||
function usePermissionSource(request: () => PermissionV2Request) {
|
||||
const data = useData()
|
||||
const part = createMemo(() => {
|
||||
const tool = request().source
|
||||
if (!tool) return
|
||||
return useSlot(data.session.message.parts(request().sessionID, tool.messageID), tool.callID)
|
||||
})
|
||||
return createMemo(() => {
|
||||
const item = part()?.()
|
||||
if (item?.type === "tool" && item.state.status !== "streaming") {
|
||||
return { input: item.state.input, structured: item.state.structured }
|
||||
}
|
||||
return { input: undefined, structured: undefined }
|
||||
})
|
||||
}
|
||||
|
||||
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
const themeState = useTheme()
|
||||
const themeV2 = themeState.themeV2
|
||||
@@ -116,17 +143,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
||||
const pathFormatter = usePathFormatter()
|
||||
const session = createMemo(() => data.session.get(props.request.sessionID))
|
||||
|
||||
const source = createMemo(() => {
|
||||
const tool = props.request.source
|
||||
if (!tool) return { input: undefined, structured: undefined }
|
||||
const message = data.session.message.get(props.request.sessionID, tool.messageID)
|
||||
if (message?.type !== "assistant") return { input: undefined, structured: undefined }
|
||||
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
|
||||
if (part?.type === "tool" && part.state.status !== "streaming") {
|
||||
return { input: part.state.input, structured: part.state.structured }
|
||||
}
|
||||
return { input: undefined, structured: undefined }
|
||||
})
|
||||
const source = usePermissionSource(() => props.request)
|
||||
|
||||
const { themeV2 } = useTheme()
|
||||
|
||||
|
||||
@@ -1,45 +1,52 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useData } from "../../context/data"
|
||||
import { Keyed } from "@opencode-ai/quark"
|
||||
import { useValue } from "@opencode-ai/quark/solid"
|
||||
import { batch, createEffect, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { messageIDFromEvent, useData } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
import { SessionContent } from "./content"
|
||||
import {
|
||||
SessionTimeline,
|
||||
compactionQueuedRow,
|
||||
isTerminalFinish,
|
||||
reduceSessionRows,
|
||||
type AppendPart,
|
||||
type PartRef,
|
||||
type SessionRow,
|
||||
} from "./timeline"
|
||||
|
||||
export type PartRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
}
|
||||
|
||||
export type SessionRow =
|
||||
| { type: "message"; messageID: string }
|
||||
| { type: "compaction-queued"; inputID: string }
|
||||
| { type: "part"; ref: PartRef }
|
||||
| {
|
||||
type: "group"
|
||||
kind: "reasoning"
|
||||
refs: PartRef[]
|
||||
completed: boolean
|
||||
}
|
||||
| {
|
||||
type: "group"
|
||||
kind: "exploration"
|
||||
refs: PartRef[]
|
||||
pending: PartRef[]
|
||||
completed: boolean
|
||||
}
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>) {
|
||||
export function createSessionRows(sessionID: Accessor<string>, options?: { readonly metrics?: Keyed.Metrics }) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const [rows, setRows] = createStore<SessionRow[]>([])
|
||||
const reportMetrics = process.env.OPENCODE_QUARK_METRICS === "1"
|
||||
const metrics = options?.metrics ?? (reportMetrics ? Keyed.metrics() : undefined)
|
||||
const state = SessionTimeline.make({ metrics })
|
||||
const rows = {
|
||||
slots: useValue(state.slots),
|
||||
values: state.values,
|
||||
}
|
||||
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
|
||||
|
||||
const isPending = (messageID: string) => {
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
if (message?.type === "user" || message?.type === "synthetic") return data.session.input.has(sessionID(), messageID)
|
||||
return message?.type === "compaction" && message.status === "running"
|
||||
}
|
||||
|
||||
const setRows = (value: SessionRow[]) => {
|
||||
batch(() => {
|
||||
state.replace(value, isPending, pendingPermissions())
|
||||
})
|
||||
}
|
||||
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const inputs = new Set(data.session.input.list(sessionID()))
|
||||
const boundary = revertBoundary()
|
||||
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs)
|
||||
partitionPending(rows, pendingPermissions())
|
||||
const rows = reduceSessionRows(
|
||||
boundary ? messages.filter((message) => message.id < boundary) : messages,
|
||||
inputs,
|
||||
(message) => data.session.message.parts(sessionID(), message.id).values(),
|
||||
)
|
||||
const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
|
||||
rows.splice(
|
||||
position === -1 ? rows.length : position,
|
||||
@@ -47,7 +54,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
...data.session.pending
|
||||
.list(sessionID())
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
|
||||
.map((item) => compactionQueuedRow(item.id)),
|
||||
)
|
||||
return rows
|
||||
}
|
||||
@@ -62,22 +69,17 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingPermissions()
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
partitionPending(draft, pending)
|
||||
}),
|
||||
)
|
||||
batch(() => state.repartition(pending))
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on([sessionID, () => client.connection.status()], ([id, status]) => {
|
||||
if (status !== "connected") return
|
||||
setRows(reconcile(reduce()))
|
||||
void data.session.pending.sync(id).catch(() => undefined)
|
||||
setRows(reduce())
|
||||
void data.session.message.sync(id).then(
|
||||
() => {
|
||||
if (sessionID() !== id) return
|
||||
setRows(reconcile(reduce()))
|
||||
setRows(reduce())
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
@@ -87,7 +89,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
// Re-reduce when the revert boundary changes (stage/clear/commit).
|
||||
createEffect(
|
||||
on(revertBoundary, () => {
|
||||
setRows(reconcile(reduce()))
|
||||
setRows(reduce())
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -98,7 +100,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
.list(sessionID())
|
||||
.filter((item) => item.type === "compaction")
|
||||
.map((item) => item.id),
|
||||
() => setRows(reconcile(reduce())),
|
||||
() => setRows(reduce()),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -119,68 +121,35 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
{
|
||||
id: message.id,
|
||||
created: message.time.created,
|
||||
input: false,
|
||||
status: message.status,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
() => setRows(reconcile(reduce())),
|
||||
() => setRows(reduce()),
|
||||
),
|
||||
)
|
||||
|
||||
const appendMessage = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
|
||||
const pending = isPending(messageID)
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
const index =
|
||||
message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft)
|
||||
if (!pending) completePrevious(draft, index)
|
||||
draft.splice(index, 0, { type: "message", messageID })
|
||||
}),
|
||||
)
|
||||
batch(() => {
|
||||
const pending = isPending(messageID)
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
state.appendMessage(messageID, { pending, compaction: message?.type === "compaction" })
|
||||
})
|
||||
|
||||
const appendPart = (ref: PartRef, part: AppendPart) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (hasPart(draft, ref)) return
|
||||
append(draft, ref, part, queuedStart(draft))
|
||||
}),
|
||||
)
|
||||
|
||||
const appendFooter = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
|
||||
const index = queuedStart(draft)
|
||||
completePrevious(draft, index)
|
||||
draft.splice(index, 0, { type: "assistant-footer", messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
const removeFooter = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID)
|
||||
if (index !== -1) draft.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
|
||||
const isPending = (messageID: string) => {
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
if (message?.type === "user" || message?.type === "synthetic") return data.session.input.has(sessionID(), messageID)
|
||||
return message?.type === "compaction" && message.status === "running"
|
||||
const appendPart = (ref: PartRef, part: AppendPart) => {
|
||||
// Streaming deltas after the first are no-ops; skip the batch machinery.
|
||||
if (state.hasPart(ref)) return
|
||||
batch(() => state.appendPart(ref, part))
|
||||
}
|
||||
|
||||
const queuedStart = (rows: SessionRow[]) => {
|
||||
const index = rows.findIndex(
|
||||
(row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)),
|
||||
)
|
||||
return index === -1 ? rows.length : index
|
||||
}
|
||||
const appendFooter = (messageID: string) => batch(() => state.appendFooter(messageID))
|
||||
|
||||
const removeFooter = (messageID: string) => batch(() => state.removeFooter(messageID))
|
||||
|
||||
const message = (event: { id: string; data: { sessionID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
|
||||
if (event.data.sessionID === sessionID()) appendMessage(messageIDFromEvent(event.id))
|
||||
}
|
||||
const input = (event: {
|
||||
data: {
|
||||
@@ -198,35 +167,41 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
const subscriptions = [
|
||||
data.on("session.input.admitted", input),
|
||||
data.on("session.compaction.started", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_"))
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? messageIDFromEvent(event.id))
|
||||
}),
|
||||
data.on("session.instructions.updated", message),
|
||||
data.on("session.synthetic", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.description?.trim())
|
||||
appendMessage(event.id.replace(/^evt_/, "msg_"))
|
||||
appendMessage(messageIDFromEvent(event.id))
|
||||
}),
|
||||
data.on("session.shell.started", message),
|
||||
data.on("session.agent.selected", message),
|
||||
data.on("session.model.selected", message),
|
||||
data.on("session.text.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.delta.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" })
|
||||
appendPart(
|
||||
{ messageID: event.data.assistantMessageID, partID: SessionContent.textID(event.data.ordinal) },
|
||||
{ type: "text" },
|
||||
)
|
||||
}),
|
||||
data.on("session.text.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" })
|
||||
appendPart(
|
||||
{ messageID: event.data.assistantMessageID, partID: SessionContent.textID(event.data.ordinal) },
|
||||
{ type: "text" },
|
||||
)
|
||||
}),
|
||||
data.on("session.reasoning.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.delta.trim())
|
||||
appendPart(
|
||||
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
|
||||
{ messageID: event.data.assistantMessageID, partID: SessionContent.reasoningID(event.data.ordinal) },
|
||||
{ type: "reasoning" },
|
||||
)
|
||||
}),
|
||||
data.on("session.reasoning.ended", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart(
|
||||
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
|
||||
{ messageID: event.data.assistantMessageID, partID: SessionContent.reasoningID(event.data.ordinal) },
|
||||
{ type: "reasoning" },
|
||||
)
|
||||
}),
|
||||
@@ -244,7 +219,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
if (event.data.sessionID === sessionID()) removeFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.step.ended", (event) => {
|
||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||
if (event.data.sessionID !== sessionID() || !isTerminalFinish(event.data.finish)) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
}),
|
||||
data.on("session.step.failed", (event) => {
|
||||
@@ -252,128 +227,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
}),
|
||||
]
|
||||
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
|
||||
onCleanup(() => {
|
||||
if (reportMetrics && metrics) console.error(`QUARK_TIMELINE_METRICS ${sessionID()} ${JSON.stringify(metrics)}`)
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set<string>()) {
|
||||
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
|
||||
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
|
||||
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
|
||||
return [
|
||||
...messages.filter((message) => !pending.has(message.id)),
|
||||
...pendingCompactions,
|
||||
...messages.filter(isInput),
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
message.content.forEach((part) => {
|
||||
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
append(rows, { messageID: message.id, partID }, part)
|
||||
})
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "assistant-footer", messageID: message.id })
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
|
||||
const byID = new Map(messages.map((message) => [message.id, message]))
|
||||
const seen = new Set<string>()
|
||||
return rows.map((row) => {
|
||||
const id = rowBoundaryMessageID(row, byID)
|
||||
if (!id || seen.has(id)) return undefined
|
||||
seen.add(id)
|
||||
return id
|
||||
})
|
||||
}
|
||||
|
||||
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
|
||||
if (row.type === "message") {
|
||||
const message = messages.get(row.messageID)
|
||||
if (message?.type === "user" && message.text.trim()) return message.id
|
||||
return undefined
|
||||
}
|
||||
const messageID =
|
||||
row.type === "part"
|
||||
? row.ref.messageID
|
||||
: row.type === "group"
|
||||
? row.refs[0]?.messageID
|
||||
: row.type === "assistant-footer"
|
||||
? row.messageID
|
||||
: undefined
|
||||
if (!messageID) return undefined
|
||||
const message = messages.get(messageID)
|
||||
if (message?.type === "assistant") return message.id
|
||||
}
|
||||
|
||||
export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
||||
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
||||
if (tool) return tool
|
||||
const match = /^(text|reasoning):(\d+)$/.exec(partID)
|
||||
if (!match) return
|
||||
const ordinal = Number(match[2])
|
||||
return message.content.filter((part) => part.type === match[1])[ordinal]
|
||||
}
|
||||
|
||||
type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string }
|
||||
|
||||
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
|
||||
if (part.type === "reasoning") {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === "reasoning") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false })
|
||||
return
|
||||
}
|
||||
if (part.type === "tool" && exploration(part.name)) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, { type: "part", ref })
|
||||
}
|
||||
|
||||
function completePrevious(rows: SessionRow[], index = rows.length) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group") previous.completed = true
|
||||
}
|
||||
|
||||
function partitionPending(rows: SessionRow[], pending: Set<string>) {
|
||||
rows.forEach((row) => {
|
||||
if (row.type !== "group" || row.kind !== "exploration") return
|
||||
const refs = [...row.refs, ...row.pending]
|
||||
row.refs = refs.filter((ref) => !pending.has(ref.partID))
|
||||
row.pending = refs.filter((ref) => pending.has(ref.partID))
|
||||
})
|
||||
}
|
||||
|
||||
function exploration(name: string) {
|
||||
return ["read", "glob", "grep"].includes(name.toLowerCase())
|
||||
}
|
||||
|
||||
function hasPart(rows: SessionRow[], ref: PartRef) {
|
||||
return rows.some((row) => {
|
||||
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
|
||||
if (row.type !== "group") return false
|
||||
const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs
|
||||
return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { Keyed, Layout, Transaction } from "@opencode-ai/quark"
|
||||
import { SessionContent } from "./content"
|
||||
|
||||
const PartRefLayout = Layout.struct({
|
||||
messageID: Layout.string,
|
||||
partID: Layout.string,
|
||||
})
|
||||
|
||||
const SessionRowLayout = Layout.keyedUnion({
|
||||
key: Layout.key("id", Layout.string),
|
||||
tag: "type",
|
||||
variants: {
|
||||
message: Layout.struct({ messageID: Layout.string }),
|
||||
"compaction-queued": Layout.struct({ inputID: Layout.string }),
|
||||
part: Layout.struct({ ref: PartRefLayout }),
|
||||
group: Layout.union({
|
||||
tag: "kind",
|
||||
variants: {
|
||||
reasoning: Layout.struct({
|
||||
origin: Layout.immutable(PartRefLayout),
|
||||
refs: Layout.array(PartRefLayout),
|
||||
completed: Layout.boolean,
|
||||
}),
|
||||
exploration: Layout.struct({
|
||||
origin: Layout.immutable(PartRefLayout),
|
||||
refs: Layout.array(PartRefLayout),
|
||||
pending: Layout.array(PartRefLayout),
|
||||
completed: Layout.boolean,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
"assistant-footer": Layout.struct({ messageID: Layout.string }),
|
||||
},
|
||||
})
|
||||
|
||||
export type PartRef = Layout.Type<typeof PartRefLayout>
|
||||
export type SessionRow = Layout.Type<typeof SessionRowLayout>
|
||||
export type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string }
|
||||
|
||||
const SessionRows = Layout.collection(
|
||||
SessionRowLayout,
|
||||
({ members }) => ({
|
||||
parts: members((row) => {
|
||||
if (row.type === "part") return [row.id]
|
||||
if (row.type !== "group") return []
|
||||
if (row.kind === "reasoning") return row.refs.map(partRowID)
|
||||
return [...row.refs, ...row.pending].map(partRowID)
|
||||
}),
|
||||
}),
|
||||
{ backend: "generated" },
|
||||
)
|
||||
|
||||
export namespace SessionTimeline {
|
||||
export function make(options?: { readonly metrics?: Keyed.Metrics }) {
|
||||
const state = SessionRows.make([], options)
|
||||
let activeGroupID: string | undefined
|
||||
let queuedBoundaryID: string | undefined
|
||||
const queuedRowIDs = new Set<string>()
|
||||
|
||||
const insert = (row: SessionRow) => state.insert(row, queuedBoundaryID ? { before: queuedBoundaryID } : "end")
|
||||
|
||||
const complete = () => {
|
||||
if (!activeGroupID) return
|
||||
state.modify(activeGroupID, (row) => (row.type === "group" ? { ...row, completed: true } : row))
|
||||
activeGroupID = undefined
|
||||
}
|
||||
|
||||
const nextQueued = (key: string) => {
|
||||
const row = state.after(key)?.()
|
||||
return row && queuedRowIDs.has(row.id) ? row.id : undefined
|
||||
}
|
||||
|
||||
const replace = (
|
||||
rows: readonly SessionRow[],
|
||||
isQueued: (messageID: string) => boolean,
|
||||
pending: ReadonlySet<string>,
|
||||
) =>
|
||||
Transaction.run(() => {
|
||||
queuedBoundaryID = undefined
|
||||
activeGroupID = undefined
|
||||
queuedRowIDs.clear()
|
||||
state.set(
|
||||
rows.map((row) => {
|
||||
const next = partition(row, pending)
|
||||
const queued = next.type === "compaction-queued" || (next.type === "message" && isQueued(next.messageID))
|
||||
if (queued) {
|
||||
queuedRowIDs.add(next.id)
|
||||
queuedBoundaryID ??= next.id
|
||||
return next
|
||||
}
|
||||
if (queuedBoundaryID) return next
|
||||
activeGroupID = next.type === "group" && !next.completed ? next.id : undefined
|
||||
return next
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const appendMessage = (messageID: string, status: { readonly pending: boolean; readonly compaction: boolean }) => {
|
||||
const id = messageRowID(messageID)
|
||||
const exists = state.has(id)
|
||||
if (exists && (status.pending || !queuedRowIDs.has(id))) return
|
||||
Transaction.run(() => {
|
||||
const row = messageRow(messageID)
|
||||
if (status.pending) {
|
||||
queuedRowIDs.add(row.id)
|
||||
if (!status.compaction) {
|
||||
state.insert(row, "end")
|
||||
queuedBoundaryID ??= row.id
|
||||
return
|
||||
}
|
||||
insert(row)
|
||||
queuedBoundaryID = row.id
|
||||
return
|
||||
}
|
||||
if (!exists) {
|
||||
complete()
|
||||
insert(row)
|
||||
return
|
||||
}
|
||||
queuedRowIDs.delete(row.id)
|
||||
complete()
|
||||
if (queuedBoundaryID === row.id) {
|
||||
queuedBoundaryID = nextQueued(row.id)
|
||||
return
|
||||
}
|
||||
if (queuedBoundaryID) state.move(row.id, { before: queuedBoundaryID })
|
||||
})
|
||||
}
|
||||
|
||||
const appendPart = (ref: PartRef, part: AppendPart) => {
|
||||
const id = partRowID(ref)
|
||||
if (state.hasMember("parts", id)) return
|
||||
Transaction.run(() => {
|
||||
const kind = groupKind(part)
|
||||
const active = activeGroupID ? state.get(activeGroupID)?.() : undefined
|
||||
if (kind && active?.type === "group" && active.kind === kind) {
|
||||
state.modify(active.id, (row) => (row.type === "group" ? { ...row, refs: [...row.refs, ref] } : row), {
|
||||
members: { parts: { add: [id] } },
|
||||
})
|
||||
return
|
||||
}
|
||||
complete()
|
||||
const row = kind ? groupRow(kind, ref) : partRow(ref)
|
||||
insert(row)
|
||||
activeGroupID = row.type === "group" ? row.id : undefined
|
||||
})
|
||||
}
|
||||
|
||||
const appendFooter = (messageID: string) => {
|
||||
const id = footerRowID(messageID)
|
||||
if (state.has(id)) return
|
||||
Transaction.run(() => {
|
||||
const row = footerRow(messageID)
|
||||
complete()
|
||||
insert(row)
|
||||
})
|
||||
}
|
||||
|
||||
const removeFooter = (messageID: string) => state.remove(footerRowID(messageID))
|
||||
|
||||
const repartition = (pending: ReadonlySet<string>) =>
|
||||
Transaction.run(() => {
|
||||
state.values().forEach((row) => {
|
||||
if (row.type !== "group" || row.kind !== "exploration") return
|
||||
const next = partition(row, pending)
|
||||
if (next !== row) state.modify(row.id, () => next)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
slots: state.slots,
|
||||
values: state.values,
|
||||
replace,
|
||||
appendMessage,
|
||||
appendPart,
|
||||
appendFooter,
|
||||
removeFooter,
|
||||
repartition,
|
||||
/** Cheap no-op check so per-delta callers can skip batching machinery. */
|
||||
hasPart: (ref: PartRef) => state.hasMember("parts", partRowID(ref)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reduceSessionRows(
|
||||
messages: SessionMessageInfo[],
|
||||
inputs = new Set<string>(),
|
||||
partsOf?: (message: SessionMessageAssistant & { id: string }) => readonly SessionContent.Part[],
|
||||
) {
|
||||
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
|
||||
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
|
||||
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
|
||||
return [
|
||||
...messages.filter((message) => !pending.has(message.id)),
|
||||
...pendingCompactions,
|
||||
...messages.filter(isInput),
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
rows.push(messageRow(message.id))
|
||||
return rows
|
||||
}
|
||||
const parts = partsOf ? partsOf(message) : SessionContent.withPartIDs(message.content)
|
||||
parts.forEach((part) => {
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
append(rows, { messageID: message.id, partID: part.partID }, part)
|
||||
})
|
||||
if (isTerminalFinish(message.finish) || message.error || message.retry) {
|
||||
completePrevious(rows)
|
||||
rows.push(footerRow(message.id))
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function messageBoundaryIDs(rows: readonly SessionRow[], messages: SessionMessageInfo[]) {
|
||||
const byID = new Map(messages.map((message) => [message.id, message]))
|
||||
const seen = new Set<string>()
|
||||
return rows.map((row) => {
|
||||
const id = rowBoundaryMessageID(row, byID)
|
||||
if (!id || seen.has(id)) return undefined
|
||||
seen.add(id)
|
||||
return id
|
||||
})
|
||||
}
|
||||
|
||||
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
|
||||
if (row.type === "message") {
|
||||
const message = messages.get(row.messageID)
|
||||
if (message?.type === "user" && message.text.trim()) return message.id
|
||||
return undefined
|
||||
}
|
||||
const messageID =
|
||||
row.type === "part"
|
||||
? row.ref.messageID
|
||||
: row.type === "group"
|
||||
? row.origin.messageID
|
||||
: row.type === "assistant-footer"
|
||||
? row.messageID
|
||||
: undefined
|
||||
if (!messageID) return undefined
|
||||
const message = messages.get(messageID)
|
||||
if (message?.type === "assistant") return message.id
|
||||
}
|
||||
|
||||
export function isTerminalFinish(finish: string | undefined) {
|
||||
return !!finish && !["tool-calls", "unknown"].includes(finish)
|
||||
}
|
||||
|
||||
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
|
||||
const previous = rows[index - 1]
|
||||
const kind = groupKind(part)
|
||||
if (kind && previous?.type === "group" && previous.kind === kind) {
|
||||
rows[index - 1] = { ...previous, refs: [...previous.refs, ref] }
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, kind ? groupRow(kind, ref) : partRow(ref))
|
||||
}
|
||||
|
||||
function groupKind(part: AppendPart) {
|
||||
if (part.type === "reasoning") return "reasoning" as const
|
||||
if (part.type === "tool" && exploration(part.name)) return "exploration" as const
|
||||
}
|
||||
|
||||
function completePrevious(rows: SessionRow[], index = rows.length) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group") rows[index - 1] = { ...previous, completed: true }
|
||||
}
|
||||
|
||||
function partition(row: SessionRow, pending: ReadonlySet<string>): SessionRow {
|
||||
if (row.type !== "group" || row.kind !== "exploration") return row
|
||||
const changed = row.refs.some((ref) => pending.has(ref.partID)) || row.pending.some((ref) => !pending.has(ref.partID))
|
||||
if (!changed) return row
|
||||
const refs = [...row.refs, ...row.pending]
|
||||
return {
|
||||
...row,
|
||||
refs: refs.filter((ref) => !pending.has(ref.partID)),
|
||||
pending: refs.filter((ref) => pending.has(ref.partID)),
|
||||
}
|
||||
}
|
||||
|
||||
function exploration(name: string) {
|
||||
return ["read", "glob", "grep"].includes(name.toLowerCase())
|
||||
}
|
||||
|
||||
function messageRow(messageID: string): SessionRow {
|
||||
return { id: messageRowID(messageID), type: "message", messageID }
|
||||
}
|
||||
|
||||
function messageRowID(messageID: string) {
|
||||
return `m${segment(messageID)}`
|
||||
}
|
||||
|
||||
export function compactionQueuedRow(inputID: string): SessionRow {
|
||||
return { id: `c${segment(inputID)}`, type: "compaction-queued", inputID }
|
||||
}
|
||||
|
||||
function partRow(ref: PartRef): SessionRow {
|
||||
return { id: partRowID(ref), type: "part", ref }
|
||||
}
|
||||
|
||||
function partRowID(ref: PartRef) {
|
||||
return `p${segment(ref.messageID)}${segment(ref.partID)}`
|
||||
}
|
||||
|
||||
function groupRow(kind: "reasoning" | "exploration", ref: PartRef): SessionRow {
|
||||
const id = `g${kind === "reasoning" ? "r" : "e"}${segment(ref.messageID)}${segment(ref.partID)}`
|
||||
if (kind === "reasoning") return { id, type: "group", kind, origin: ref, refs: [ref], completed: false }
|
||||
return { id, type: "group", kind, origin: ref, refs: [ref], pending: [], completed: false }
|
||||
}
|
||||
|
||||
function footerRow(messageID: string): SessionRow {
|
||||
return { id: footerRowID(messageID), type: "assistant-footer", messageID }
|
||||
}
|
||||
|
||||
function footerRowID(messageID: string) {
|
||||
return `f${segment(messageID)}`
|
||||
}
|
||||
|
||||
function segment(value: string) {
|
||||
return `${value.length}:${value}`
|
||||
}
|
||||
@@ -19,6 +19,27 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
|
||||
return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(", ")}]`
|
||||
}
|
||||
|
||||
const toolDisplays = new Set([
|
||||
"shell",
|
||||
"glob",
|
||||
"read",
|
||||
"grep",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"write",
|
||||
"edit",
|
||||
"subagent",
|
||||
"execute",
|
||||
"patch",
|
||||
"question",
|
||||
"skill",
|
||||
])
|
||||
|
||||
export function toolDisplay(tool: string) {
|
||||
const normalized = canonicalToolName(tool)
|
||||
return toolDisplays.has(normalized) ? normalized : "generic"
|
||||
}
|
||||
|
||||
export function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
|
||||
@@ -8,7 +8,8 @@ import { createEffect, onMount, type ParentProps } from "solid-js"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createSessionRows } from "../../../src/routes/session/rows"
|
||||
import type { SessionRow } from "../../../src/routes/session/timeline"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
@@ -28,6 +29,19 @@ async function wait(fn: () => boolean, timeout = 2000) {
|
||||
}
|
||||
}
|
||||
|
||||
function readRows(rows: ReturnType<typeof createSessionRows>) {
|
||||
return rows.values()
|
||||
}
|
||||
|
||||
function withoutRowID(row: SessionRow) {
|
||||
const { id: _id, ...value } = row
|
||||
if (value.type === "group") {
|
||||
const { origin: _origin, ...group } = value
|
||||
return group
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
|
||||
events.emit({ ...event, location: { directory } })
|
||||
}
|
||||
@@ -717,10 +731,16 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
||||
}, events)
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
let structuralUpdates = 0
|
||||
const metrics = { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 }
|
||||
|
||||
function Probe() {
|
||||
client = useClient()
|
||||
rows = createSessionRows(() => sessionID)
|
||||
rows = createSessionRows(() => sessionID, { metrics })
|
||||
createEffect(() => {
|
||||
rows.slots()
|
||||
structuralUpdates++
|
||||
})
|
||||
return <box />
|
||||
}
|
||||
|
||||
@@ -762,31 +782,81 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
||||
name: "read",
|
||||
},
|
||||
})
|
||||
await wait(() => rows.some((row) => row.type === "group" && !row.completed))
|
||||
await wait(() => readRows(rows).some((row) => row.type === "group" && !row.completed))
|
||||
expect(metrics.slotPublications).toBe(0)
|
||||
expect(metrics.structuralPublications).toBe(1)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_tool_started_2",
|
||||
created: 2,
|
||||
type: "session.tool.input.started",
|
||||
durable: durable(sessionID, 2),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
callID: "call-read-2",
|
||||
name: "read",
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const group = readRows(rows).find((row) => row.type === "group")
|
||||
return group?.refs.length === 2
|
||||
})
|
||||
expect(metrics.slotPublications).toBe(1)
|
||||
expect(metrics.structuralPublications).toBe(1)
|
||||
const groupSlot = rows.slots().find((slot) => slot().type === "group")
|
||||
expect(groupSlot).toBeDefined()
|
||||
const beforePermission = structuralUpdates
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_asked",
|
||||
created: 2,
|
||||
type: "permission.v2.asked",
|
||||
data: {
|
||||
id: "permission-read",
|
||||
sessionID,
|
||||
action: "read",
|
||||
resources: ["src/example.ts"],
|
||||
source: { type: "tool", messageID: "message-assistant", callID: "call-read" },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const group = readRows(rows).find((row) => row.type === "group" && row.kind === "exploration")
|
||||
return group?.pending[0]?.partID === "call-read"
|
||||
})
|
||||
expect(rows.slots().find((slot) => slot().type === "group")).toBe(groupSlot)
|
||||
expect(structuralUpdates).toBe(beforePermission)
|
||||
expect(metrics.slotPublications).toBe(2)
|
||||
expect(metrics.structuralPublications).toBe(1)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_prompt_admitted",
|
||||
created: 3,
|
||||
type: "session.input.admitted",
|
||||
durable: durable(sessionID, 2),
|
||||
durable: durable(sessionID, 3),
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: "message-user",
|
||||
input: { type: "user", data: { text: "Continue" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await wait(() => rows.at(-1)?.type === "message")
|
||||
expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
|
||||
await wait(() => readRows(rows).at(-1)?.type === "message")
|
||||
expect(readRows(rows).find((row) => row.type === "group")?.completed).toBe(false)
|
||||
expect(metrics.slotPublications).toBe(2)
|
||||
expect(metrics.structuralPublications).toBe(2)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_prompt_promoted",
|
||||
created: 4,
|
||||
type: "session.input.promoted",
|
||||
durable: durable(sessionID, 3),
|
||||
durable: durable(sessionID, 4),
|
||||
data: { sessionID, inputID: "message-user" },
|
||||
})
|
||||
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
|
||||
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
|
||||
await wait(() => readRows(rows).find((row) => row.type === "group")?.completed === true)
|
||||
expect(rows.slots().find((slot) => slot().type === "group")).toBe(groupSlot)
|
||||
expect(withoutRowID(readRows(rows).at(-1)!)).toEqual({ type: "message", messageID: "message-user" })
|
||||
expect(metrics.slotPublications).toBe(3)
|
||||
expect(metrics.structuralPublications).toBe(2)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -834,8 +904,274 @@ test("classifies live tool rows independently of their call ID", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => rows.length > 0)
|
||||
expect(rows).toEqual([{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } }])
|
||||
await wait(() => readRows(rows).length > 0)
|
||||
expect(readRows(rows).map(withoutRowID)).toEqual([
|
||||
{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } },
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("publishes streaming deltas to one part slot without touching siblings", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-part-slots"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
client = useClient()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_slots_step",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_slots_tool",
|
||||
created: 2,
|
||||
type: "session.tool.input.started",
|
||||
durable: durable(sessionID, 1),
|
||||
data: { sessionID, assistantMessageID: "message-assistant", callID: "call-slots", name: "read" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_slots_text",
|
||||
created: 3,
|
||||
type: "session.text.started",
|
||||
durable: durable(sessionID, 2),
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 },
|
||||
})
|
||||
const parts = data.session.message.parts(sessionID, "message-assistant")
|
||||
await wait(() => parts.get("text:0") !== undefined && parts.get("call-slots") !== undefined)
|
||||
|
||||
const publications = { text: 0, tool: 0, structure: 0 }
|
||||
const disposers = [
|
||||
parts.get("text:0")!.subscribe(() => publications.text++),
|
||||
parts.get("call-slots")!.subscribe(() => publications.tool++),
|
||||
parts.slots.subscribe(() => publications.structure++),
|
||||
]
|
||||
emitEvent(events, {
|
||||
id: "evt_slots_delta_1",
|
||||
created: 4,
|
||||
type: "session.text.delta",
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "one" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_slots_delta_2",
|
||||
created: 5,
|
||||
type: "session.text.delta",
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" },
|
||||
})
|
||||
await wait(() => {
|
||||
const part = parts.get("text:0")?.()
|
||||
return part?.type === "text" && part.text === "onetwo"
|
||||
})
|
||||
|
||||
// One slot publication per delta; the sibling tool slot and the part
|
||||
// structure never publish, independent of message size.
|
||||
expect(publications).toEqual({ text: 2, tool: 0, structure: 0 })
|
||||
disposers.forEach((dispose) => dispose())
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps part slot references alive across message sync", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-slot-identity"
|
||||
// The refetch snapshot is identical to the streamed state, so any failure
|
||||
// here is collection identity loss, not snapshot staleness.
|
||||
let snapshot: unknown[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: snapshot, cursor: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
client = useClient()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_identity_step",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_identity_text",
|
||||
created: 2,
|
||||
type: "session.text.started",
|
||||
durable: durable(sessionID, 1),
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_identity_ended",
|
||||
created: 3,
|
||||
type: "session.text.ended",
|
||||
durable: durable(sessionID, 2),
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, text: "hello world" },
|
||||
})
|
||||
// A mounted view captures the collection once, at useSlot setup.
|
||||
const mounted = data.session.message.parts(sessionID, "message-assistant")
|
||||
await wait(() => {
|
||||
const part = mounted.get("text:0")?.()
|
||||
return part?.type === "text" && part.text === "hello world"
|
||||
})
|
||||
|
||||
snapshot = [
|
||||
{
|
||||
id: "message-assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "hello world" }],
|
||||
time: { created: 1 },
|
||||
},
|
||||
]
|
||||
await data.session.message.sync(sessionID)
|
||||
|
||||
// The registry must hand back the same collection the view is holding.
|
||||
expect(data.session.message.parts(sessionID, "message-assistant")).toBe(mounted)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_identity_delta",
|
||||
created: 4,
|
||||
type: "session.text.delta",
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "!!" },
|
||||
})
|
||||
// The delta must reach the reference a mounted view is subscribed to.
|
||||
await wait(() => {
|
||||
const part = mounted.get("text:0")?.()
|
||||
return part?.type === "text" && part.text === "hello world!!"
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not publish timeline rows for duplicate streaming deltas", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-stream-metrics"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
const metrics = { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 }
|
||||
let data!: ReturnType<typeof useData>
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
client = useClient()
|
||||
rows = createSessionRows(() => sessionID, { metrics })
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_stream_step",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_stream_started",
|
||||
created: 2,
|
||||
type: "session.text.started",
|
||||
durable: durable(sessionID, 1),
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_stream_delta_1",
|
||||
created: 3,
|
||||
type: "session.text.delta",
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "one" },
|
||||
})
|
||||
await wait(() => readRows(rows).some((row) => row.type === "part"))
|
||||
const afterFirst = { ...metrics }
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_stream_delta_2",
|
||||
created: 4,
|
||||
type: "session.text.delta",
|
||||
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" },
|
||||
})
|
||||
await wait(() => {
|
||||
const part = data.session.message.parts(sessionID, "message-assistant").get("text:0")?.()
|
||||
return part?.type === "text" && part.text === "onetwo"
|
||||
})
|
||||
|
||||
expect(afterFirst).toEqual({ slotPublications: 0, structuralPublications: 1, equivalenceSuppressions: 0 })
|
||||
expect(metrics).toEqual(afterFirst)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -987,13 +1323,15 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let rows!: SessionRow[]
|
||||
let manualRows!: SessionRow[]
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let manualRows!: ReturnType<typeof createSessionRows>
|
||||
let liveRows!: ReturnType<typeof createSessionRows>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
rows = createSessionRows(() => "session-retry")
|
||||
manualRows = createSessionRows(() => "session-manual")
|
||||
liveRows = createSessionRows(() => "session-live")
|
||||
return <box />
|
||||
}
|
||||
|
||||
@@ -1189,7 +1527,7 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry?.attempt === 2
|
||||
})
|
||||
await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||
await wait(() => readRows(rows).some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_next_step",
|
||||
created: 2_000,
|
||||
@@ -1206,7 +1544,9 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
const assistant = data.session.message.get("session-retry", "message-retry")
|
||||
return assistant?.type === "assistant" && assistant.retry === undefined
|
||||
})
|
||||
await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
|
||||
await wait(
|
||||
() => !readRows(rows).some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"),
|
||||
)
|
||||
expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1)
|
||||
emitEvent(events, {
|
||||
id: "evt_retry_scheduled_again",
|
||||
@@ -1261,7 +1601,9 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
|
||||
})
|
||||
expect(data.session.pending.list("session-manual")).toEqual([])
|
||||
const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")
|
||||
const compactionRow = readRows(manualRows).find(
|
||||
(row) => row.type === "message" && row.messageID === "message-compaction",
|
||||
)
|
||||
emitEvent(events, {
|
||||
id: "evt_manual_compaction_ended",
|
||||
created: 3,
|
||||
@@ -1273,10 +1615,12 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
const message = data.session.message.get("session-manual", "message-compaction")
|
||||
return message?.type === "compaction" && message.status === "completed"
|
||||
})
|
||||
expect(manualRows.filter((row) => row.type === "message")).toEqual([
|
||||
{ type: "message", messageID: "message-compaction" },
|
||||
])
|
||||
expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
|
||||
expect(
|
||||
readRows(manualRows)
|
||||
.filter((row) => row.type === "message")
|
||||
.map(withoutRowID),
|
||||
).toEqual([{ type: "message", messageID: "message-compaction" }])
|
||||
expect(readRows(manualRows).find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
|
||||
compactionRow,
|
||||
)
|
||||
|
||||
@@ -1303,7 +1647,10 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
const message = data.session.message.get("session-live", "msg_compaction_started")
|
||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
|
||||
})
|
||||
const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")
|
||||
const autoCompactionRow = readRows(liveRows).find(
|
||||
(row) => row.type === "message" && row.messageID === "msg_compaction_started",
|
||||
)
|
||||
expect(autoCompactionRow).toBeDefined()
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_ended",
|
||||
@@ -1321,10 +1668,12 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
status: "completed",
|
||||
summary: "Live summary",
|
||||
})
|
||||
expect(rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe(
|
||||
expect(readRows(liveRows).find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe(
|
||||
autoCompactionRow,
|
||||
)
|
||||
expect(rows.some((row) => row.type === "message" && row.messageID === "msg_compaction_ended")).toBeFalse()
|
||||
expect(
|
||||
readRows(liveRows).some((row) => row.type === "message" && row.messageID === "msg_compaction_ended"),
|
||||
).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -1383,8 +1732,12 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
"message-compaction-queued",
|
||||
"message-compaction-later",
|
||||
])
|
||||
await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2)
|
||||
expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([
|
||||
await wait(() => readRows(rows).filter((row) => row.type === "compaction-queued").length === 2)
|
||||
expect(
|
||||
readRows(rows)
|
||||
.filter((row) => row.type === "compaction-queued")
|
||||
.map(withoutRowID),
|
||||
).toEqual([
|
||||
{ type: "compaction-queued", inputID: "message-compaction-queued" },
|
||||
{ type: "compaction-queued", inputID: "message-compaction-later" },
|
||||
])
|
||||
@@ -1401,8 +1754,8 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
text: "Active output",
|
||||
},
|
||||
})
|
||||
await wait(() => rows.some((row) => row.type === "part"))
|
||||
expect(rows.map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
|
||||
await wait(() => readRows(rows).some((row) => row.type === "part"))
|
||||
expect(readRows(rows).map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_started",
|
||||
@@ -2343,12 +2696,9 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
})
|
||||
|
||||
await wait(() => {
|
||||
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
|
||||
const part = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.()
|
||||
return (
|
||||
assistant?.type === "assistant" &&
|
||||
assistant.content[0]?.type === "tool" &&
|
||||
assistant.content[0].state.status === "running" &&
|
||||
assistant.content[0].state.structured.sessionID === "session-child"
|
||||
part?.type === "tool" && part.state.status === "running" && part.state.structured.sessionID === "session-child"
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2368,19 +2718,15 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
})
|
||||
|
||||
await wait(() => {
|
||||
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
|
||||
return (
|
||||
assistant?.type === "assistant" &&
|
||||
assistant.content[0]?.type === "tool" &&
|
||||
assistant.content[0].state.status === "error"
|
||||
)
|
||||
const part = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.()
|
||||
return part?.type === "tool" && part.state.status === "error"
|
||||
})
|
||||
|
||||
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
|
||||
expect(assistant?.type).toBe("assistant")
|
||||
if (assistant?.type !== "assistant") return
|
||||
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
||||
const tool = assistant.content[0]
|
||||
const tool = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.()
|
||||
expect(tool?.type).toBe("tool")
|
||||
if (tool?.type !== "tool") return
|
||||
expect(tool.state.status).toBe("error")
|
||||
@@ -2407,16 +2753,40 @@ test("settles pending tools when a live failure arrives", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("renders admitted prompts immediately and tracks them until promoted", async () => {
|
||||
test("preserves admitted prompts when hydration races with promotion", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-1"
|
||||
const messageID = "msg_user_1"
|
||||
const queuedID = "msg_user_2"
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`)
|
||||
if (url.pathname === `/api/session/${sessionID}/pending`)
|
||||
return json({
|
||||
data: [{ id: messageID, type: "user", text: "hello", time: { created: 0 } }],
|
||||
cursor: {},
|
||||
data: [
|
||||
{
|
||||
admittedSeq: 0,
|
||||
id: messageID,
|
||||
sessionID,
|
||||
timeCreated: 0,
|
||||
type: "user",
|
||||
data: { text: "hello" },
|
||||
delivery: "steer",
|
||||
},
|
||||
{
|
||||
admittedSeq: 1,
|
||||
id: queuedID,
|
||||
sessionID,
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
data: { text: "queued" },
|
||||
delivery: "queue",
|
||||
},
|
||||
],
|
||||
})
|
||||
if (url.pathname !== `/api/session/${sessionID}/message`) return
|
||||
requested.resolve()
|
||||
return response.promise
|
||||
}, events)
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
@@ -2474,12 +2844,11 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
||||
])
|
||||
expect(sync.session.input.list(sessionID)).toEqual([messageID])
|
||||
|
||||
await sync.session.message.sync(sessionID)
|
||||
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
|
||||
|
||||
const refresh = sync.session.message.sync(sessionID)
|
||||
await requested.promise
|
||||
emitEvent(events, {
|
||||
id: "evt_prompted_1",
|
||||
created: 0,
|
||||
created: 1,
|
||||
type: "session.input.promoted",
|
||||
durable: durable(sessionID, 1),
|
||||
data: {
|
||||
@@ -2491,14 +2860,17 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
||||
await wait(() => received.at(-1) === "session.input.promoted")
|
||||
expect(received.slice(-2)).toEqual(["session.input.admitted", "session.input.promoted"])
|
||||
unsubscribe()
|
||||
const message = sync.session.message.list(sessionID)?.[0]
|
||||
response.resolve(json({ data: [], cursor: {} }))
|
||||
await refresh
|
||||
|
||||
const message = sync.session.message.get(sessionID, messageID)
|
||||
expect(message?.type).toBe("user")
|
||||
if (message?.type !== "user") return
|
||||
expect(message).toMatchObject({ id: messageID, text: "hello" })
|
||||
expect(message.metadata).toBeUndefined()
|
||||
expect(sync.session.pending.list(sessionID)).toEqual([])
|
||||
expect(sync.session.input.list(sessionID)).toEqual([])
|
||||
expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID])
|
||||
expect(sync.session.input.list(sessionID)).toEqual([queuedID])
|
||||
expect(sync.session.message.list(sessionID).map((message) => message.id)).toEqual([messageID, queuedID])
|
||||
expect(sync.session.message.get(sessionID, queuedID)).toMatchObject({ id: queuedID, text: "queued" })
|
||||
expect(sync.session.message.list("missing")).toEqual([])
|
||||
expect(sync.session.message.get(sessionID, messageID)).toBe(message)
|
||||
expect(sync.session.message.get(sessionID, "missing")).toBeUndefined()
|
||||
@@ -2508,6 +2880,96 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
||||
}
|
||||
})
|
||||
|
||||
test("reconciles admissions and promotions that arrive while pending work hydrates", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-pending-race"
|
||||
const messageID = "msg_late_user"
|
||||
const promotedID = "msg_promoted_user"
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== `/api/session/${sessionID}/pending`) return
|
||||
requested.resolve()
|
||||
return response.promise
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
const sync = data.session.pending.sync(sessionID)
|
||||
await requested.promise
|
||||
emitEvent(events, {
|
||||
id: "evt_late_admitted",
|
||||
created: 1,
|
||||
type: "session.input.admitted",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: messageID,
|
||||
input: { type: "user", data: { text: "late" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_promoted_admitted",
|
||||
created: 2,
|
||||
type: "session.input.admitted",
|
||||
durable: durable(sessionID, 1),
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: promotedID,
|
||||
input: { type: "user", data: { text: "promoted" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_promoted",
|
||||
created: 3,
|
||||
type: "session.input.promoted",
|
||||
durable: durable(sessionID, 2),
|
||||
data: { sessionID, inputID: promotedID },
|
||||
})
|
||||
await wait(() => data.session.pending.list(sessionID).length === 1)
|
||||
response.resolve(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
admittedSeq: 1,
|
||||
id: promotedID,
|
||||
sessionID,
|
||||
timeCreated: 2,
|
||||
type: "user",
|
||||
data: { text: "promoted" },
|
||||
delivery: "steer",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
await sync
|
||||
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([messageID])
|
||||
expect(data.session.input.list(sessionID)).toEqual([messageID])
|
||||
expect(data.session.message.get(sessionID, messageID)).toMatchObject({ text: "late" })
|
||||
expect(data.session.message.get(sessionID, promotedID)).toMatchObject({ text: "promoted" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("skips initial instruction state and projects later updates with their message ID", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
parseDiagnostics,
|
||||
parseQuestionAnswers,
|
||||
parseQuestions,
|
||||
toolDisplay,
|
||||
} from "../../../src/routes/session"
|
||||
import { toolDisplay } from "../../../src/util/tool-display"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ const messages: SessionMessageInfo[] = [
|
||||
assistant("assistant-1", "Response"),
|
||||
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
|
||||
]
|
||||
const hasText = (messageID: string) => {
|
||||
const message = messages.find((item) => item.id === messageID)
|
||||
return message?.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim())
|
||||
}
|
||||
const children = [
|
||||
{ id: "user-1", y: 0 },
|
||||
{ id: "assistant-1", y: 20 },
|
||||
@@ -24,6 +28,7 @@ test("finds the next user message without stopping at an assistant message", ()
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
userOnly: true,
|
||||
@@ -37,6 +42,7 @@ test("finds the previous user message without stopping at an assistant message",
|
||||
direction: "prev",
|
||||
children: children.map((child) => ({ ...child, y: child.y - 35 })),
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 35,
|
||||
viewportY: 0,
|
||||
userOnly: true,
|
||||
@@ -50,6 +56,7 @@ test("preserves navigation across both user and assistant messages", () => {
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
}),
|
||||
@@ -59,6 +66,7 @@ test("preserves navigation across both user and assistant messages", () => {
|
||||
direction: "prev",
|
||||
children: children.map((child) => ({ ...child, y: child.y - 35 })),
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 35,
|
||||
viewportY: 0,
|
||||
}),
|
||||
@@ -71,6 +79,7 @@ test("uses the selected message when the viewport is too tall to scroll", () =>
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-1",
|
||||
@@ -82,6 +91,7 @@ test("uses the selected message when the viewport is too tall to scroll", () =>
|
||||
direction: "prev",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
@@ -96,6 +106,7 @@ test("stops at the first and last selected user message", () => {
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
@@ -107,6 +118,7 @@ test("stops at the first and last selected user message", () => {
|
||||
direction: "prev",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-1",
|
||||
@@ -121,6 +133,7 @@ test("keeps the logical boundary when layout temporarily moves it outside the vi
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
@@ -135,6 +148,7 @@ test("stops at the first and last message", () => {
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
@@ -145,6 +159,7 @@ test("stops at the first and last message", () => {
|
||||
direction: "prev",
|
||||
children,
|
||||
messages,
|
||||
hasText,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-1",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { OpenCodeEvent, PermissionV2Request } from "@opencode-ai/client"
|
||||
import { createEffect, createSignal, type ParentProps } from "solid-js"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { usePermissionInput } from "../../../src/routes/session/permission"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function SyncLocation() {
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
createEffect(() => location.set(data.location.default()))
|
||||
return null
|
||||
}
|
||||
|
||||
function DataProvider(props: ParentProps) {
|
||||
return (
|
||||
<DataProviderBase>
|
||||
<LocationProvider>
|
||||
<SyncLocation />
|
||||
{props.children}
|
||||
</LocationProvider>
|
||||
</DataProviderBase>
|
||||
)
|
||||
}
|
||||
|
||||
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
|
||||
events.emit({ ...event, location: { directory } })
|
||||
}
|
||||
|
||||
test("permission input tracks the tool part slot as input settles", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-permission-input"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
let input!: () => unknown
|
||||
|
||||
const permissionRequest = (id: string, callID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
permission: "shell",
|
||||
resources: [],
|
||||
metadata: {},
|
||||
source: { messageID: "message-assistant", callID },
|
||||
time: { created: 1 },
|
||||
}) as unknown as PermissionV2Request
|
||||
|
||||
const [request, setRequest] = createSignal(permissionRequest("perm_1", "call-permission"))
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
client = useClient()
|
||||
// Mounted like the permission dialog: before the tool call has settled.
|
||||
input = usePermissionInput(request)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
expect(input()).toEqual({})
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_perm_tool_started",
|
||||
created: 1,
|
||||
type: "session.tool.input.started",
|
||||
durable: { aggregateID: `session_${sessionID}`, seq: 0, version: 1 },
|
||||
data: { sessionID, assistantMessageID: "message-assistant", callID: "call-permission", name: "shell" },
|
||||
} as unknown as OpenCodeEvent)
|
||||
emitEvent(events, {
|
||||
id: "evt_perm_tool_called",
|
||||
created: 2,
|
||||
type: "session.tool.called",
|
||||
durable: { aggregateID: `session_${sessionID}`, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
callID: "call-permission",
|
||||
input: { command: "rm -rf ./dist" },
|
||||
timestamp: 2,
|
||||
},
|
||||
} as unknown as OpenCodeEvent)
|
||||
|
||||
// The prompt is already mounted; the resolved input must flow through the
|
||||
// part slot once the tool call settles out of input streaming.
|
||||
await wait(() => {
|
||||
const value = input()
|
||||
return typeof value === "object" && value !== null && (value as { command?: string }).command === "rm -rf ./dist"
|
||||
})
|
||||
|
||||
// The prompt stays mounted while requests queue: when the next request
|
||||
// becomes current, the input must re-resolve against its tool call.
|
||||
emitEvent(events, {
|
||||
id: "evt_perm_tool_started_2",
|
||||
created: 3,
|
||||
type: "session.tool.input.started",
|
||||
durable: { aggregateID: `session_${sessionID}`, seq: 2, version: 1 },
|
||||
data: { sessionID, assistantMessageID: "message-assistant", callID: "call-permission-2", name: "shell" },
|
||||
} as unknown as OpenCodeEvent)
|
||||
emitEvent(events, {
|
||||
id: "evt_perm_tool_called_2",
|
||||
created: 4,
|
||||
type: "session.tool.called",
|
||||
durable: { aggregateID: `session_${sessionID}`, seq: 3, version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
callID: "call-permission-2",
|
||||
input: { command: "git push" },
|
||||
timestamp: 4,
|
||||
},
|
||||
} as unknown as OpenCodeEvent)
|
||||
await wait(() => data.session.message.parts(sessionID, "message-assistant").has("call-permission-2"))
|
||||
|
||||
setRequest(permissionRequest("perm_2", "call-permission-2"))
|
||||
await wait(() => {
|
||||
const value = input()
|
||||
return typeof value === "object" && value !== null && (value as { command?: string }).command === "git push"
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,18 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
|
||||
import {
|
||||
SessionTimeline,
|
||||
messageBoundaryIDs,
|
||||
reduceSessionRows,
|
||||
type SessionRow,
|
||||
} from "../../../src/routes/session/timeline"
|
||||
|
||||
const withoutIDs = (rows: ReturnType<typeof reduceSessionRows>) =>
|
||||
rows.map(({ id: _id, ...row }) => {
|
||||
if (row.type !== "group") return row
|
||||
const { origin: _origin, ...group } = row
|
||||
return group
|
||||
})
|
||||
|
||||
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
@@ -16,6 +28,22 @@ test("assigns assistant boundaries to the first rendered row instead of the firs
|
||||
expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
|
||||
})
|
||||
|
||||
test("keeps a group boundary at its immutable origin while visible refs repartition", () => {
|
||||
const messages = [assistant("assistant-1", []), assistant("assistant-2", [])]
|
||||
const origin = { messageID: "assistant-1", partID: "read-1" }
|
||||
const group: SessionRow = {
|
||||
id: "group",
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
origin,
|
||||
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||
pending: [origin],
|
||||
completed: false,
|
||||
}
|
||||
|
||||
expect(messageBoundaryIDs([group], messages)).toEqual(["assistant-1"])
|
||||
})
|
||||
|
||||
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
@@ -30,7 +58,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{
|
||||
@@ -57,7 +85,7 @@ test("keeps non-exploration tools as individual part rows", () => {
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
@@ -86,7 +114,7 @@ test("assigns stable kind ordinals within an assistant message", () => {
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{
|
||||
type: "group",
|
||||
@@ -114,7 +142,7 @@ test("groups adjacent reasoning parts until a visible boundary", () => {
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
@@ -146,7 +174,7 @@ test("groups across empty assistant reasoning parts", () => {
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
@@ -177,7 +205,7 @@ test("completes exploration groups when another row follows", () => {
|
||||
finished,
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
@@ -209,7 +237,7 @@ test("hides synthetic messages without descriptions", () => {
|
||||
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
@@ -236,7 +264,7 @@ test("renders synthetic messages with descriptions", () => {
|
||||
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
@@ -263,7 +291,7 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
|
||||
error: { type: "provider.transport", message: "Disconnected" },
|
||||
}
|
||||
|
||||
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
|
||||
expect(withoutIDs(reduceSessionRows([message]))).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
|
||||
})
|
||||
|
||||
test("places a running compaction barrier before every queued user message", () => {
|
||||
@@ -287,13 +315,169 @@ test("places a running compaction barrier before every queued user message", ()
|
||||
queued("user-after", "After", 3),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
|
||||
expect(withoutIDs(reduceSessionRows(messages, new Set(["user-before", "user-after"])))).toEqual([
|
||||
{ type: "message", messageID: "compaction" },
|
||||
{ type: "message", messageID: "user-before" },
|
||||
{ type: "message", messageID: "user-after" },
|
||||
])
|
||||
})
|
||||
|
||||
test("matches snapshot reduction through direct timeline operations", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
const response = assistant("assistant-1", [
|
||||
{ type: "reasoning", text: "First" },
|
||||
{ type: "reasoning", text: "Second" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
{ type: "text", text: "Done" },
|
||||
])
|
||||
response.finish = "stop"
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
response,
|
||||
{ type: "user", id: "user-queued", text: "Continue", time: { created: 4 } },
|
||||
]
|
||||
|
||||
timeline.appendMessage("user-1", { pending: false, compaction: false })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:0" }, { type: "reasoning" })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:1" }, { type: "reasoning" })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "grep-1" }, { type: "tool", name: "grep" })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "text:0" }, { type: "text" })
|
||||
timeline.appendFooter("assistant-1")
|
||||
timeline.appendMessage("user-queued", { pending: true, compaction: false })
|
||||
|
||||
expect(timeline.values()).toEqual(reduceSessionRows(messages, new Set(["user-queued"])))
|
||||
})
|
||||
|
||||
test("ignores a duplicate part through the parts membership index", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
const ref = { messageID: "assistant-1", partID: "read-1" }
|
||||
timeline.appendPart(ref, { type: "tool", name: "read" })
|
||||
const values = timeline.values()
|
||||
const slots = timeline.slots()
|
||||
|
||||
timeline.appendPart(ref, { type: "text" })
|
||||
|
||||
expect(timeline.values()).toBe(values)
|
||||
expect(timeline.slots()).toBe(slots)
|
||||
})
|
||||
|
||||
test("inserts output before the earliest queued compaction and prompt", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" })
|
||||
timeline.appendMessage("user-1", { pending: true, compaction: false })
|
||||
timeline.appendMessage("user-2", { pending: true, compaction: false })
|
||||
timeline.appendMessage("compaction-1", { pending: true, compaction: true })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "text:0" }, { type: "text" })
|
||||
|
||||
expect(withoutIDs([...timeline.values()])).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{ type: "message", messageID: "compaction-1" },
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{ type: "message", messageID: "user-2" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a group slot stable through join, repartition, and completion", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" })
|
||||
const slot = timeline.slots()[0]
|
||||
|
||||
timeline.appendPart({ messageID: "assistant-2", partID: "grep-1" }, { type: "tool", name: "grep" })
|
||||
expect(timeline.slots()[0]).toBe(slot)
|
||||
|
||||
timeline.repartition(new Set(["read-1"]))
|
||||
expect(timeline.slots()[0]).toBe(slot)
|
||||
expect(slot()).toMatchObject({
|
||||
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||
pending: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
completed: false,
|
||||
})
|
||||
|
||||
timeline.appendMessage("user-queued", { pending: true, compaction: false })
|
||||
expect(slot()).toMatchObject({ completed: false })
|
||||
timeline.appendMessage("user-queued", { pending: false, compaction: false })
|
||||
|
||||
expect(timeline.slots()[0]).toBe(slot)
|
||||
expect(slot()).toMatchObject({ completed: true })
|
||||
})
|
||||
|
||||
test("does not complete an active group for duplicate messages or footers", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
timeline.appendMessage("user-1", { pending: false, compaction: false })
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:0" }, { type: "reasoning" })
|
||||
const first = timeline.slots()[1]
|
||||
|
||||
timeline.appendMessage("user-1", { pending: false, compaction: false })
|
||||
expect(first()).toMatchObject({ completed: false })
|
||||
|
||||
timeline.appendFooter("assistant-1")
|
||||
timeline.appendPart({ messageID: "assistant-2", partID: "reasoning:0" }, { type: "reasoning" })
|
||||
const second = timeline.slots()[3]
|
||||
timeline.appendFooter("assistant-1")
|
||||
|
||||
expect(second()).toMatchObject({ completed: false })
|
||||
})
|
||||
|
||||
test("moves a promoted queued message before the remaining queue", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "reasoning:0" }, { type: "reasoning" })
|
||||
timeline.appendMessage("user-1", { pending: true, compaction: false })
|
||||
timeline.appendMessage("user-2", { pending: true, compaction: false })
|
||||
|
||||
timeline.appendMessage("user-2", { pending: false, compaction: false })
|
||||
timeline.appendPart({ messageID: "assistant-2", partID: "text:0" }, { type: "text" })
|
||||
|
||||
expect(withoutIDs([...timeline.values()])).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: true,
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
|
||||
},
|
||||
{ type: "message", messageID: "user-2" },
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
|
||||
{ type: "message", messageID: "user-1" },
|
||||
])
|
||||
})
|
||||
|
||||
test("advances the queue boundary when a running compaction completes", () => {
|
||||
const timeline = SessionTimeline.make()
|
||||
timeline.appendPart({ messageID: "assistant-1", partID: "read-1" }, { type: "tool", name: "read" })
|
||||
timeline.appendMessage("compaction-1", { pending: true, compaction: true })
|
||||
timeline.appendMessage("user-1", { pending: true, compaction: false })
|
||||
|
||||
timeline.appendMessage("compaction-1", { pending: false, compaction: true })
|
||||
timeline.appendPart({ messageID: "assistant-2", partID: "grep-1" }, { type: "tool", name: "grep" })
|
||||
|
||||
expect(withoutIDs([...timeline.values()])).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "message", messageID: "compaction-1" },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||
},
|
||||
{ type: "message", messageID: "user-1" },
|
||||
])
|
||||
})
|
||||
|
||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
type: "assistant",
|
||||
|
||||
@@ -17,3 +17,26 @@ test("registers and updates grouped DevTools data", () => {
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("updates a DevTools group in one batch", () => {
|
||||
const group = DevTools.register({ id: "batch", title: "Batch data" })
|
||||
|
||||
group.setAll([
|
||||
{ key: "CPU", value: "20%" },
|
||||
{ key: "Memory", value: "100 MB" },
|
||||
])
|
||||
group.setAll([
|
||||
{ key: "CPU", value: "30%" },
|
||||
{ key: "FPS", value: 60 },
|
||||
])
|
||||
|
||||
expect(DevTools.data().find((item) => item.id === "batch")).toEqual({
|
||||
id: "batch",
|
||||
title: "Batch data",
|
||||
entries: [
|
||||
{ key: "CPU", value: "30%" },
|
||||
{ key: "Memory", value: "100 MB" },
|
||||
{ key: "FPS", value: 60 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,6 +109,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
})
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json({ data: {} })
|
||||
if (/^\/api\/session\/[^/]+\/pending$/.test(url.pathname)) return json({ data: [] })
|
||||
if (url.pathname === "/api/permission/request")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/form/request")
|
||||
|
||||
@@ -43,20 +43,21 @@ function structured(next: StreamCommit) {
|
||||
|
||||
describe("run entry body", () => {
|
||||
test("renders a failed direct shell as an error instead of completed success", () => {
|
||||
expect(
|
||||
entryBody(
|
||||
commit({
|
||||
kind: "tool",
|
||||
text: "Shell exited with code 7",
|
||||
phase: "final",
|
||||
source: "tool",
|
||||
tool: "shell",
|
||||
toolState: "error",
|
||||
toolError: "Shell exited with code 7",
|
||||
shell: { command: "false" },
|
||||
}),
|
||||
),
|
||||
).toEqual({ type: "text", content: "✖ shell failed: Shell exited with code 7" })
|
||||
const failed = commit({
|
||||
kind: "tool",
|
||||
text: "Shell exited with code 7",
|
||||
phase: "final",
|
||||
source: "tool",
|
||||
tool: "shell",
|
||||
toolState: "error",
|
||||
toolError: "Shell → exited · code 7",
|
||||
shell: { command: "false" },
|
||||
})
|
||||
expect(entryBody(failed)).toEqual({ type: "text", content: "✖ shell failed: Shell → exited · code 7" })
|
||||
expect(entryBody(failed, { mono: true })).toEqual({
|
||||
type: "text",
|
||||
content: "! shell failed: Shell → exited · code 7",
|
||||
})
|
||||
})
|
||||
|
||||
test("renders assistant, reasoning, and user entries in their display formats", () => {
|
||||
@@ -114,6 +115,11 @@ describe("run entry body", () => {
|
||||
type: "text",
|
||||
content: "› Inspect footer tabs",
|
||||
})
|
||||
expect(
|
||||
entryBody(commit({ kind: "user", text: "Inspect footer tabs", phase: "start", source: "system" }), {
|
||||
mono: true,
|
||||
}),
|
||||
).toEqual({ type: "text", content: "> Inspect footer tabs" })
|
||||
})
|
||||
|
||||
for (const item of [
|
||||
|
||||
@@ -55,7 +55,8 @@ test("down opens subagents from an empty prompt", async () => {
|
||||
subagent={subagents}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={config}
|
||||
miniSettings={() => ({ thinking: "hide", shell_output: "hide" })}
|
||||
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false })}
|
||||
mono={false}
|
||||
onSubmit={() => true}
|
||||
onPermissionReply={() => {}}
|
||||
onFormReply={() => {}}
|
||||
|
||||
@@ -121,6 +121,7 @@ async function renderFooter(
|
||||
view?: FooterView
|
||||
onFormReply?: (input: unknown) => void
|
||||
miniSettings?: MiniSettings
|
||||
mono?: boolean
|
||||
onMiniSettingChange?: (change: MiniSettingChange) => void
|
||||
} = {},
|
||||
) {
|
||||
@@ -130,7 +131,9 @@ async function renderFooter(
|
||||
)
|
||||
const state = footerState(input.state)
|
||||
const config = input.tuiConfig ?? tuiConfig
|
||||
const [miniSettings] = createSignal<MiniSettings>(input.miniSettings ?? { thinking: "hide", shell_output: "hide" })
|
||||
const [miniSettings] = createSignal<MiniSettings>(
|
||||
input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false },
|
||||
)
|
||||
function Harness() {
|
||||
return (
|
||||
<Keymap.Provider config={config}>
|
||||
@@ -148,6 +151,7 @@ async function renderFooter(
|
||||
view={view}
|
||||
subagent={subagents}
|
||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||
mono={input.mono ?? false}
|
||||
tuiConfig={config}
|
||||
miniSettings={miniSettings}
|
||||
onSubmit={input.onSubmit ?? (() => true)}
|
||||
@@ -395,6 +399,7 @@ test("direct command panel renders grouped command palette", async () => {
|
||||
const frame = app.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("Commands")
|
||||
expect(frame).toMatch(/^ {2}Commands/m)
|
||||
expect(frame).toContain("Search")
|
||||
expect(frame).toContain("Session")
|
||||
expect(frame).toContain("Agent")
|
||||
@@ -418,7 +423,12 @@ test("direct command panel renders grouped command palette", async () => {
|
||||
})
|
||||
|
||||
test("direct settings panel changes Mini transcript preferences", async () => {
|
||||
const [settings, setSettings] = createSignal<MiniSettings>({ thinking: "hide", shell_output: "hide" })
|
||||
const [settings, setSettings] = createSignal<MiniSettings>({
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "show",
|
||||
mono: false,
|
||||
})
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={100} height={RUN_COMMAND_PANEL_ROWS}>
|
||||
@@ -429,6 +439,7 @@ test("direct settings panel changes Mini transcript preferences", async () => {
|
||||
onChange={(change) => {
|
||||
setSettings((current) => ({ ...current, [change.key]: change.value }))
|
||||
}}
|
||||
mono
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
@@ -437,15 +448,32 @@ test("direct settings panel changes Mini transcript preferences", async () => {
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Settings")
|
||||
expect(app.captureCharFrame()).toContain("Thinking")
|
||||
expect(app.captureCharFrame()).toContain("Shell tool output")
|
||||
expect(app.captureCharFrame()).toContain("left/right change")
|
||||
const frame = app.captureCharFrame()
|
||||
expect(frame).toContain("Settings")
|
||||
expect(frame).toMatch(/^ Settings/m)
|
||||
expect(frame).toContain("Thinking")
|
||||
expect(frame).toContain("Shell")
|
||||
expect(frame).toContain("Turn summary")
|
||||
expect(frame).toContain("Monochrome UI")
|
||||
expect(frame).toContain("left/right change")
|
||||
expect(frame).not.toMatch(/[^\x00-\x7F]/)
|
||||
|
||||
app.mockInput.pressKey("ARROW_RIGHT")
|
||||
await app.renderOnce()
|
||||
|
||||
expect(settings()).toEqual({ thinking: "show", shell_output: "hide" })
|
||||
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show", mono: false })
|
||||
|
||||
app.mockInput.pressKey("ARROW_DOWN")
|
||||
app.mockInput.pressKey("ARROW_DOWN")
|
||||
app.mockInput.pressKey("ARROW_RIGHT")
|
||||
await app.renderOnce()
|
||||
|
||||
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide", mono: false })
|
||||
|
||||
app.mockInput.pressKey("ARROW_DOWN")
|
||||
app.mockInput.pressKey("ARROW_RIGHT")
|
||||
await app.renderOnce()
|
||||
expect(settings().mono).toBe(true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -921,11 +949,11 @@ test("direct footer closes settings with ctrl-c instead of arming exit", async (
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Shell tool output")
|
||||
expect(app.captureCharFrame()).toContain("Shell")
|
||||
|
||||
app.mockInput.pressKey("c", { ctrl: true })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("Shell tool output")
|
||||
expect(app.captureCharFrame()).not.toContain("Shell")
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
@@ -1099,7 +1127,8 @@ test("direct footer shows authoritative pending work while running", async () =>
|
||||
]}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={tuiConfig}
|
||||
miniSettings={() => ({ thinking: "hide", shell_output: "hide" })}
|
||||
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false })}
|
||||
mono={false}
|
||||
onSubmit={() => true}
|
||||
onPermissionReply={() => {}}
|
||||
onFormReply={() => {}}
|
||||
@@ -1241,14 +1270,17 @@ test("direct footer omits interrupt key hint when interrupt is unbound", async (
|
||||
const app = await renderFooter({
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none", input_clear: "ctrl+l" } }),
|
||||
state: { phase: "running" },
|
||||
mono: true,
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
const statusline = frame.split("\n").find((line) => line.includes("interrupt"))
|
||||
|
||||
expect(frame).toContain("interrupt")
|
||||
expect(frame).not.toContain("ctrl+l")
|
||||
expect(statusline).toMatch(/^\S/)
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
|
||||
@@ -155,28 +155,32 @@ describe("run permission shared", () => {
|
||||
})
|
||||
|
||||
test("uses source patch text when an edit has no generated diff", () => {
|
||||
expect(
|
||||
permissionInfo(
|
||||
req({
|
||||
action: "edit",
|
||||
resources: ["src/index.ts"],
|
||||
source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
|
||||
tool: canonicalToolPart(
|
||||
"edit",
|
||||
{
|
||||
status: "running",
|
||||
input: { patchText: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch" },
|
||||
structured: {},
|
||||
content: [],
|
||||
},
|
||||
"call-edit",
|
||||
),
|
||||
}),
|
||||
const patch = '*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+const arrow = "→"\n*** End Patch'
|
||||
const request = req({
|
||||
action: "edit",
|
||||
resources: ["src/index.ts"],
|
||||
source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
|
||||
tool: canonicalToolPart(
|
||||
"edit",
|
||||
{
|
||||
status: "running",
|
||||
input: { patchText: patch },
|
||||
structured: {},
|
||||
content: [],
|
||||
},
|
||||
"call-edit",
|
||||
),
|
||||
).toMatchObject({
|
||||
})
|
||||
expect(permissionInfo(request)).toMatchObject({
|
||||
title: "Edit src/index.ts",
|
||||
diff: undefined,
|
||||
patch: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch",
|
||||
patch,
|
||||
})
|
||||
expect(permissionInfo(request, undefined, true)).toMatchObject({
|
||||
title: "Edit src/index.ts",
|
||||
lines: [patch],
|
||||
diff: undefined,
|
||||
patch: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -103,10 +103,19 @@ describe("run runtime boot", () => {
|
||||
expect(result.theme).toEqual({ mode: "light" })
|
||||
expect(result.leader.timeout).toBe(450)
|
||||
expect(result.session?.thinking).toBe("show")
|
||||
expect(resolveMiniSettings(result)).toEqual({ thinking: "hide", shell_output: "hide" })
|
||||
expect(resolveMiniSettings({ mini: { thinking: "show", shell_output: "show" } })).toEqual({
|
||||
expect(resolveMiniSettings(result)).toEqual({
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
turn_summary: "show",
|
||||
mono: false,
|
||||
})
|
||||
expect(
|
||||
resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide", mono: true } }),
|
||||
).toEqual({
|
||||
thinking: "show",
|
||||
shell_output: "show",
|
||||
turn_summary: "hide",
|
||||
mono: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
|
||||
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
|
||||
import { RUN_THEME_MONO, RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
|
||||
import { DEFAULT_THEMES } from "../../src/theme"
|
||||
|
||||
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
|
||||
@@ -23,12 +23,14 @@ function terminalColors(input: Partial<TerminalColors> = {}): TerminalColors {
|
||||
function renderer(
|
||||
input: {
|
||||
themeMode?: "dark" | "light"
|
||||
resolvedThemeMode?: "dark" | "light"
|
||||
colors?: TerminalColors
|
||||
fail?: boolean
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
themeMode: input.themeMode,
|
||||
waitForThemeMode: async () => input.resolvedThemeMode ?? input.themeMode ?? null,
|
||||
getPalette: async () => {
|
||||
if (input.fail) {
|
||||
throw new Error("boom")
|
||||
@@ -61,6 +63,21 @@ function spread(color: RGBA) {
|
||||
|
||||
test("falls back when palette lookup fails", async () => {
|
||||
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
|
||||
expect(await resolveRunTheme(renderer({ fail: true }), undefined, true)).toBe(RUN_THEME_MONO)
|
||||
const light = await resolveRunTheme(renderer({ resolvedThemeMode: "light" }), undefined, true)
|
||||
expect(expectRgba(light.footer.text).toInts().slice(0, 3)).toEqual([0, 0, 0])
|
||||
expect(RUN_THEME_MONO.block.syntax).toBeUndefined()
|
||||
for (const color of [
|
||||
RUN_THEME_MONO.background,
|
||||
...Object.values(RUN_THEME_MONO.footer),
|
||||
...Object.values(RUN_THEME_MONO.splash),
|
||||
...Object.values(RUN_THEME_MONO.entry).flatMap((tone) => [tone.body, tone.start].filter(Boolean)),
|
||||
...Object.entries(RUN_THEME_MONO.block)
|
||||
.filter(([key]) => key !== "syntax")
|
||||
.map(([, value]) => value),
|
||||
]) {
|
||||
expect(expectRgba(color).intent).toBe("default")
|
||||
}
|
||||
})
|
||||
|
||||
test("resolveTheme preserves Mini indexed color and result shape semantics", () => {
|
||||
|
||||
@@ -228,7 +228,6 @@ const normalize = (value: string) =>
|
||||
.replace(/[‘’‚‛]/g, "'")
|
||||
.replace(/[“”„‟]/g, '"')
|
||||
.replace(/[‐‑‒–—―−]/g, "-")
|
||||
.replace(/…/g, "...")
|
||||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
||||
const splitBom = (text: string) =>
|
||||
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
const marker = "QUARK_GROUP_COMPLETE"
|
||||
|
||||
export default defineScript({
|
||||
project: {
|
||||
git: true,
|
||||
files: {
|
||||
"src/one.ts": "export const one = 1\n",
|
||||
"src/two.ts": "export const two = 2\n",
|
||||
},
|
||||
},
|
||||
config: {
|
||||
permissions: [
|
||||
{ action: "*", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*one.ts", effect: "allow" },
|
||||
],
|
||||
},
|
||||
tui: { viewport: { cols: 120, rows: 36 }, recording: true },
|
||||
run: ({ llm, ui }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.waitFor((state) => state.focused.editor)
|
||||
let attempt = 0
|
||||
yield* llm.serve(() => {
|
||||
const current = attempt++
|
||||
if (current === 0)
|
||||
return Stream.make(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_read_one",
|
||||
name: "read",
|
||||
input: { path: "src/one.ts" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
if (current === 1)
|
||||
return Stream.make(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "call_read_two",
|
||||
name: "read",
|
||||
input: { path: "src/two.ts" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
return Stream.make(Llm.text(marker), Llm.finish("stop"))
|
||||
})
|
||||
yield* ui.submit("Inspect both source modules, then finish.")
|
||||
yield* ui.waitFor("Permission required", { timeout: 15_000 })
|
||||
const frame = yield* ui.capture()
|
||||
const row = frame.lines.findIndex((line) => line.spans.some((span) => span.text.includes("Exploring")))
|
||||
if (row === -1) throw new Error("the exploration summary was not visible")
|
||||
const column = 8
|
||||
const candidates = (yield* ui.state()).elements.filter(
|
||||
(element) =>
|
||||
element.x <= column &&
|
||||
element.x + element.width > column &&
|
||||
element.y <= row &&
|
||||
element.y + element.height > row,
|
||||
)
|
||||
if (candidates.length === 0) throw new Error("the exploration summary had no renderable target")
|
||||
const summary = candidates.reduce((smallest, element) =>
|
||||
element.width * element.height < smallest.width * smallest.height ? element : smallest,
|
||||
)
|
||||
yield* ui.click(summary, { x: column - summary.x, y: row - summary.y })
|
||||
yield* ui.waitFor("src/one.ts")
|
||||
const before = yield* ui.screenshot("group-expanded-pending")
|
||||
yield* ui.enter()
|
||||
yield* ui.waitFor(marker, { timeout: 15_000 })
|
||||
yield* ui.waitFor("src/two.ts")
|
||||
const after = yield* ui.screenshot("group-expanded-complete")
|
||||
yield* Effect.sync(() => console.log(`ARTIFACT group_before=${before}`))
|
||||
yield* Effect.sync(() => console.log(`ARTIFACT group_after=${after}`))
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Effect } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
|
||||
const marker = "QUARK_TIMELINE_COMPLETE"
|
||||
const reasoning = Array.from(
|
||||
{ length: 16 },
|
||||
(_, index) => `Reasoning segment ${index + 1} checks streamed timeline updates.`,
|
||||
).join(" ")
|
||||
const response = `${Array.from(
|
||||
{ length: 24 },
|
||||
(_, index) => `Timeline chunk ${index + 1} remains visible and ordered.`,
|
||||
).join(" ")} ${marker}`
|
||||
|
||||
export default defineScript({
|
||||
project: {
|
||||
git: true,
|
||||
files: {
|
||||
"src/example.ts": "export const timeline = true\n",
|
||||
},
|
||||
},
|
||||
tui: { viewport: { cols: 120, rows: 36 } },
|
||||
run: ({ opencode, llm, ui }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ui.waitFor((state) => state.focused.editor)
|
||||
yield* llm.title(() => Effect.succeed("Timeline comparison"))
|
||||
const started = Date.now()
|
||||
yield* ui.submit("Explain how this project handles its timeline.")
|
||||
yield* llm.send(
|
||||
Llm.reasoning(reasoning, { delay: 1, chunkSize: 8 }),
|
||||
Llm.text(response, { delay: 1, chunkSize: 8 }),
|
||||
)
|
||||
yield* ui.waitFor(marker, { timeout: 30_000 })
|
||||
const session = (yield* opencode.session.list({})).data[0]
|
||||
if (!session) throw new Error("the Drive session was not projected")
|
||||
const assistant = (yield* opencode.message.list({ sessionID: session.id })).data.findLast(
|
||||
(message) => message.type === "assistant",
|
||||
)
|
||||
if (assistant?.type !== "assistant") throw new Error("the assistant message was not projected")
|
||||
if (!assistant.content.some((part) => part.type === "reasoning" && part.text === reasoning))
|
||||
throw new Error("the projected reasoning content did not match")
|
||||
if (!assistant.content.some((part) => part.type === "text" && part.text === response))
|
||||
throw new Error("the projected text content did not match")
|
||||
if (assistant.finish !== "stop") throw new Error("the projected assistant message did not finish")
|
||||
yield* Effect.sync(() => console.log(`METRIC tui_timeline_drive_ms=${Date.now() - started}`))
|
||||
}),
|
||||
})
|
||||
Reference in New Issue
Block a user