Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton ffa0645572 perf(tui): batch event propagation 2026-07-21 15:50:12 -04:00
83 changed files with 1279 additions and 6805 deletions
-10
View File
@@ -627,13 +627,6 @@
"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",
@@ -889,7 +882,6 @@
"@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:*",
@@ -2099,8 +2091,6 @@
"@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"],
+2 -2
View File
@@ -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", turn_summary: "hide", mono: true }
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide" }
})
}),
)
@@ -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", turn_summary: "hide", mono: true },
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide" },
})
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
} finally {
-80
View File
@@ -1,80 +0,0 @@
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: [] })
@@ -1,45 +0,0 @@
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] })
+9 -2
View File
@@ -25,7 +25,7 @@ export type ResolveInput = typeof ResolveInput.Type
export class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
path: Schema.String,
reason: Schema.Literal("non_directory_ancestor"),
reason: Schema.Literals(["location_escape", "non_directory_ancestor"]),
}) {}
export interface ExternalDirectoryAuthorization {
@@ -83,6 +83,7 @@ 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)))
@@ -123,8 +124,14 @@ 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(location.directory, absolute) || ".")
const resource = external
? slash(resolved.canonical)
: slash(path.relative(locationRoot, resolved.canonical) || ".")
const externalDirectory =
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
const externalResource = slash(path.join(externalDirectory, "*"))
-4
View File
@@ -2,8 +2,6 @@ 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"
@@ -68,7 +66,6 @@ const locationServiceNodes = [
Pty.node,
Shell.node,
SkillV2.node,
CodeMode.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
LocationMutation.node,
@@ -80,7 +77,6 @@ const locationServiceNodes = [
ToolRegistry.toolsNode,
Image.node,
SkillInstructions.node,
CodeModeInstructions.node,
ReferenceInstructions.node,
InstructionEntry.node,
Form.node,
-4
View File
@@ -12,7 +12,6 @@ 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"
@@ -55,7 +54,6 @@ 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
@@ -79,7 +77,6 @@ const layer = Layer.effect(
const instructions = yield* Effect.all(
[
builtins.load(sessionID),
codeModeInstructions.load(agent),
discovery.load(),
skillInstructions.load(agent),
referenceInstructions.load(),
@@ -112,7 +109,6 @@ export const node = makeLocationNode({
layer,
deps: [
AgentV2.node,
CodeModeInstructions.node,
Database.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
+21 -34
View File
@@ -36,23 +36,34 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
export interface Registration {
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,
description: discovery.instructions(),
input: CodeMode.Input,
output: ExecuteOutput,
structured: ExecuteMetadata,
@@ -81,7 +92,6 @@ 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)
@@ -131,29 +141,6 @@ 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 }
+15 -18
View File
@@ -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 { CodeMode } from "../codemode"
import { ExecuteTool } from "./execute"
import {
definition,
permission,
@@ -74,7 +74,6 @@ 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"]) {
@@ -112,6 +111,7 @@ 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,22 +212,15 @@ const registryLayer = Layer.effect(
return yield* Effect.fail(
new RegistrationError({ name: reserved.key, message: 'Tool name "execute" is reserved for CodeMode' }),
)
return { tools, options, entries, codemode }
return { entries, codemode }
}),
)
// 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
if (planned.every((plan) => plan.entries.length === 0)) return
yield* Effect.uninterruptible(
registrationLock.withPermit(
Effect.gen(function* () {
const token = {}
for (const { entries } of direct)
for (const { entries, codemode } of planned)
for (const entry of entries)
local.set(entry.key, [
...(local.get(entry.key) ?? []),
@@ -237,13 +230,14 @@ 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 direct)
for (const { entries } of planned)
for (const entry of entries) {
const registrations =
local.get(entry.key)?.filter((registration) => registration.token !== token) ?? []
@@ -271,16 +265,19 @@ const registryLayer = Layer.effect(
registerBatch,
materialize: Effect.fn("ToolRegistry.materialize")((permissions) =>
registrationLock.withPermit(
Effect.gen(function* () {
Effect.sync(() => {
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
direct.set(name, registration)
if (registration.codemode) codemode.set(name, registration)
else direct.set(name, registration)
}
const execute = (yield* codeMode.materialize(permissions)).tool
const execute =
codemode.size > 0 && !whollyDisabled("execute", rules) ? ExecuteTool.create(codemode) : undefined
return {
definitions: [
...Array.from(direct, ([name, registration]) => definition(name, registration.tool)),
@@ -318,11 +315,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
export const node = makeLocationNode({
service: Service,
layer,
deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node],
deps: [ToolOutputStore.node, ToolHooks.node, Image.node],
})
export const toolsNode = makeLocationNode({
service: Tools.Service,
layer,
deps: [CodeMode.node, ToolOutputStore.node, ToolHooks.node, Image.node],
deps: [ToolOutputStore.node, ToolHooks.node, Image.node],
})
-27
View File
@@ -1,27 +0,0 @@
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))),
)
})
@@ -1,49 +0,0 @@
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))
})
})
+6 -8
View File
@@ -77,7 +77,7 @@ describe("LocationMutation", () => {
),
)
it.live("authorizes a prospective target below an external symlink by its in-location path", () =>
it.live("rejects a prospective target below an escaping symlink ancestor", () =>
withTmp((directory) => {
const outside = `${directory}-outside`
return Effect.gen(function* () {
@@ -86,12 +86,10 @@ describe("LocationMutation", () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "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()
const error = yield* Effect.flip(
(yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") }),
)
expect(error).toMatchObject({ _tag: "LocationMutation.PathError", reason: "location_escape" })
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
}).pipe(provide(directory))
}),
@@ -108,7 +106,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: "linked/new.txt",
resource: "actual/new.txt",
})
}).pipe(provide(directory)),
),
+2 -3
View File
@@ -767,10 +767,9 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute")
const materialized = yield* registry.materialize()
const execute = materialized.definitions.find((tool) => tool.name === "execute")
const execute = (yield* toolDefinitions(registry)).find((tool) => tool.name === "execute")
expect(execute?.description).not.toContain("tools.demo.search")
expect(execute?.description).toContain("tools.demo.search(input: {}): Promise<{\n ok: boolean,\n}>")
}),
)
@@ -481,9 +481,6 @@ 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({
-35
View File
@@ -199,41 +199,6 @@ 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()])),
-33
View File
@@ -220,39 +220,6 @@ 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()])),
-33
View File
@@ -1,33 +0,0 @@
# 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.
```
-195
View File
@@ -1,195 +0,0 @@
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()
-60
View File
@@ -1,60 +0,0 @@
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)]
}
-217
View File
@@ -1,217 +0,0 @@
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()
-63
View File
@@ -1,63 +0,0 @@
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()
-19
View File
@@ -1,19 +0,0 @@
{
"$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:"
}
}
-3
View File
@@ -1,3 +0,0 @@
export { Keyed } from "./keyed"
export { Layout } from "./layout"
export { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"
-177
View File
@@ -1,177 +0,0 @@
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]))
}
}
-966
View File
@@ -1,966 +0,0 @@
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
}
}
}
-498
View File
@@ -1,498 +0,0 @@
/**
* 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)
}
-46
View File
@@ -1,46 +0,0 @@
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),
})
}
-339
View File
@@ -1,339 +0,0 @@
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,
})
})
})
-561
View File
@@ -1,561 +0,0 @@
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",
})
})
})
-427
View File
@@ -1,427 +0,0 @@
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)
})
})
-100
View File
@@ -1,100 +0,0 @@
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()
}
}
}
})
})
-120
View File
@@ -1,120 +0,0 @@
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()
-2
View File
@@ -6,7 +6,6 @@
"type": "module",
"license": "MIT",
"scripts": {
"bench:timeline": "bun --conditions=browser bench/session-timeline.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
},
@@ -93,7 +92,6 @@
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"@opencode-ai/quark": "workspace:*",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
"open": "10.1.2",
+1 -5
View File
@@ -46,7 +46,6 @@ 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"
@@ -1136,10 +1135,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
</Show>
</box>
<Show when={devtools()}>
<>
<PerformanceDevTools />
<DevToolsSidebar />
</>
<DevToolsSidebar />
</Show>
</box>
<Show when={!startup.skipInitialLoading}>
@@ -1,93 +0,0 @@
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)
}
+3 -6
View File
@@ -125,16 +125,13 @@ export const Info = Schema.Struct({
mini: Schema.optional(
Schema.Struct({
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide model reasoning",
description: "Show or hide model reasoning in Mini",
}),
shell_output: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide raw shell tool output",
description: "Show or hide raw shell tool output in Mini",
}),
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",
description: "Show or hide the agent, model, and duration summary in Mini scrollback",
}),
}),
).annotate({ description: "Mini transcript presentation settings" }),
+10 -2
View File
@@ -1,8 +1,9 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup, onMount } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { createEventBatcher } from "./event-batcher"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
@@ -61,6 +62,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
const cancel = () => request.abort(controller.signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
controller.signal.addEventListener("abort", cancel, { once: true })
let queued: ReturnType<typeof createEventBatcher<OpenCodeEvent>> | undefined
const error = await (async () => {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
log.info("event stream connecting", { attempt })
@@ -79,6 +81,11 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
log.info("event stream connected")
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
queued = createEventBatcher((pending) => {
batch(() => {
for (const event of pending) events.emit(event.type, event)
})
})
while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return undefined
@@ -89,12 +96,13 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
events.emit(event.value.type, event.value)
queued.add(event.value)
}
return undefined
})()
.catch((error) => error)
.finally(() => {
queued?.end(abort.signal.aborted || controller.signal.aborted)
request.abort()
clearTimeout(timeout)
controller.signal.removeEventListener("abort", cancel)
+185 -241
View File
@@ -1,7 +1,7 @@
// Client data layer: apply server events and cache API reads into a Solid store.
// 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.
// 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.
import type {
AgentInfo,
@@ -19,6 +19,9 @@ import type {
ReferenceInfo,
SessionMessageInfo,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionInfo,
SessionPendingInfo,
ShellInfo,
@@ -30,11 +33,10 @@ 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"
export const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
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
@@ -69,6 +71,7 @@ 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[]>
@@ -79,11 +82,6 @@ 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])
}
@@ -133,6 +131,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
active: {},
message: {},
pending: {},
input: {},
permission: {},
form: {},
},
@@ -147,63 +146,19 @@ 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",
@@ -212,36 +167,6 @@ 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(
@@ -274,32 +199,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined
},
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 },
}
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,
)
},
}
@@ -357,8 +269,6 @@ 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}`)
@@ -371,6 +281,7 @@ 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)) {
@@ -442,7 +353,6 @@ 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)
@@ -464,35 +374,59 @@ 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 || !pending) return
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) 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": {
const pending: SessionPendingInfo = {
case "session.input.admitted":
addPending({
id: event.data.inputID,
sessionID: event.data.sessionID,
admittedSeq: event.durable.seq,
timeCreated: event.created,
...event.data.input,
}
addPending(pending)
})
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,
])
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, message.fromPending(pending))
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 },
},
)
})
break
}
case "session.instructions.updated":
const instructions = event.metadata?.instructions
if (
@@ -607,109 +541,142 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
break
case "session.text.started":
insertPart(event.data, { type: "text", text: "", partID: SessionContent.textID(event.data.ordinal) })
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "text",
text: "",
})
})
break
case "session.text.delta":
modifyText(event.data, (part) => ({ ...part, text: part.text + event.data.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
})
break
case "session.text.ended":
modifyText(event.data, (part) => ({ ...part, text: event.data.text }))
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
})
break
case "session.tool.input.started":
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,
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: "" },
})
})
break
case "session.tool.input.delta":
modifyTool(event.data, (part) =>
part.state.status !== "streaming"
? part
: { ...part, state: { ...part.state, input: part.state.input + event.data.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
})
break
case "session.tool.input.ended":
modifyTool(event.data, (part) =>
part.state.status !== "streaming" ? part : { ...part, state: { ...part.state, input: event.data.text } },
)
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
})
break
case "session.tool.called":
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: [] },
}))
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: [] }
})
break
case "session.tool.progress":
modifyTool(event.data, (part) =>
part.state.status !== "running"
? part
: {
...part,
state: { ...part.state, structured: event.data.structured, content: [...event.data.content] },
},
)
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]
})
break
case "session.tool.success":
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),
},
)
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
})
break
case "session.tool.failed":
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),
},
)
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
})
break
case "session.reasoning.started":
insertPart(event.data, {
type: "reasoning",
text: "",
state: event.data.state,
time: { created: event.created },
partID: SessionContent.reasoningID(event.data.ordinal),
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 },
})
})
break
case "session.reasoning.delta":
modifyReasoning(event.data, (part) => ({ ...part, text: part.text + event.data.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
})
break
case "session.reasoning.ended":
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,
}))
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
}
})
break
case "session.retry.scheduled":
message.update(event.data.sessionID, (draft, index) => {
@@ -769,20 +736,16 @@ 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",
"pending",
"input",
event.data.sessionID,
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
(store.session.input[event.data.sessionID] ?? []).filter((id) => 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)
content.drop(event.data.sessionID, item.id)
}
for (const item of draft.splice(position)) index.delete(item.id)
})
break
case "session.compaction.delta":
@@ -951,10 +914,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
input: {
list(sessionID: string) {
return pendingInputs(sessionID).map((item) => item.id)
return store.session.input[sessionID] ?? []
},
has(sessionID: string, inputID: string) {
return pendingInputs(sessionID).some((item) => item.id === inputID)
return store.session.input[sessionID]?.includes(inputID) ?? false
},
},
pending: {
@@ -962,7 +925,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.pending[sessionID] ?? []
},
sync(sessionID: string) {
return syncPending(sessionID)
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)),
)
})
},
invalidate(sessionID: string) {
sync.invalidate(`session.pending:${sessionID}`)
@@ -988,41 +960,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
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))
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))
})
},
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}`)
},
+57
View File
@@ -0,0 +1,57 @@
const defaultInterval = 16
const defaultLimit = 1_024
type Options = {
interval?: number
limit?: number
now?: () => number
schedule?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>
cancel?: (timer: ReturnType<typeof setTimeout>) => void
}
export function createEventBatcher<T>(onFlush: (events: T[]) => void, options: Options = {}) {
const interval = options.interval ?? defaultInterval
const limit = options.limit ?? defaultLimit
const now = options.now ?? Date.now
const schedule = options.schedule ?? setTimeout
const cancel = options.cancel ?? clearTimeout
let queue: T[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
let ended = false
function flush() {
if (queue.length === 0) return
const pending = queue
queue = []
timer = undefined
last = now()
onFlush(pending)
}
return {
add(event: T) {
if (ended) return
queue.push(event)
if (queue.length >= limit) {
if (timer !== undefined) cancel(timer)
flush()
return
}
if (timer !== undefined) return
if (now() - last >= interval) {
flush()
return
}
timer = schedule(flush, interval)
},
end(discard: boolean) {
if (ended) return
ended = true
if (timer !== undefined) cancel(timer)
timer = undefined
if (!discard) flush()
queue = []
},
}
}
+1 -13
View File
@@ -4,12 +4,10 @@ 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 Entry[]
entries: readonly Readonly<{ key: string; value: Value }>[]
}>
const [groups, setGroups] = createSignal<readonly Group[]>([])
@@ -37,16 +35,6 @@ 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()] }
}),
)
},
}
}
+9 -39
View File
@@ -1,5 +1,4 @@
import { toolEntryBody } from "./tool"
import { monoPrefix, monoToolText } from "./mono"
import type { RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
export type EntryFlags = {
@@ -49,17 +48,17 @@ function markdownBody(content: string): RunEntryBody {
}
}
function userBody(raw: string, mono: boolean): RunEntryBody {
function userBody(raw: string): 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}${mono ? ">" : ""} ${body}`)
return textBody(`${lead} ${body}`)
}
function reasoningBody(raw: string, mono: boolean): RunEntryBody {
function reasoningBody(raw: string): RunEntryBody {
const clean = raw.replace(/\[REDACTED\]/g, "")
if (!clean) {
return RUN_ENTRY_NONE
@@ -69,39 +68,16 @@ function reasoningBody(raw: string, mono: boolean): 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 mono ? textBody(clean) : codeBody(clean, "markdown")
return 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 {
@@ -192,17 +168,13 @@ 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, mono)
return userBody(raw)
}
if (commit.kind === "tool") {
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))
return toolEntryBody(commit, raw, options) ?? RUN_ENTRY_NONE
}
if (commit.kind === "assistant") {
@@ -214,7 +186,7 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
}
return mono ? textBody(raw) : markdownBody(raw)
return markdownBody(raw)
}
if (commit.kind === "reasoning") {
@@ -226,10 +198,8 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
return commit.interrupted ? textBody("reasoning interrupted") : RUN_ENTRY_NONE
}
return reasoningBody(raw, mono)
return reasoningBody(raw)
}
const body = systemBody(raw, commit.phase)
if (!mono || body.type !== "text") return body
return textBody(monoPrefix(body.content, true))
return systemBody(raw, commit.phase)
}
+142 -105
View File
@@ -62,13 +62,29 @@ 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: "",
@@ -264,17 +280,19 @@ function PanelShell(props: {
onQuery: (query: string) => void
children: JSX.Element
hint?: string
mono?: boolean
dark?: boolean
chrome?: "default" | "minimal"
}) {
const background = () => props.theme().shade
const background = () => (props.dark ? props.theme().shade : props.theme().surface)
const minimal = () => props.chrome === "minimal"
const content = (
<>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
height={1}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexDirection="row"
gap={1}
flexShrink={0}
@@ -290,15 +308,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} ${props.mono ? "-" : "·"} ` : ""}esc
{props.hint ? `${props.hint} · ` : ""}esc
</text>
</box>
<box height={1} flexShrink={0} backgroundColor={background()} />
<box
width="100%"
height={1}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
flexShrink={0}
backgroundColor={background()}
>
@@ -329,11 +347,25 @@ function PanelShell(props: {
)
return (
<box width="100%" flexDirection="column" 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 : (
{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%"
height={1}
@@ -342,8 +374,27 @@ function PanelShell(props: {
backgroundColor="transparent"
customBorderChars={HALF_BLOCK_BORDER}
/>
)}
</box>
</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>
)
}
@@ -367,7 +418,6 @@ 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)
@@ -383,18 +433,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",
@@ -408,16 +458,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[] = [
{
@@ -427,17 +477,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",
@@ -448,13 +498,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() ?? [])
@@ -483,7 +533,7 @@ export function RunCommandMenuBody(props: {
{
action: "settings",
category: "System",
display: "Settings",
display: "Open settings",
footer: "/settings",
keywords: "/settings settings preferences configuration",
},
@@ -561,7 +611,8 @@ export function RunCommandMenuBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
mono={props.mono}
dark
chrome="minimal"
>
<RunFooterMenu
theme={props.theme}
@@ -572,12 +623,11 @@ export function RunCommandMenuBody(props: {
limit={PANEL_LIST_ROWS}
empty="No results found"
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={!controller.query().trim()}
background
headerColor={props.theme().muted}
mono={props.mono}
/>
</PanelShell>
)
@@ -588,20 +638,21 @@ 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",
display: "Shell tool output",
description: "model-issued commands",
footer: saving() === "shell_output" ? "saving" : props.settings().shell_output,
keywords: `shell tool command output ${props.settings().shell_output}`,
key: "shell_output",
@@ -609,27 +660,18 @@ export function RunSettingsBody(props: {
{
category: "Transcript",
display: "Turn summary",
description: "agent, model, and duration",
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 next: MiniSettingChange =
item.key === "mono"
? { key: "mono", value: !props.settings().mono }
: { key: item.key, value: props.settings()[item.key] === "show" ? "hide" : "show" }
const current = props.settings()[item.key]
setSaving(item.key)
void Promise.resolve(props.onChange(next))
.catch(() => { })
void Promise.resolve(props.onChange({ key: item.key, value: current === "show" ? "hide" : "show" }))
.catch(() => {})
.finally(() => setSaving())
}
const controller = createSearchablePanelController({
@@ -658,7 +700,8 @@ export function RunSettingsBody(props: {
inputRef={controller.inputRef}
onQuery={controller.setQuery}
hint="left/right change"
mono={props.mono}
dark
chrome="minimal"
>
<RunFooterMenu
theme={props.theme}
@@ -669,12 +712,11 @@ export function RunSettingsBody(props: {
limit={PANEL_LIST_ROWS}
empty="No settings found"
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={!controller.query().trim()}
background
headerColor={props.theme().muted}
mono={props.mono}
/>
</PanelShell>
)
@@ -687,7 +729,6 @@ export function RunSubagentSelectBody(props: {
onClose: () => void
onSelect: (sessionID: string) => void
onRows?: (rows: number) => void
mono?: boolean
}) {
const entries = createMemo<SubagentEntry[]>(() =>
props.tabs().map((item) => {
@@ -723,7 +764,8 @@ export function RunSubagentSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
mono={props.mono}
dark
chrome="minimal"
>
<RunFooterMenu
theme={props.theme}
@@ -734,11 +776,10 @@ export function RunSubagentSelectBody(props: {
limit={SUBAGENT_LIST_ROWS}
empty="No subagents found"
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
mono={props.mono}
/>
</PanelShell>
)
@@ -749,7 +790,6 @@ export function RunQueuedPromptSelectBody(props: {
prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void
onRows?: (rows: number) => void
mono?: boolean
}) {
const entries = createMemo<QueuedEntry[]>(() =>
props.prompts().map((prompt) => ({
@@ -778,7 +818,8 @@ export function RunQueuedPromptSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
mono={props.mono}
dark
chrome="minimal"
>
<RunFooterMenu
theme={props.theme}
@@ -789,11 +830,10 @@ export function RunQueuedPromptSelectBody(props: {
limit={SUBAGENT_LIST_ROWS}
empty="No pending work"
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
mono={props.mono}
/>
</PanelShell>
)
@@ -804,7 +844,6 @@ export function RunSkillSelectBody(props: {
commands: Accessor<RunCommand[] | undefined>
onClose: () => void
onSelect: (name: string) => void
mono?: boolean
}) {
const entries = createMemo<SkillEntry[]>(() =>
(props.commands() ?? [])
@@ -835,7 +874,8 @@ export function RunSkillSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
mono={props.mono}
dark
chrome="minimal"
>
<RunFooterMenu
theme={props.theme}
@@ -846,11 +886,10 @@ export function RunSkillSelectBody(props: {
limit={PANEL_LIST_ROWS}
empty={props.commands() ? "No skills found" : "Skills loading"}
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
mono={props.mono}
/>
</PanelShell>
)
@@ -862,7 +901,6 @@ export function RunVariantSelectBody(props: {
current: Accessor<string | undefined>
onClose: () => void
onSelect: (variant: string | undefined) => void
mono?: boolean
}) {
const entries = createMemo<VariantEntry[]>(() => [
{
@@ -900,7 +938,8 @@ export function RunVariantSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
mono={props.mono}
dark
chrome="minimal"
>
<RunFooterMenu
theme={props.theme}
@@ -911,11 +950,10 @@ export function RunVariantSelectBody(props: {
limit={PANEL_LIST_ROWS}
empty="No results found"
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={false}
background
mono={props.mono}
/>
</PanelShell>
)
@@ -927,7 +965,6 @@ export function RunModelSelectBody(props: {
current: Accessor<RunInput["model"]>
onClose: () => void
onSelect: (model: NonNullable<RunInput["model"]>) => void
mono?: boolean
}) {
const entries = createMemo<ModelEntry[]>(() =>
(props.providers() ?? [])
@@ -988,7 +1025,8 @@ export function RunModelSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
mono={props.mono}
dark
chrome="minimal"
>
<RunFooterMenu
theme={props.theme}
@@ -999,12 +1037,11 @@ export function RunModelSelectBody(props: {
limit={PANEL_LIST_ROWS}
empty={props.providers() ? "No results found" : "Models loading"}
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
paddingLeft={PANEL_PAD}
paddingRight={PANEL_PAD}
grouped={!controller.query().trim()}
background
headerColor={props.theme().muted}
mono={props.mono}
/>
</PanelShell>
)
+4 -11
View File
@@ -43,7 +43,6 @@ 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)) => {
@@ -259,7 +258,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}>{props.mono ? "*" : "◆"}</text>
<text fg={unsupported() ? props.theme.warning : props.theme.highlight}></text>
<text fg={props.theme.text}>{props.request.title}</text>
<Show when={!unsupported() && !formSingle(props.request)}>
<text fg={props.theme.muted}>
@@ -353,9 +352,7 @@ export function RunFormBody(props: {
onMouseOver={() => setState((previous) => formSetSelected(previous, index()))}
onMouseUp={() => choose(index())}
>
<text fg={active() ? props.theme.highlight : props.theme.muted}>
{props.mono ? `${active() ? ">" : " "}${index() + 1}.` : `${index() + 1}.`}
</text>
<text fg={active() ? props.theme.highlight : props.theme.muted}>{index() + 1}.</text>
<text fg={active() ? props.theme.text : props.theme.muted}>
{multiple() ? `[${picked() ? "x" : " "}] ` : ""}
{row.label}
@@ -371,9 +368,7 @@ 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}>
{props.mono
? `${state().selected === rows().length ? ">" : " "}${rows().length + 1}.`
: `${rows().length + 1}.`}
{rows().length + 1}.
</text>
<text fg={state().selected === rows().length ? props.theme.text : props.theme.muted}>
Type your own answer
@@ -418,9 +413,7 @@ export function RunFormBody(props: {
? "enter submit esc dismiss"
: textual() || state().editing
? "enter save esc dismiss"
: props.mono
? "up/down select enter choose tab next esc dismiss"
: "↑↓ 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>
+5 -10
View File
@@ -6,7 +6,6 @@ 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
@@ -78,7 +77,6 @@ export function RunFooterMenu(props: {
grouped?: boolean
background?: boolean
headerColor?: ColorInput
mono?: boolean
}) {
const term = useTerminalDimensions()
const limit = () => props.limit ?? FOOTER_MENU_ROWS
@@ -172,8 +170,7 @@ export function RunFooterMenu(props: {
descriptionColumn() -
footerWidth -
4
const width = Math.max(12, available)
return props.mono ? monoTruncate(item.description, width, true) : Locale.truncate(item.description, width)
return Locale.truncate(item.description, Math.max(12, available))
}
return (
<box
@@ -190,7 +187,7 @@ export function RunFooterMenu(props: {
>
{border() ? (
<text fg={props.theme().border} wrapMode="none">
{props.mono ? "|" : "┃"}
</text>
) : undefined}
<box
@@ -227,8 +224,6 @@ 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
@@ -241,7 +236,7 @@ export function RunFooterMenu(props: {
<box paddingRight={0} flexDirection="row" backgroundColor={background()}>
{border() ? (
<text fg={props.theme().highlight} bg={background()} wrapMode="none">
{active() ? (props.mono ? ">" : "▌") : " "}
{active() ? "▌" : " "}
</text>
) : undefined}
<box
@@ -255,7 +250,7 @@ export function RunFooterMenu(props: {
<box flexDirection="row" gap={0} flexGrow={1} flexShrink={1}>
<text
fg={active() ? props.theme().selectedText : props.theme().text}
attributes={attributes()}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
flexShrink={0}
@@ -286,7 +281,7 @@ export function RunFooterMenu(props: {
{row.item.footer ? (
<text
fg={active() ? props.theme().selectedText : props.theme().muted}
attributes={attributes()}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
flexShrink={0}
+25 -24
View File
@@ -11,7 +11,7 @@
// The diff view (when available) uses the same diff component as scrollback
// tool snapshots.
/** @jsxImportSource @opentui/solid */
import { TextAttributes, type TextareaRenderable } from "@opentui/core"
import type { TextareaRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import {
@@ -40,7 +40,6 @@ function buttons(
disabled: boolean,
onHover: (option: PermissionOption) => void,
onSelect: (option: PermissionOption) => void,
mono: boolean,
) {
return (
<box flexDirection="row" gap={1} flexShrink={0}>
@@ -57,12 +56,7 @@ function buttons(
if (!disabled) onSelect(option)
}}
>
<text
fg={option === selected ? theme.surface : theme.muted}
attributes={option === selected && mono ? TextAttributes.INVERSE : undefined}
>
{permissionLabel(option)}
</text>
<text fg={option === selected ? theme.surface : theme.muted}>{permissionLabel(option)}</text>
</box>
)}
</For>
@@ -140,20 +134,12 @@ 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?.(), props.mono))
const info = createMemo(() => permissionInfo(props.request, props.directory?.()))
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),
)
@@ -283,9 +269,7 @@ export function RunPermissionBody(props: {
flexShrink={0}
>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}>
{props.mono ? "!" : "△"}
</text>
<text fg={state().stage === "reject" ? props.theme.error : props.theme.warning}></text>
<text fg={props.theme.text}>{title()}</text>
</box>
<Switch>
@@ -362,7 +346,16 @@ 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={scrollbar()}>
<scrollbox
width="100%"
height="100%"
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: props.theme.surface,
foregroundColor: props.theme.line,
},
}}
>
<box width="100%" flexDirection="column" gap={1}>
<Show
when={info().diff}
@@ -434,7 +427,16 @@ export function RunPermissionBody(props: {
</scrollbox>
</Match>
<Match when={true}>
<scrollbox width="100%" height="100%" verticalScrollbarOptions={scrollbar()}>
<scrollbox
width="100%"
height="100%"
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: props.theme.surface,
foregroundColor: props.theme.line,
},
}}
>
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
<For each={permissionAlwaysLines(props.request)}>
{(line) => (
@@ -470,7 +472,6 @@ export function RunPermissionBody(props: {
setState((prev) => permissionHover(prev, option))
},
run,
props.mono ?? false,
)}
<Show
when={!busy()}
@@ -482,7 +483,7 @@ export function RunPermissionBody(props: {
>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={props.theme.text}>
{props.mono ? "left/right" : "⇆"} <span style={{ fg: props.theme.muted }}>select</span>
{"⇆"} <span style={{ fg: props.theme.muted }}>select</span>
</text>
<text fg={props.theme.text}>
enter <span style={{ fg: props.theme.muted }}>confirm</span>
+2 -8
View File
@@ -28,7 +28,6 @@ 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"
@@ -71,7 +70,6 @@ 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
@@ -304,9 +302,7 @@ export function createPromptState(input: PromptInput): PromptState {
const references = createMemo<Auto[]>(() => {
return input.references().map((item) => ({
kind: "mention",
display: input.mono()
? monoTruncateMiddle("@" + item.name, width(), true)
: Locale.truncateMiddle("@" + item.name, width()),
display: Locale.truncateMiddle("@" + item.name, width()),
value: item.name,
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
part: {
@@ -348,9 +344,7 @@ export function createPromptState(input: PromptInput): PromptState {
return {
kind: "mention",
display: input.mono()
? monoTruncateMiddle("@" + filename, width(), true)
: Locale.truncateMiddle("@" + filename, width()),
display: Locale.truncateMiddle("@" + filename, width()),
value: filename,
directory: item.endsWith("/"),
part: {
+8 -18
View File
@@ -28,20 +28,20 @@ function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"])
return theme.highlight
}
function statusIcon(status: FooterSubagentTab["status"], mono: boolean) {
function statusIcon(status: FooterSubagentTab["status"]) {
if (status === "completed") {
return mono ? "*" : "●"
return "●"
}
if (status === "cancelled") {
return mono ? "-" : "○"
return "○"
}
if (status === "error") {
return mono ? "!" : "◍"
return "◍"
}
return mono ? "." : "◔"
return "◔"
}
export function RunFooterSubagentBody(props: {
@@ -57,7 +57,6 @@ 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)
@@ -68,7 +67,6 @@ export function RunFooterSubagentBody(props: {
backgroundColor: footer().surface,
foregroundColor: footer().line,
},
visible: !props.mono,
}))
const title = createMemo(() => {
const current = tab()
@@ -89,11 +87,7 @@ 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, mono: props.mono }}
/>
<RunEntryContent commit={commit()} theme={theme()} opts={{ shellOutput: props.shellOutput?.() ?? true }} />
</box>
))
let scroll: ScrollBoxRenderable | undefined
@@ -140,15 +134,11 @@ export function RunFooterSubagentBody(props: {
<box width="100%" flexDirection="row" gap={1} paddingBottom={1} flexShrink={0}>
{current().status === "running" ? (
<box flexShrink={0}>
<spinner
frames={props.mono ? ["-", "\\", "|", "/"] : SPINNER_FRAMES}
interval={props.mono ? 160 : 80}
color={statusColor(footer(), current().status)}
/>
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(footer(), current().status)} />
</box>
) : (
<text fg={statusColor(footer(), current().status)} wrapMode="none" truncate flexShrink={0}>
{statusIcon(current().status, props.mono ?? false)}
{statusIcon(current().status)}
</text>
)}
<text fg={footer().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
+7 -12
View File
@@ -82,7 +82,6 @@ type RunFooterOptions = {
first: boolean
history?: RunPrompt[]
theme: RunTheme
mono: boolean
tuiConfig: RunTuiConfig
miniSettings: {
current: MiniSettings
@@ -203,7 +202,7 @@ export class RunFooter implements FooterApi {
private paletteRefreshRunning = false
private paletteRefreshQueued = false
private themeRefreshTimeouts: NodeJS.Timeout[] = []
private unsubscribeThemeSignal = () => {}
private unsubscribeThemeSignal: () => void
private createScrollback(wrote: boolean): RunScrollbackStream {
return new RunScrollbackStream(this.renderer, this.theme(), {
@@ -215,7 +214,6 @@ export class RunFooter implements FooterApi {
.finally(() => this.destroyTheme(theme))
},
shellOutput: () => this.miniSettings().shell_output === "show",
mono: this.options.mono,
})
}
@@ -283,12 +281,10 @@ export class RunFooter implements FooterApi {
this.scrollback = this.createScrollback(options.wrote ?? false)
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
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)
}
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(
@@ -311,7 +307,6 @@ 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,
@@ -881,7 +876,7 @@ export class RunFooter implements FooterApi {
try {
this.setMiniSettings(await this.options.miniSettings.update(change))
this.setNotice(change.key === "mono" ? "Mono applies after restart" : "settings updated")
this.setNotice("settings updated")
} catch (error) {
this.setNotice("failed to save settings")
throw error
@@ -1023,7 +1018,7 @@ export class RunFooter implements FooterApi {
}
private handleThemeRefresh = (): void => {
if (this.isGone || this.options.mono) {
if (this.isGone) {
return
}
+14 -40
View File
@@ -31,7 +31,6 @@ 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,
@@ -85,7 +84,6 @@ type RunFooterViewProps = {
subagent?: () => FooterSubagentState
queuedPrompts?: () => FooterQueuedPrompt[]
theme: () => RunTheme
mono: boolean
tuiConfig: RunTuiConfig
miniSettings: () => MiniSettings
history?: () => RunPrompt[]
@@ -176,15 +174,14 @@ export function RunFooterView(props: RunFooterViewProps) {
return current.type === "subagent" ? subagent().details[current.sessionID] : undefined
})
const shortcuts = Keymap.useShortcuts()
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 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 busy = createMemo(() => props.state().phase === "running")
const armed = createMemo(() => props.state().interrupt > 0)
const exiting = createMemo(() => props.state().exit > 0)
@@ -200,12 +197,6 @@ 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,
@@ -335,7 +326,6 @@ export function RunFooterView(props: RunFooterViewProps) {
prompt,
width,
theme,
mono: () => props.mono,
history: props.history,
onSubmit: props.onSubmit,
onCycle: props.onCycle,
@@ -390,7 +380,7 @@ export function RunFooterView(props: RunFooterViewProps) {
return ""
}
return props.mono ? usage().replaceAll(" · ", " - ") : usage()
return usage()
})
const modelStatus = createMemo(() => {
const current = model()
@@ -451,7 +441,7 @@ export function RunFooterView(props: RunFooterViewProps) {
return { key: command(), label: "cmd" }
}
})
const sectionSeparator = () => <span style={{ fg: theme().muted }}>{props.mono ? "- " : "· "}</span>
const sectionSeparator = () => <span style={{ fg: theme().muted }}>· </span>
createEffect(() => {
props.onRequestExit?.(composer.requestExit)
@@ -629,7 +619,7 @@ export function RunFooterView(props: RunFooterViewProps) {
? undefined
: {
...EMPTY_BORDER,
vertical: props.mono ? "|" : "█",
vertical: "█",
}
}
>
@@ -664,7 +654,6 @@ export function RunFooterView(props: RunFooterViewProps) {
onClose={closePanel}
onSelect={openTab}
onRows={setSubagentMenuRows}
mono={props.mono}
/>
</Match>
<Match when={selectingQueued()}>
@@ -673,7 +662,6 @@ export function RunFooterView(props: RunFooterViewProps) {
prompts={queuedPrompts}
onClose={closePanel}
onRows={setSubagentMenuRows}
mono={props.mono}
/>
</Match>
<Match when={commanding()}>
@@ -708,7 +696,6 @@ export function RunFooterView(props: RunFooterViewProps) {
closePanel()
}}
onExit={props.onExit}
mono={props.mono}
/>
</Match>
<Match when={skilling()}>
@@ -728,7 +715,6 @@ export function RunFooterView(props: RunFooterViewProps) {
})
closePanel()
}}
mono={props.mono}
/>
</Match>
<Match when={modeling()}>
@@ -741,7 +727,6 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onModelSelect(model)
closePanel()
}}
mono={props.mono}
/>
</Match>
<Match when={varianting()}>
@@ -754,7 +739,6 @@ export function RunFooterView(props: RunFooterViewProps) {
props.onVariantSelect(variant)
closePanel()
}}
mono={props.mono}
/>
</Match>
<Match when={setting()}>
@@ -763,7 +747,6 @@ export function RunFooterView(props: RunFooterViewProps) {
settings={props.miniSettings}
onClose={closePanel}
onChange={props.onMiniSettingChange}
mono={props.mono}
/>
</Match>
<Match when={active().type === "permission"}>
@@ -773,7 +756,6 @@ export function RunFooterView(props: RunFooterViewProps) {
theme={theme()}
block={block()}
onReply={props.onPermissionReply}
mono={props.mono}
/>
</Match>
<Match when={active().type === "form"}>
@@ -797,7 +779,6 @@ export function RunFooterView(props: RunFooterViewProps) {
settledForms.add(input.formID)
formStates.delete(input.formID)
}}
mono={props.mono}
/>
)}
</For>
@@ -819,7 +800,6 @@ export function RunFooterView(props: RunFooterViewProps) {
limit={FOOTER_MENU_ROWS}
border={false}
paddingLeft={0}
mono={props.mono}
/>
</Show>
@@ -834,12 +814,7 @@ export function RunFooterView(props: RunFooterViewProps) {
>
<Show when={modeLabel()}>
{(label) => (
<box
paddingLeft={props.mono ? 0 : 1}
paddingRight={1}
backgroundColor={theme().statusAccent}
flexShrink={0}
>
<box paddingLeft={1} paddingRight={1} backgroundColor={theme().statusAccent} flexShrink={0}>
<text wrapMode="none" truncate>
<span style={{ fg: modeColor(), bold: true }}>{label()}</span>
</text>
@@ -853,7 +828,7 @@ export function RunFooterView(props: RunFooterViewProps) {
flexGrow={1}
flexShrink={1}
minWidth={12}
paddingLeft={props.mono ? 0 : 1}
paddingLeft={1}
paddingRight={1}
backgroundColor="transparent"
>
@@ -939,7 +914,7 @@ export function RunFooterView(props: RunFooterViewProps) {
borderColor={theme().highlight}
customBorderChars={{
...EMPTY_BORDER,
vertical: props.mono ? "|" : "┃",
vertical: "┃",
}}
>
<RunFooterSubagentBody
@@ -953,7 +928,6 @@ export function RunFooterView(props: RunFooterViewProps) {
onClose={closeTab}
interrupt={() => subagentInterruptShortcut() || undefined}
shellOutput={() => props.miniSettings().shell_output === "show"}
mono={props.mono}
/>
</box>
</Show>
-54
View File
@@ -1,54 +0,0 @@
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))
}
+2 -14
View File
@@ -1,7 +1,6 @@
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"
@@ -45,9 +44,9 @@ export function permissionOptions(stage: PermissionStage): PermissionOption[] {
return []
}
export function permissionInfo(request: MiniPermissionRequest, directory?: string, mono = false) {
export function permissionInfo(request: MiniPermissionRequest, directory?: string) {
const state = request.tool?.state
const info = permissionPresentation(
return permissionPresentation(
{
action: request.action,
resources: request.resources,
@@ -57,17 +56,6 @@ 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 {
-1
View File
@@ -89,6 +89,5 @@ export function resolveMiniSettings(config?: { mini?: Partial<MiniSettings> }):
thinking: config?.mini?.thinking ?? "hide",
shell_output: config?.mini?.shell_output ?? "hide",
turn_summary: config?.mini?.turn_summary ?? "show",
mono: config?.mini?.mono ?? false,
}
}
+4 -15
View File
@@ -164,9 +164,6 @@ 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,
@@ -175,16 +172,15 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
autoFocus: false,
openConsoleOnError: false,
exitOnCtrlC: false,
useKittyKeyboard: mono
? { disambiguate: false, alternateKeys: false, events: false, allKeysAsEscapes: false, reportText: false }
: { events: input.host.platform === "win32" },
useKittyKeyboard: { events: input.host.platform === "win32" },
screenMode: "split-footer",
footerHeight: FOOTER_HEIGHT,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
clearOnShutdown: false,
})
const theme = await resolveRunTheme(renderer, tuiConfig.theme, mono)
const tuiConfig = await input.tuiConfig
const theme = await resolveRunTheme(renderer, tuiConfig.theme)
renderer.setBackgroundColor(theme.background)
const state: SplashState = {
entry: false,
@@ -194,7 +190,6 @@ 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,
@@ -210,7 +205,6 @@ 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(() => {})
@@ -232,11 +226,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
first: input.first,
history: input.history,
theme,
mono,
wrote,
tuiConfig,
miniSettings: {
current: miniSettings,
current: resolveMiniSettings(tuiConfig),
update: input.onMiniSettingChange,
},
onPermissionReply: input.onPermissionReply,
@@ -326,10 +319,8 @@ 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(() => {})
@@ -383,12 +374,10 @@ 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()
+2 -5
View File
@@ -89,7 +89,6 @@ export class RunScrollbackStream {
private treeSitterClient: TreeSitterClient | undefined
private wrote: boolean
private shellOutput: () => boolean
private mono: boolean
private pendingThemes: RunTheme[] = []
constructor(
@@ -100,13 +99,11 @@ 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
}
@@ -354,13 +351,13 @@ export class RunScrollbackStream {
if (commit.summary) {
this.writeSpacer(1)
this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme, mono: this.mono }))
this.renderer.writeToScrollback(turnSummaryWriter({ ...commit.summary, theme: this.theme }))
this.markRendered(commit)
this.tail = commit
return
}
const body = entryBody(commit, { shellOutput: this.shellOutput(), mono: this.mono })
const body = entryBody(commit, { shellOutput: this.shellOutput() })
if (body.type === "none") {
if (entryDone(commit)) {
this.markRendered(await this.finishActive(false))
+3 -3
View File
@@ -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, TurnSummary } from "./types"
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
export function entryGroupKey(commit: StreamCommit): string | undefined {
if (!commit.partID) {
@@ -281,7 +281,7 @@ export function spacerWriter(): ScrollbackWriter {
})
}
export function turnSummaryWriter(input: TurnSummary & { theme: RunTheme; mono?: boolean }) {
export function turnSummaryWriter(input: { agent: string; model: string; duration: string; theme: RunTheme }) {
return createScrollbackWriter(
() => (
<box width="100%" height={1}>
@@ -289,7 +289,7 @@ export function turnSummaryWriter(input: TurnSummary & { theme: RunTheme; mono?:
<span style={{ fg: input.theme.block.text }}>{input.agent}</span>
<span style={{ fg: input.theme.block.muted }}>
{" "}
{input.mono ? "-" : "·"} {input.model} {input.mono ? "-" : "·"} {input.duration}
· {input.model} · {input.duration}
</span>
</text>
</box>
+8 -12
View File
@@ -19,7 +19,6 @@ 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
@@ -28,7 +27,6 @@ const SPLASH_TITLE_FALLBACK = "Untitled session"
type SplashInput = {
title: string | undefined
session_id: string
mono?: boolean
}
type SplashWriterInput = SplashInput & {
@@ -71,7 +69,7 @@ function cells(line: string): Cell[] {
return list
}
function title(text: string | undefined, mono = false): string {
function title(text: string | undefined): string {
if (!text) {
return SPLASH_TITLE_FALLBACK
}
@@ -96,7 +94,7 @@ function title(text: string | undefined, mono = false): string {
return SPLASH_TITLE_FALLBACK
}
return mono ? monoTruncate(value, SPLASH_TITLE_LIMIT, true) : Locale.truncate(value, SPLASH_TITLE_LIMIT)
return Locale.truncate(value, SPLASH_TITLE_LIMIT)
}
function write(
@@ -183,7 +181,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
let height = 1
if (kind === "entry") {
const mark = input.mono ? ["[O]"] : go.right.slice(1)
const mark = go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
@@ -202,18 +200,16 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
lines,
body_left,
top + 1,
input.mono
? monoTruncateMiddle(input.detail, Math.max(1, width - body_left), true)
: Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
Locale.truncateMiddle(input.detail, Math.max(1, width - body_left)),
left,
undefined,
)
}
height = top + Math.max(mark.length, input.detail ? 2 : 1)
height = top + mark.length
}
if (kind === "exit") {
const mark = input.mono ? ["[O]"] : go.right.slice(1)
const mark = go.right.slice(1)
const top = 1
const body_left = (mark[0]?.length ?? 0) + 2
const session = "Session "
@@ -243,7 +239,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
undefined,
TextAttributes.BOLD,
)
height = top + Math.max(mark.length, 2)
height = top + mark.length
}
const root = new BoxRenderable(ctx.renderContext, {
@@ -270,7 +266,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback
export function splashMeta(input: SplashInput): SplashMeta {
return {
title: title(input.title, input.mono),
title: title(input.title),
session_id: input.session_id,
}
}
+1 -65
View File
@@ -506,71 +506,7 @@ export const RUN_THEME_FALLBACK: 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
}
export async function resolveRunTheme(renderer: CliRenderer, config?: RunTuiConfig["theme"]): Promise<RunTheme> {
try {
const colors = await renderer.getPalette({
size: 256,
+3 -4
View File
@@ -185,7 +185,6 @@ export type TurnSummary = {
export type ScrollbackOptions = {
suppressBackgrounds?: boolean
shellOutput?: boolean
mono?: boolean
}
export type ToolCodeSnapshot = {
@@ -398,12 +397,12 @@ export type MiniSettings = {
thinking: "show" | "hide"
shell_output: "show" | "hide"
turn_summary: "show" | "hide"
mono: boolean
}
export type MiniSettingChange = {
[Key in keyof MiniSettings]: { key: Key; value: MiniSettings[Key] }
}[keyof MiniSettings]
key: keyof MiniSettings
value: "show" | "hide"
}
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
// appends content (coalesced in the footer queue), "final" closes it.
-132
View File
@@ -1,132 +0,0 @@
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,7 +6,6 @@ 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"
@@ -60,7 +59,10 @@ export function DialogMessage(props: {
value.type === "user"
? value.text
: value.type === "assistant"
? SessionContent.text(data.session.message.parts(props.sessionID, props.messageID))
? value.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("\n")
: "text" in value
? value.text
: ""
+191 -108
View File
@@ -5,7 +5,6 @@ import {
createMemo,
createSignal,
For,
mapArray,
Match,
on,
onCleanup,
@@ -14,8 +13,6 @@ 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"
@@ -42,9 +39,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"
@@ -83,8 +80,7 @@ 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 } from "./rows"
import { messageBoundaryIDs, type PartRef, type SessionRow } from "./timeline"
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
@@ -217,16 +213,7 @@ export function Session() {
})
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
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 boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
@@ -326,7 +313,6 @@ export function Session() {
direction,
children: scroll.getChildren(),
messages: messages(),
hasText: (messageID) => SessionContent.hasText(partsOf(messageID)),
scrollTop: scroll.scrollTop,
viewportY: scroll.viewport.y,
currentID: navigationMessage(),
@@ -694,9 +680,7 @@ export function Session() {
return
}
const textParts = partsOf(lastAssistantMessage.id)
.values()
.filter((part) => part.type === "text")
const textParts = lastAssistantMessage.content.filter((part) => part.type === "text")
if (textParts.length === 0) {
toast.show({ message: "No text parts found in last assistant message", variant: "error" })
dialog.clear()
@@ -734,9 +718,7 @@ export function Session() {
try {
const sessionData = session()
if (!sessionData) return
const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), (messageID) =>
partsOf(messageID).values(),
)
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
await clipboard.write?.(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch {
@@ -763,9 +745,7 @@ export function Session() {
const content =
options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking, (messageID) =>
partsOf(messageID).values(),
)
? formatSessionTranscript(sessionData, messages(), options.thinking)
: await (async () => {
if (options.debug) {
const events: { readonly created: number }[] = []
@@ -948,17 +928,16 @@ export function Session() {
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<KeyedFor each={rows.slots}>
<For each={rows}>
{(row, index) => (
<SessionRowView
row={row()}
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
parts={partsOf}
boundaryID={boundaries()[index()]}
/>
)}
</KeyedFor>
<BackgroundToolHint messages={messages()} parts={partsOf} />
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
@@ -1055,7 +1034,6 @@ export function Session() {
function SessionRowView(props: {
row: SessionRow
message: (messageID: string) => SessionMessageInfo | undefined
parts: (messageID: string) => SessionContent.PartsView
boundaryID?: string
}) {
return (
@@ -1070,17 +1048,10 @@ function SessionRowView(props: {
<CompactionQueued />
</Match>
<Match when={props.row.type === "part" ? props.row : undefined}>
{(row) => <SessionPartView partRef={row().ref} message={props.message} parts={props.parts} />}
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
{(row) => (
<SessionReasoningGroupView
refs={row().refs}
completed={row().completed}
message={props.message}
parts={props.parts}
/>
)}
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
{(row) => (
@@ -1089,7 +1060,6 @@ function SessionRowView(props: {
pending={row().pending}
completed={row().completed}
message={props.message}
parts={props.parts}
/>
)}
</Match>
@@ -1109,23 +1079,20 @@ function SessionRowView(props: {
)
}
function BackgroundToolHint(props: {
messages: SessionMessageInfo[]
parts: (messageID: string) => SessionContent.PartsView
}) {
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const { themeV2 } = useTheme()
const shortcut = Keymap.useShortcut("session.background")
const current = createMemo(() =>
props.messages.findLast(
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
),
)
// 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
const current = 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
)
})
return (
<Show when={visible() && shortcut()}>
@@ -1166,23 +1133,13 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
)
}
// 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
}) {
function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) {
const message = createMemo(() => props.message(props.partRef.messageID))
// 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)
const part = createMemo(() => {
const item = message()
if (item?.type !== "assistant") return
return resolvePart(item, props.partRef.partID)
})
return (
<Show when={part()}>
{(item) => (
@@ -1207,22 +1164,20 @@ function SessionPartView(props: {
}
function SessionReasoningGroupView(props: {
refs: readonly PartRef[]
refs: 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(() =>
accessors().flatMap((entry) => {
const message = props.message(entry.ref.messageID)
props.refs.flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = entry.part()
const part = resolvePart(message, ref.partID)
if (part?.type !== "reasoning" || !reasoningContent(part)) return []
return [{ message, part }]
}),
@@ -1247,11 +1202,7 @@ 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} parts={props.parts} />}
</For>
}
fallback={<For each={props.refs}>{(ref) => <SessionPartView partRef={ref} message={props.message} />}</For>}
>
<box flexDirection="column" flexShrink={0}>
<InlineToolRow
@@ -1287,14 +1238,15 @@ function SessionReasoningGroupView(props: {
<box paddingLeft={3}>
<For each={props.refs}>
{(ref) => {
const slot = useSlot(props.parts(ref.messageID), ref.partID)
const part = createMemo(() => {
const item = slot()
return item?.type === "reasoning" ? item : undefined
})
const messageCompleted = createMemo(() => {
const message = createMemo(() => {
const item = props.message(ref.messageID)
return item?.type === "assistant" && item.time.completed !== undefined
return item?.type === "assistant" ? item : undefined
})
const part = createMemo(() => {
const item = message()
if (!item) return undefined
const part = resolvePart(item, ref.partID)
return part?.type === "reasoning" ? part : undefined
})
const content = createMemo(() => {
const item = part()
@@ -1312,7 +1264,7 @@ function SessionReasoningGroupView(props: {
<code
filetype="markdown"
drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && !messageCompleted()}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
@@ -1333,27 +1285,26 @@ function SessionReasoningGroupView(props: {
}
function SessionGroupView(props: {
refs: readonly PartRef[]
pending: readonly PartRef[]
refs: PartRef[]
pending: 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 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()
const parts = (refs: PartRef[]) =>
refs.flatMap((ref) => {
const message = props.message(ref.messageID)
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
if (part?.type !== "tool") return []
return [part]
})
const grouped = createMemo(() => parts(groupedSlots()))
const pending = createMemo(() => parts(pendingSlots()))
const grouped = createMemo(() => parts(props.refs))
const pending = createMemo(() => parts(props.pending))
const label = createMemo(() => {
const counts = grouped().reduce<Record<string, number>>((result, part) => {
const tool = toolDisplay(part.name)
@@ -1770,6 +1721,122 @@ 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 (
@@ -2938,23 +3005,39 @@ 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,
partsOf: (messageID: string) => readonly SessionContent.Part[],
) {
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {
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 = partsOf(message.id).flatMap((item) => {
const content = message.content.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,9 +19,6 @@ 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
@@ -38,7 +35,7 @@ export function findMessageBoundary(input: {
return [{ id: child.id, y, top: y }]
}
if (input.userOnly || message.type !== "assistant") return []
if (!input.hasText(message.id)) return []
if (!message.content.some((content) => content.type === "text" && content.text.trim())) return []
const y = input.scrollTop + child.y - input.viewportY
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
})
+11 -28
View File
@@ -14,36 +14,9 @@ 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
@@ -143,7 +116,17 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
const pathFormatter = usePathFormatter()
const session = createMemo(() => data.session.get(props.request.sessionID))
const source = usePermissionSource(() => props.request)
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 { themeV2 } = useTheme()
+221 -77
View File
@@ -1,52 +1,45 @@
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 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 { useClient } from "../../context/client"
import { SessionContent } from "./content"
import {
SessionTimeline,
compactionQueuedRow,
isTerminalFinish,
reduceSessionRows,
type AppendPart,
type PartRef,
type SessionRow,
} from "./timeline"
export function createSessionRows(sessionID: Accessor<string>, options?: { readonly metrics?: Keyed.Metrics }) {
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>) {
const data = useData()
const client = useClient()
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 [rows, setRows] = createStore<SessionRow[]>([])
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,
(message) => data.session.message.parts(sessionID(), message.id).values(),
)
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs)
partitionPending(rows, pendingPermissions())
const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
rows.splice(
position === -1 ? rows.length : position,
@@ -54,7 +47,7 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
...data.session.pending
.list(sessionID())
.filter((item) => item.type === "compaction")
.map((item) => compactionQueuedRow(item.id)),
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
)
return rows
}
@@ -69,17 +62,22 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
createEffect(() => {
const pending = pendingPermissions()
batch(() => state.repartition(pending))
setRows(
produce((draft) => {
partitionPending(draft, pending)
}),
)
})
createEffect(
on([sessionID, () => client.connection.status()], ([id, status]) => {
if (status !== "connected") return
setRows(reduce())
setRows(reconcile(reduce()))
void data.session.pending.sync(id).catch(() => undefined)
void data.session.message.sync(id).then(
() => {
if (sessionID() !== id) return
setRows(reduce())
setRows(reconcile(reduce()))
},
() => undefined,
)
@@ -89,7 +87,7 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
// Re-reduce when the revert boundary changes (stage/clear/commit).
createEffect(
on(revertBoundary, () => {
setRows(reduce())
setRows(reconcile(reduce()))
}),
)
@@ -100,7 +98,7 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
.list(sessionID())
.filter((item) => item.type === "compaction")
.map((item) => item.id),
() => setRows(reduce()),
() => setRows(reconcile(reduce())),
),
)
@@ -121,35 +119,68 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
{
id: message.id,
created: message.time.created,
input: false,
status: message.status,
},
]
: [],
),
() => setRows(reduce()),
() => setRows(reconcile(reduce())),
),
)
const appendMessage = (messageID: string) =>
batch(() => {
const pending = isPending(messageID)
const message = data.session.message.get(sessionID(), messageID)
state.appendMessage(messageID, { pending, compaction: message?.type === "compaction" })
})
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 })
}),
)
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 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 appendFooter = (messageID: string) => batch(() => state.appendFooter(messageID))
const removeFooter = (messageID: string) => batch(() => state.removeFooter(messageID))
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 message = (event: { id: string; data: { sessionID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(messageIDFromEvent(event.id))
if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
}
const input = (event: {
data: {
@@ -167,41 +198,35 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
const subscriptions = [
data.on("session.input.admitted", input),
data.on("session.compaction.started", (event) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? messageIDFromEvent(event.id))
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_"))
}),
data.on("session.instructions.updated", message),
data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
appendMessage(messageIDFromEvent(event.id))
appendMessage(event.id.replace(/^evt_/, "msg_"))
}),
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: SessionContent.textID(event.data.ordinal) },
{ type: "text" },
)
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${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: SessionContent.textID(event.data.ordinal) },
{ type: "text" },
)
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${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: SessionContent.reasoningID(event.data.ordinal) },
{ messageID: event.data.assistantMessageID, partID: `reasoning:${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: SessionContent.reasoningID(event.data.ordinal) },
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
{ type: "reasoning" },
)
}),
@@ -219,7 +244,7 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
if (event.data.sessionID === sessionID()) removeFooter(event.data.assistantMessageID)
}),
data.on("session.step.ended", (event) => {
if (event.data.sessionID !== sessionID() || !isTerminalFinish(event.data.finish)) return
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
appendFooter(event.data.assistantMessageID)
}),
data.on("session.step.failed", (event) => {
@@ -227,9 +252,128 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
}),
]
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)
})
}
-325
View File
@@ -1,325 +0,0 @@
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}`
}
-21
View File
@@ -19,27 +19,6 @@ 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"
+108 -513
View File
@@ -8,8 +8,7 @@ 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 } from "../../../src/routes/session/rows"
import type { SessionRow } from "../../../src/routes/session/timeline"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -29,19 +28,6 @@ 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 } })
}
@@ -731,16 +717,10 @@ 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, { metrics })
createEffect(() => {
rows.slots()
structuralUpdates++
})
rows = createSessionRows(() => sessionID)
return <box />
}
@@ -782,81 +762,88 @@ test("completes exploration when a queued prompt is promoted", async () => {
name: "read",
},
})
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)
await wait(() => rows.some((row) => row.type === "group" && !row.completed))
emitEvent(events, {
id: "evt_prompt_admitted",
created: 3,
type: "session.input.admitted",
durable: durable(sessionID, 3),
durable: durable(sessionID, 2),
data: {
sessionID,
inputID: "message-user",
input: { type: "user", data: { text: "Continue" }, delivery: "steer" },
},
})
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)
await wait(() => rows.at(-1)?.type === "message")
expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
emitEvent(events, {
id: "evt_prompt_promoted",
created: 4,
type: "session.input.promoted",
durable: durable(sessionID, 4),
durable: durable(sessionID, 3),
data: { sessionID, inputID: "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)
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
} finally {
app.renderer.destroy()
}
})
test("batches burst event projections into fewer reactive executions", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
const sessionID = "session-event-burst"
let client!: ReturnType<typeof useClient>
let received = 0
let executions = 0
function Probe() {
const data = useData()
client = useClient()
client.event.on("session.input.admitted", () => received++)
createEffect(() => {
data.session.message.list(sessionID).length
executions++
})
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")
const baseline = executions
for (let index = 0; index < 10; index++) {
emitEvent(events, {
id: `evt_input_${index}`,
created: index,
type: "session.input.admitted",
durable: durable(sessionID, index),
data: {
sessionID,
inputID: `message-${index}`,
input: { type: "user", data: { text: `${index}` }, delivery: "steer" },
},
})
}
await wait(() => received === 10)
await Bun.sleep(20)
expect(received).toBe(10)
expect(executions - baseline).toBe(2)
} finally {
app.renderer.destroy()
}
@@ -904,274 +891,8 @@ test("classifies live tool rows independently of their call ID", async () => {
},
})
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)
await wait(() => rows.length > 0)
expect(rows).toEqual([{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } }])
} finally {
app.renderer.destroy()
}
@@ -1323,15 +1044,13 @@ test("tracks session status from active sessions and execution events", async ()
})
}, events)
let data!: ReturnType<typeof useData>
let rows!: ReturnType<typeof createSessionRows>
let manualRows!: ReturnType<typeof createSessionRows>
let liveRows!: ReturnType<typeof createSessionRows>
let rows!: SessionRow[]
let manualRows!: SessionRow[]
function Probe() {
data = useData()
rows = createSessionRows(() => "session-retry")
manualRows = createSessionRows(() => "session-manual")
liveRows = createSessionRows(() => "session-live")
return <box />
}
@@ -1527,7 +1246,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(() => readRows(rows).some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"))
emitEvent(events, {
id: "evt_retry_next_step",
created: 2_000,
@@ -1544,9 +1263,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 === undefined
})
await wait(
() => !readRows(rows).some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"),
)
await wait(() => !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",
@@ -1601,9 +1318,7 @@ 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 = readRows(manualRows).find(
(row) => row.type === "message" && row.messageID === "message-compaction",
)
const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")
emitEvent(events, {
id: "evt_manual_compaction_ended",
created: 3,
@@ -1615,12 +1330,10 @@ 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(
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(
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(
compactionRow,
)
@@ -1647,10 +1360,7 @@ 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 = readRows(liveRows).find(
(row) => row.type === "message" && row.messageID === "msg_compaction_started",
)
expect(autoCompactionRow).toBeDefined()
const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")
emitEvent(events, {
id: "evt_compaction_ended",
@@ -1668,12 +1378,10 @@ test("tracks session status from active sessions and execution events", async ()
status: "completed",
summary: "Live summary",
})
expect(readRows(liveRows).find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe(
expect(rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe(
autoCompactionRow,
)
expect(
readRows(liveRows).some((row) => row.type === "message" && row.messageID === "msg_compaction_ended"),
).toBeFalse()
expect(rows.some((row) => row.type === "message" && row.messageID === "msg_compaction_ended")).toBeFalse()
} finally {
app.renderer.destroy()
}
@@ -1732,12 +1440,8 @@ test("restores queued compaction from durable pending input", async () => {
"message-compaction-queued",
"message-compaction-later",
])
await wait(() => readRows(rows).filter((row) => row.type === "compaction-queued").length === 2)
expect(
readRows(rows)
.filter((row) => row.type === "compaction-queued")
.map(withoutRowID),
).toEqual([
await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2)
expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([
{ type: "compaction-queued", inputID: "message-compaction-queued" },
{ type: "compaction-queued", inputID: "message-compaction-later" },
])
@@ -1754,8 +1458,8 @@ test("restores queued compaction from durable pending input", async () => {
text: "Active output",
},
})
await wait(() => readRows(rows).some((row) => row.type === "part"))
expect(readRows(rows).map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
await wait(() => rows.some((row) => row.type === "part"))
expect(rows.map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"])
emitEvent(events, {
id: "evt_compaction_started",
@@ -2696,9 +2400,12 @@ test("settles pending tools when a live failure arrives", async () => {
})
await wait(() => {
const part = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.()
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
return (
part?.type === "tool" && part.state.status === "running" && part.state.structured.sessionID === "session-child"
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
assistant.content[0].state.status === "running" &&
assistant.content[0].state.structured.sessionID === "session-child"
)
})
@@ -2718,15 +2425,19 @@ test("settles pending tools when a live failure arrives", async () => {
})
await wait(() => {
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")
return (
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
assistant.content[0].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 = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.()
const tool = assistant.content[0]
expect(tool?.type).toBe("tool")
if (tool?.type !== "tool") return
expect(tool.state.status).toBe("error")
@@ -2753,40 +2464,16 @@ test("settles pending tools when a live failure arrives", async () => {
}
})
test("preserves admitted prompts when hydration races with promotion", async () => {
test("renders admitted prompts immediately and tracks them until promoted", 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}/pending`)
if (url.pathname === `/api/session/${sessionID}/message`)
return json({
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",
},
],
data: [{ id: messageID, type: "user", text: "hello", time: { created: 0 } }],
cursor: {},
})
if (url.pathname !== `/api/session/${sessionID}/message`) return
requested.resolve()
return response.promise
}, events)
let sync!: ReturnType<typeof useData>
let ready!: () => void
@@ -2844,11 +2531,12 @@ test("preserves admitted prompts when hydration races with promotion", async ()
])
expect(sync.session.input.list(sessionID)).toEqual([messageID])
const refresh = sync.session.message.sync(sessionID)
await requested.promise
await sync.session.message.sync(sessionID)
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
emitEvent(events, {
id: "evt_prompted_1",
created: 1,
created: 0,
type: "session.input.promoted",
durable: durable(sessionID, 1),
data: {
@@ -2860,17 +2548,14 @@ test("preserves admitted prompts when hydration races with promotion", async ()
await wait(() => received.at(-1) === "session.input.promoted")
expect(received.slice(-2)).toEqual(["session.input.admitted", "session.input.promoted"])
unsubscribe()
response.resolve(json({ data: [], cursor: {} }))
await refresh
const message = sync.session.message.get(sessionID, messageID)
const message = sync.session.message.list(sessionID)?.[0]
expect(message?.type).toBe("user")
if (message?.type !== "user") return
expect(message).toMatchObject({ id: messageID, text: "hello" })
expect(message.metadata).toBeUndefined()
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.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.message.list("missing")).toEqual([])
expect(sync.session.message.get(sessionID, messageID)).toBe(message)
expect(sync.session.message.get(sessionID, "missing")).toBeUndefined()
@@ -2880,96 +2565,6 @@ test("preserves admitted prompts when hydration races with promotion", async ()
}
})
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,10 +7,6 @@ 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 },
@@ -28,7 +24,6 @@ test("finds the next user message without stopping at an assistant message", ()
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
userOnly: true,
@@ -42,7 +37,6 @@ 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,
@@ -56,7 +50,6 @@ test("preserves navigation across both user and assistant messages", () => {
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
}),
@@ -66,7 +59,6 @@ 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,
}),
@@ -79,7 +71,6 @@ 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",
@@ -91,7 +82,6 @@ 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",
@@ -106,7 +96,6 @@ test("stops at the first and last selected user message", () => {
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
@@ -118,7 +107,6 @@ test("stops at the first and last selected user message", () => {
direction: "prev",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
@@ -133,7 +121,6 @@ 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",
@@ -148,7 +135,6 @@ test("stops at the first and last message", () => {
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
@@ -159,7 +145,6 @@ test("stops at the first and last message", () => {
direction: "prev",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
@@ -1,148 +0,0 @@
/** @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()
}
})
+11 -195
View File
@@ -1,18 +1,6 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
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
})
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
const messages: SessionMessageInfo[] = [
@@ -28,22 +16,6 @@ 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 } },
@@ -58,7 +30,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
]),
]
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "user-1" },
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
{
@@ -85,7 +57,7 @@ test("keeps non-exploration tools as individual part rows", () => {
]),
]
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
expect(reduceSessionRows(messages)).toEqual([
{
type: "group",
kind: "exploration",
@@ -114,7 +86,7 @@ test("assigns stable kind ordinals within an assistant message", () => {
]),
]
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
expect(reduceSessionRows(messages)).toEqual([
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
{
type: "group",
@@ -142,7 +114,7 @@ test("groups adjacent reasoning parts until a visible boundary", () => {
]),
]
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
expect(reduceSessionRows(messages)).toEqual([
{
type: "group",
kind: "reasoning",
@@ -174,7 +146,7 @@ test("groups across empty assistant reasoning parts", () => {
]),
]
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
expect(reduceSessionRows(messages)).toEqual([
{
type: "group",
kind: "reasoning",
@@ -205,7 +177,7 @@ test("completes exploration groups when another row follows", () => {
finished,
]
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
expect(reduceSessionRows(messages)).toEqual([
{
type: "group",
kind: "exploration",
@@ -237,7 +209,7 @@ test("hides synthetic messages without descriptions", () => {
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
]
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
expect(reduceSessionRows(messages)).toEqual([
{
type: "group",
kind: "exploration",
@@ -264,7 +236,7 @@ test("renders synthetic messages with descriptions", () => {
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
]
expect(withoutIDs(reduceSessionRows(messages))).toEqual([
expect(reduceSessionRows(messages)).toEqual([
{
type: "group",
kind: "exploration",
@@ -291,7 +263,7 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
error: { type: "provider.transport", message: "Disconnected" },
}
expect(withoutIDs(reduceSessionRows([message]))).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
})
test("places a running compaction barrier before every queued user message", () => {
@@ -315,169 +287,13 @@ test("places a running compaction barrier before every queued user message", ()
queued("user-after", "After", 3),
]
expect(withoutIDs(reduceSessionRows(messages, new Set(["user-before", "user-after"])))).toEqual([
expect(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",
+43 -5
View File
@@ -51,14 +51,12 @@ function update(version: string): OpenCodeEvent {
}
}
async function mount(
reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
log?: LogSink,
) {
async function mount(reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>, log?: LogSink) {
const events = createEventStream()
const calls = createFetch(undefined, events)
const seen: OpenCodeEvent[] = []
const workspaces: Array<string | undefined> = []
const handshakes: string[] = []
let client!: ReturnType<typeof useClient>
let done!: () => void
const ready = new Promise<void>((resolve) => {
@@ -76,24 +74,27 @@ async function mount(
}}
seen={seen}
workspaces={workspaces}
handshakes={handshakes}
/>
</ClientProvider>
</TestTuiContexts>
))
await ready
return { app, events, emit: events.emit, client, seen, workspaces }
return { app, events, emit: (event: OpenCodeEvent) => events.emit(event), client, seen, workspaces, handshakes }
}
function Probe(props: {
seen: OpenCodeEvent[]
workspaces: Array<string | undefined>
handshakes: string[]
onReady: (ctx: { client: ReturnType<typeof useClient> }) => void
}) {
const client = useClient()
const event = useEvent()
onMount(() => {
client.event.on("server.connected", () => props.handshakes.push(client.connection.status()))
event.subscribe((evt, { workspace }) => {
props.seen.push(evt)
props.workspaces.push(workspace)
@@ -105,6 +106,35 @@ function Probe(props: {
}
describe("useEvent", () => {
test("dispatches server.connected immediately", async () => {
const { app, client, handshakes } = await mount()
try {
await wait(() => client.connection.status() === "connected")
expect(handshakes).toEqual(["connecting"])
} finally {
app.renderer.destroy()
}
})
test("delivers a burst exactly once and in order", async () => {
const { app, client, emit, seen } = await mount()
try {
await wait(() => client.connection.status() === "connected")
for (const branch of ["one", "two", "three"]) emit(vcs(branch))
await wait(() => seen.length === 3)
expect(seen.map((item) => (item.type === "vcs.branch.updated" ? item.data.branch : item.type))).toEqual([
"one",
"two",
"three",
])
} finally {
app.renderer.destroy()
}
})
test("logs only durable events", async () => {
const logs: Array<{ level: LogLevel; message: string; tags: Readonly<Record<string, unknown>> }> = []
const { app, emit, seen } = await mount(undefined, (level, message, tags) => {
@@ -195,6 +225,9 @@ describe("useEvent", () => {
await wait(() => client.connection.status() === "connected")
// Reconnection only runs when the stream is down, never while connected.
expect(attempts).toEqual([])
events.emit(event(vcs("before-drop"), { directory: "/tmp/original" }))
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "before-drop"))
events.emit(event(vcs("at-drop"), { directory: "/tmp/original" }))
events.disconnect()
await wait(() => client.connection.status() === "connected" && attempts.length > 0)
replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" }))
@@ -202,6 +235,11 @@ describe("useEvent", () => {
expect(client.api).toBe(replacement.api)
expect(attempts).toEqual([1])
expect(seen.map((item) => (item.type === "vcs.branch.updated" ? item.data.branch : item.type))).toEqual([
"before-drop",
"at-drop",
"rediscovered",
])
const history = client.connection.internal.history()
expect(history.map((event) => [event.data.status, event.data.attempt])).toEqual([
["connecting", 0],
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import { createEventBatcher } from "../../src/context/event-batcher"
function clock() {
let time = 100
const scheduled = new Map<ReturnType<typeof setTimeout>, { callback: () => void; at: number }>()
return {
now: () => time,
schedule(callback: () => void, delay: number) {
const timer = setTimeout(() => {}, 60_000)
scheduled.set(timer, { callback, at: time + delay })
return timer
},
cancel(timer: ReturnType<typeof setTimeout>) {
clearTimeout(timer)
scheduled.delete(timer)
},
advance(delay: number) {
time += delay
for (const [timer, task] of scheduled) {
if (task.at > time) continue
clearTimeout(timer)
scheduled.delete(timer)
task.callback()
}
},
pending() {
return scheduled.size
},
}
}
describe("createEventBatcher", () => {
test("preserves events in frame-bounded flushes", () => {
const time = clock()
const flushes: number[][] = []
const batcher = createEventBatcher<number>((events) => flushes.push(events), time)
batcher.add(1)
time.advance(1)
batcher.add(2)
batcher.add(3)
expect(flushes).toEqual([[1]])
expect(time.pending()).toBe(1)
time.advance(15)
expect(flushes).toEqual([[1]])
time.advance(1)
expect(flushes).toEqual([[1], [2, 3]])
expect(flushes.flat()).toEqual([1, 2, 3])
})
test("flushes a live generation and discards an obsolete generation", () => {
const time = clock()
const live: number[][] = []
const active = createEventBatcher<number>((events) => live.push(events), time)
active.add(1)
time.advance(1)
active.add(2)
active.end(false)
const obsolete: number[][] = []
const stale = createEventBatcher<number>((events) => obsolete.push(events), time)
stale.add(3)
time.advance(1)
stale.add(4)
stale.end(true)
time.advance(16)
expect(live).toEqual([[1], [2]])
expect(obsolete).toEqual([[3]])
expect(time.pending()).toBe(0)
})
test("caps a batch when timers cannot run", () => {
const time = clock()
const flushes: number[][] = []
const batcher = createEventBatcher<number>((events) => flushes.push(events), { ...time, limit: 3 })
batcher.add(1)
time.advance(1)
batcher.add(2)
batcher.add(3)
batcher.add(4)
expect(flushes).toEqual([[1], [2, 3, 4]])
expect(time.pending()).toBe(0)
})
})
-23
View File
@@ -17,26 +17,3 @@ 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 },
],
})
})
-1
View File
@@ -109,7 +109,6 @@ 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")
+14 -20
View File
@@ -43,21 +43,20 @@ function structured(next: StreamCommit) {
describe("run entry body", () => {
test("renders a failed direct shell as an error instead of completed success", () => {
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",
})
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" })
})
test("renders assistant, reasoning, and user entries in their display formats", () => {
@@ -115,11 +114,6 @@ 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,8 +55,7 @@ test("down opens subagents from an empty prompt", async () => {
subagent={subagents}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={config}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false })}
mono={false}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show" })}
onSubmit={() => true}
onPermissionReply={() => {}}
onFormReply={() => {}}
+11 -29
View File
@@ -121,7 +121,6 @@ async function renderFooter(
view?: FooterView
onFormReply?: (input: unknown) => void
miniSettings?: MiniSettings
mono?: boolean
onMiniSettingChange?: (change: MiniSettingChange) => void
} = {},
) {
@@ -132,7 +131,7 @@ async function renderFooter(
const state = footerState(input.state)
const config = input.tuiConfig ?? tuiConfig
const [miniSettings] = createSignal<MiniSettings>(
input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false },
input.miniSettings ?? { thinking: "hide", shell_output: "hide", turn_summary: "show" },
)
function Harness() {
return (
@@ -151,7 +150,6 @@ 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)}
@@ -399,7 +397,6 @@ 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")
@@ -427,7 +424,6 @@ test("direct settings panel changes Mini transcript preferences", async () => {
thinking: "hide",
shell_output: "hide",
turn_summary: "show",
mono: false,
})
const app = await testRender(
() => (
@@ -439,7 +435,6 @@ test("direct settings panel changes Mini transcript preferences", async () => {
onChange={(change) => {
setSettings((current) => ({ ...current, [change.key]: change.value }))
}}
mono
/>
</box>
),
@@ -448,32 +443,23 @@ test("direct settings panel changes Mini transcript preferences", async () => {
try {
await app.renderOnce()
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]/)
expect(app.captureCharFrame()).toContain("Settings")
expect(app.captureCharFrame()).toContain("Thinking")
expect(app.captureCharFrame()).toContain("Shell tool output")
expect(app.captureCharFrame()).toContain("Turn summary")
expect(app.captureCharFrame()).toContain("left/right change")
app.mockInput.pressKey("ARROW_RIGHT")
await app.renderOnce()
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show", mono: false })
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "show" })
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)
expect(settings()).toEqual({ thinking: "show", shell_output: "hide", turn_summary: "hide" })
} finally {
app.renderer.destroy()
}
@@ -949,11 +935,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")
expect(app.captureCharFrame()).toContain("Shell tool output")
app.mockInput.pressKey("c", { ctrl: true })
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("Shell")
expect(app.captureCharFrame()).not.toContain("Shell tool output")
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
} finally {
app.cleanup()
@@ -1127,8 +1113,7 @@ test("direct footer shows authoritative pending work while running", async () =>
]}
theme={() => RUN_THEME_FALLBACK}
tuiConfig={tuiConfig}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", mono: false })}
mono={false}
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show" })}
onSubmit={() => true}
onPermissionReply={() => {}}
onFormReply={() => {}}
@@ -1270,17 +1255,14 @@ 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,32 +155,28 @@ describe("run permission shared", () => {
})
test("uses source patch text when an edit has no generated diff", () => {
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",
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",
),
}),
),
})
expect(permissionInfo(request)).toMatchObject({
).toMatchObject({
title: "Edit src/index.ts",
diff: undefined,
patch,
})
expect(permissionInfo(request, undefined, true)).toMatchObject({
title: "Edit src/index.ts",
lines: [patch],
diff: undefined,
patch: undefined,
patch: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch",
})
})
+2 -10
View File
@@ -103,19 +103,11 @@ 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",
turn_summary: "show",
mono: false,
})
expect(
resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide", mono: true } }),
).toEqual({
expect(resolveMiniSettings(result)).toEqual({ thinking: "hide", shell_output: "hide", turn_summary: "show" })
expect(resolveMiniSettings({ mini: { thinking: "show", shell_output: "show", turn_summary: "hide" } })).toEqual({
thinking: "show",
shell_output: "show",
turn_summary: "hide",
mono: true,
})
})
+1 -18
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
import { RUN_THEME_MONO, RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
import { 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,14 +23,12 @@ 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")
@@ -63,21 +61,6 @@ 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", () => {
-76
View File
@@ -1,76 +0,0 @@
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}`))
}),
})
-46
View File
@@ -1,46 +0,0 @@
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}`))
}),
})