refactor(tui): adopt reactive first index and read-only keyed surface

- quark: Collection.first(name) is now a stable Readable<A | undefined>;
  Keyed.ReadOnly names the read surface; useSlot takes plain constant keys;
  Layout.collectionOf<Target>() replaces the content plan double cast
- BackgroundToolHint reads a backgroundRunning first index instead of
  hand-tracking structure plus tool slots
- toolDisplay moves to util/tool-display (content.ts needs it for the index)
This commit is contained in:
Kit Langton
2026-07-18 22:54:25 -04:00
parent 4027dbb4d8
commit c0ce124f8c
12 changed files with 374 additions and 98 deletions
+195
View File
@@ -0,0 +1,195 @@
import { Layout } from "../src/layout"
import { createHarness, type Workload } from "./harness"
type Job = {
readonly id: number
readonly labels: readonly number[]
readonly status: "running" | "retrying"
readonly value: number
}
const size = 1_000
const iterations = 200_000
const initial = Array.from({ length: size }, (_, id): Job => ({
id,
labels: [id],
status: id === 750 ? "retrying" : "running",
value: 0,
}))
const JobLayout = Layout.struct({
id: Layout.key(Layout.number),
labels: Layout.array(Layout.number),
status: Layout.string,
value: Layout.number,
})
const Jobs = Layout.collection(JobLayout, ({ members, first }) => ({
labels: members(["labels"], (job) => job.labels),
retry: first(["status"], (job) => job.status === "retrying"),
}))
const LabeledJobs = Layout.collection(JobLayout, ({ members }) => ({
labels: members(["labels"], (job) => job.labels),
}))
const JobPlan = Layout.compile(JobLayout)
const bench = createHarness({ warmup: 10_000, width: 38 })
function manualValueUpdate(): Workload {
const jobs = JobPlan.make(initial)
const labels = new Set(initial.flatMap((job) => job.labels))
const retry = jobs.get(750)!
return {
run(index) {
const id = index % size
jobs.modify(id, (job) => ({ ...job, value: index + 1 }))
},
consume: () => Number(labels.has(500)) + retry().id + jobs.get(iterations % size)!().value,
}
}
function indexedValueUpdate(): Workload {
const jobs = Jobs.make(initial)
return {
run(index) {
const id = index % size
jobs.modify(id, (job) => ({ ...job, value: index + 1 }))
},
consume: () =>
Number(jobs.hasMember("labels", 500)) + jobs.first("retry")()!.id + jobs.get(iterations % size)!().value,
}
}
function manualMemberAppend(): Workload {
const jobs = JobPlan.make(initial)
const labels = new Map(initial.flatMap((job) => job.labels.map((label) => [label, 1])))
const members = new Map(initial.map((job) => [job.id, new Set(job.labels)]))
return {
run(index) {
const id = index % size
const label = size + index
jobs.modify(id, (job) => {
const next = [...job.labels.slice(-7), label]
const previous = members.get(id)!
const current = new Set(next)
previous.forEach((member) => {
if (current.has(member)) return
const count = labels.get(member)!
if (count === 1) labels.delete(member)
if (count > 1) labels.set(member, count - 1)
})
current.forEach((member) => {
if (!previous.has(member)) labels.set(member, (labels.get(member) ?? 0) + 1)
})
members.set(id, current)
return { ...job, labels: next }
})
},
consume: () => Number(labels.has(size + iterations - 1)) + jobs.get(iterations % size)!().labels.length,
}
}
function indexedMemberAppend(): Workload {
const jobs = LabeledJobs.make(initial)
return {
run(index) {
const id = index % size
const label = size + index
jobs.modify(id, (job) => ({
...job,
labels: [...job.labels.slice(-7), label],
}))
},
consume: () =>
Number(jobs.hasMember("labels", size + iterations - 1)) + jobs.get(iterations % size)!().labels.length,
}
}
function manualGrowingAppend(): Workload {
const jobs = JobPlan.make([{ id: 0, labels: [], status: "running", value: 0 }])
const labels = new Set<number>()
let label = 0
return {
run() {
const next = label++
jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] }))
labels.add(next)
},
consume: () => Number(labels.has(label - 1)) + jobs.get(0)!().labels.length,
}
}
function indexedGrowingAppend(): Workload {
const jobs = LabeledJobs.make([{ id: 0, labels: [], status: "running", value: 0 }])
let label = 0
return {
run() {
const next = label++
jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] }), {
members: { labels: { add: [next] } },
})
},
consume: () => Number(jobs.hasMember("labels", label - 1)) + jobs.get(0)!().labels.length,
}
}
function automaticGrowingAppend(): Workload {
const jobs = LabeledJobs.make([{ id: 0, labels: [], status: "running", value: 0 }])
let label = 0
return {
run() {
const next = label++
jobs.modify(0, (job) => ({ ...job, labels: [...job.labels, next] }))
},
consume: () => Number(jobs.hasMember("labels", label - 1)) + jobs.get(0)!().labels.length,
}
}
function collectionSet(indexed: boolean, changed: boolean): Workload {
const jobs = indexed ? Jobs.make(initial) : JobPlan.make(initial)
return {
run(index) {
const id = index % size
const current = jobs.values()
const job = current[id]
jobs.set(current.with(id, { ...job, value: changed ? job.value + 1 : job.value }))
},
consume: () => jobs.values()[0].value + jobs.values().length,
}
}
console.log(`Compiled collection benchmark (${size} items, ${bench.samples} samples)\n`)
const value = bench.compare(iterations, [
{ name: "Handwritten value update", make: manualValueUpdate },
{ name: "Compiled indexed value update", make: indexedValueUpdate },
])
const member = bench.compare(iterations, [
{ name: "Handwritten member append", make: manualMemberAppend },
{ name: "Compiled indexed member append", make: indexedMemberAppend },
])
const growing = bench.compare(2_000, [
{ name: "Handwritten growing append", make: manualGrowingAppend },
{ name: "Automatic indexed growing append", make: automaticGrowingAppend },
{ name: "Indexed delta growing append", make: indexedGrowingAppend },
])
const equivalentSet = bench.compare(2_000, [
{ name: "Bare keyed equivalent set", make: () => collectionSet(false, false) },
{ name: "Compiled indexed equivalent set", make: () => collectionSet(true, false) },
])
const changedSet = bench.compare(2_000, [
{ name: "Bare keyed changed set", make: () => collectionSet(false, true) },
{ name: "Compiled indexed changed set", make: () => collectionSet(true, true) },
])
console.log("\nRatios to handwritten (lower is faster)")
console.log(`Value update: ${value.ratio(1, 0).toFixed(3)}x`)
console.log(`Member append: ${member.ratio(1, 0).toFixed(3)}x`)
console.log(`Automatic growing append: ${growing.ratio(1, 0).toFixed(3)}x`)
console.log(`Delta growing append: ${growing.ratio(2, 0).toFixed(3)}x`)
console.log(`Equivalent collection set: ${equivalentSet.ratio(1, 0).toFixed(3)}x`)
console.log(`Changed collection set: ${changedSet.ratio(1, 0).toFixed(3)}x`)
console.log(`METRIC collection_value_ratio=${value.ratio(1, 0).toFixed(6)}`)
console.log(`METRIC collection_member_ratio=${member.ratio(1, 0).toFixed(6)}`)
console.log(`METRIC collection_automatic_growing_ratio=${growing.ratio(1, 0).toFixed(6)}`)
console.log(`METRIC collection_delta_growing_ratio=${growing.ratio(2, 0).toFixed(6)}`)
console.log(`METRIC collection_equivalent_set_ratio=${equivalentSet.ratio(1, 0).toFixed(6)}`)
console.log(`METRIC collection_changed_set_ratio=${changedSet.ratio(1, 0).toFixed(6)}`)
bench.finish()
+7 -3
View File
@@ -9,19 +9,23 @@ export namespace Keyed {
equivalenceSuppressions: number
}
export interface Keyed<A, Key> {
/** 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
before(key: Key): Readable<A> | undefined
after(key: Key): Readable<A> | undefined
}
export function make<A, Key>(options: {
+59 -24
View File
@@ -1,5 +1,5 @@
import { Keyed } from "./keyed"
import { Transaction, type Readable } from "./reactivity"
import { Computed, State, Transaction, type Readable } from "./reactivity"
export namespace Layout {
export interface Field<A> {
@@ -41,10 +41,8 @@ export namespace Layout {
readonly fields: StructFields
}
export interface Union<
Tag extends PropertyKey,
Variants extends Readonly<Record<string, Field<unknown>>>,
> extends Field<Variant<Tag, Variants>> {
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
@@ -135,7 +133,8 @@ export namespace Layout {
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
first<Name extends FirstNames<Definitions>>(name: Name): Readable<A> | undefined
/** 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> {
@@ -223,7 +222,8 @@ export namespace Layout {
>(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>>>>
| 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(
@@ -276,11 +276,17 @@ export namespace Layout {
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>) =>
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) =>
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"],
@@ -293,6 +299,28 @@ export namespace Layout {
}
}
/**
* 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,
@@ -358,7 +386,7 @@ export namespace Layout {
afterPlacement?(slot: Readable<A>): void
afterRebuild?(): void
member?(candidate: unknown): boolean
firstSlot?(): Readable<A> | undefined
readonly first?: Readable<A | undefined>
}
function membersEntry(
@@ -421,29 +449,34 @@ export namespace Layout {
matches: (value: A) => boolean,
): Entry {
const matching = new Set<Key>()
let slot: Readable<A> | undefined
// 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 = values.slots().find((candidate) => matching.has(plan.keyOf(candidate())))
slot.set(values.slots().find((candidate) => matching.has(plan.keyOf(candidate()))))
}
function afterPlacement(candidate: Readable<A>) {
if (!matching.has(plan.keyOf(candidate()))) return
if (!slot) {
slot = candidate
const current = slot()
if (!current) {
slot.set(candidate)
return
}
if (slot === candidate) {
if (current === candidate) {
findFirst()
return
}
// Single pass: whichever of the two slots appears first wins.
for (const current of values.slots()) {
if (current === candidate) {
slot = candidate
for (const item of values.slots()) {
if (item === candidate) {
slot.set(candidate)
return
}
if (current === slot) return
if (item === current) return
}
}
@@ -457,20 +490,20 @@ export namespace Layout {
if (matched) matching.add(key)
if (!matched) matching.delete(key)
if (mode === "add") return
if (slot === valueSlot && !matched) {
if (slot() === valueSlot && !matched) {
findFirst()
return
}
if (slot !== valueSlot && !previous && matched) afterPlacement(valueSlot)
if (slot() !== valueSlot && !previous && matched) afterPlacement(valueSlot)
}
},
remove(key, removedSlot) {
matching.delete(key)
if (removedSlot && slot === removedSlot) findFirst()
if (removedSlot && slot() === removedSlot) findFirst()
},
afterPlacement,
afterRebuild: findFirst,
firstSlot: () => slot,
first,
}
}
@@ -628,7 +661,9 @@ export namespace Layout {
return byName.get(normalizeName(name))?.member?.(member) ?? false
},
first(name) {
return byName.get(normalizeName(name))?.firstSlot?.()
const entry = byName.get(normalizeName(name))
if (!entry?.first) throw new Error(`Unknown first index: ${String(name)}`)
return entry.first
},
}
collection.set(initial)
+10 -4
View File
@@ -1,3 +1,11 @@
/**
* 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
@@ -188,8 +196,7 @@ function readComputed<A>(node: ComputedNode<A>): A {
}
if (
flags & Flags.Dirty ||
(flags & Flags.Pending &&
(checkDirty(node.deps!, node) || ((node.flags = flags & ~Flags.Pending), false))) ||
(flags & Flags.Pending && (checkDirty(node.deps!, node) || ((node.flags = flags & ~Flags.Pending), false))) ||
!flags
) {
if (updateComputed(node) && node.subs !== undefined) {
@@ -255,8 +262,7 @@ 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))
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
+7 -2
View File
@@ -10,12 +10,17 @@ export function useValue<A>(readable: Readable<A>): Accessor<A> {
* 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: Pick<Keyed.Keyed<A, Key>, "slots" | "get">, key: () => Key): Accessor<A | undefined> {
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(key())
return keyed.get(resolve())
})
const value = createMemo(() => {
const current = slot()
+34 -16
View File
@@ -224,19 +224,40 @@ describe("Layout", () => {
expect(jobs.hasMember("labels", "billing")).toBe(true)
expect(jobs.hasMember("labels", "missing")).toBe(false)
expect(jobs.first("nextRetry")?.().id).toBe("two")
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")
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")
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", () => {
@@ -244,14 +265,14 @@ describe("Layout", () => {
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")
expect(jobs.first("nextRetry")()?.id).toBe("two")
jobs.update({ id: "two", labels: [], status: "done" })
expect(jobs.first("nextRetry")?.().id).toBe("three")
expect(jobs.first("nextRetry")()?.id).toBe("three")
expect(jobs.hasMember("labels", "shared")).toBe(true)
jobs.remove("three")
expect(jobs.first("nextRetry")).toBeUndefined()
expect(jobs.first("nextRetry")()).toBeUndefined()
expect(jobs.hasMember("labels", "shared")).toBe(false)
jobs.set([
@@ -260,7 +281,7 @@ describe("Layout", () => {
])
expect(jobs.hasMember("labels", "replacement")).toBe(true)
expect(jobs.hasMember("labels", "one")).toBe(false)
expect(jobs.first("nextRetry")?.().id).toBe("four")
expect(jobs.first("nextRetry")()?.id).toBe("four")
})
it("skips index projection when the change is disjoint from its declared fields", () => {
@@ -284,7 +305,7 @@ describe("Layout", () => {
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")
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" })
@@ -340,14 +361,11 @@ describe("Layout", () => {
const jobs = CountedJobs.make([one, two])
comparisons = 0
jobs.set([
{ ...one },
{ ...two, value: "after" },
])
jobs.set([{ ...one }, { ...two, value: "after" }])
expect(comparisons).toBe(4)
expect(jobs.get("one")?.()).toBe(one)
expect(jobs.first("changed")?.().id).toBe("two")
expect(jobs.first("changed")()?.id).toBe("two")
comparisons = 0
jobs.update({ ...jobs.get("one")!(), status: "busy" })
@@ -356,7 +374,7 @@ describe("Layout", () => {
comparisons = 0
jobs.modify("two", (job) => ({ ...job, value: "done" }))
expect(comparisons).toBe(1)
expect(jobs.first("changed")).toBeUndefined()
expect(jobs.first("changed")()).toBeUndefined()
})
it("refreshes indexes that read a changed union discriminant", () => {
@@ -379,7 +397,7 @@ describe("Layout", () => {
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")
expect(rows.first("firstReady")()?.type).toBe("ready")
})
it("tracks lazy member iterables while they are consumed", () => {
@@ -470,7 +488,7 @@ describe("Layout", () => {
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.first("nextRetry")()).toBeUndefined()
expect(() => jobs.insert({ id: "two", labels: ["boom"], status: "retrying" }, { before: "missing" })).toThrow(
"Keyed value does not exist: missing",
+16 -1
View File
@@ -33,7 +33,7 @@ describe("Solid adapter", () => {
createRoot((dispose) => {
const current = useValue(state)
createComputed(() => value = current())
createComputed(() => (value = current()))
state.set(second)
dispose()
})
@@ -41,6 +41,21 @@ describe("Solid adapter", () => {
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([
+13 -4
View File
@@ -1,5 +1,6 @@
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.
@@ -12,9 +13,9 @@ import { Keyed, Layout } from "@opencode-ai/quark"
export namespace SessionContent {
type ContentPart = SessionMessageAssistant["content"][number]
export type Part = ContentPart & { readonly partID: string }
export type Parts = Keyed.Keyed<Part, string>
export type Parts = ReturnType<typeof PartPlan.make>
/** Read surface for view components; mutation stays with the data layer. */
export type PartsView = Pick<Parts, "slots" | "values" | "get" | "has">
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.
@@ -38,8 +39,16 @@ export namespace SessionContent {
},
})
// The layout describes the reactive fields of the client content shapes;
// the plan is typed against those shapes at this single boundary.
const PartPlan = Layout.compile(PartLayout) as unknown as Layout.Plan<Part, string>
// 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}`
+8 -42
View File
@@ -41,7 +41,7 @@ import type {
import { useLocal } from "../../context/local"
import { Locale } from "../../util/locale"
import { FilePath } from "../../ui/file-path"
import { webSearchProviderLabel } from "../../util/tool-display"
import { toolDisplay, webSearchProviderLabel } from "../../util/tool-display"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useClient } from "../../context/client"
import { useEditorContext } from "../../context/editor"
@@ -1107,24 +1107,13 @@ function BackgroundToolHint(props: {
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
),
)
// Track the part structure (publishes only on part insert/remove) and the
// individual tool slots; text and reasoning deltas never reach this memo.
const toolSlots = createMemo(() => {
// 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 []
const parts = props.parts(message.id)
return useValue(parts.slots)()
.filter((slot) => slot().type === "tool")
.map((slot) => useValue(slot))
if (!message) return false
return useValue(props.parts(message.id).first("backgroundRunning"))() !== undefined
})
const visible = createMemo(() =>
toolSlots().some((tool) => {
const part = tool()
if (part.type !== "tool" || part.state.status !== "running") return false
const display = toolDisplay(part.name)
return display === "shell" || display === "subagent"
}),
)
return (
<Show when={visible() && shortcut()}>
{(value) => (
@@ -1169,7 +1158,7 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
// 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) }))
return mapArray(refs, (ref) => ({ ref, part: useSlot(parts(ref.messageID), ref.partID) }))
}
function SessionPartView(props: {
@@ -1285,7 +1274,7 @@ function SessionReasoningGroupView(props: {
<box paddingLeft={3}>
<For each={props.refs}>
{(ref) => {
const slot = useSlot(props.parts(ref.messageID), () => ref.partID)
const slot = useSlot(props.parts(ref.messageID), ref.partID)
const part = createMemo(() => {
const item = slot()
return item?.type === "reasoning" ? item : undefined
@@ -2942,29 +2931,6 @@ function numberValue(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}
const toolDisplays = new Set([
"shell",
"glob",
"read",
"grep",
"webfetch",
"websearch",
"write",
"edit",
"subagent",
"execute",
"patch",
"question",
"skill",
])
export function toolDisplay(tool: string) {
// Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render
// them with the renamed views.
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : 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>
@@ -24,7 +24,7 @@ export function usePermissionInput(request: PermissionV2Request): () => Record<s
const data = useData()
const tool = request.source
if (!tool) return () => ({})
const part = useSlot(data.session.message.parts(request.sessionID, tool.messageID), () => tool.callID)
const part = 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 item.state.input
+23
View File
@@ -1,3 +1,26 @@
const toolDisplays = new Set([
"shell",
"glob",
"read",
"grep",
"webfetch",
"websearch",
"write",
"edit",
"subagent",
"execute",
"patch",
"question",
"skill",
])
export function toolDisplay(tool: string) {
// Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render
// them with the renamed views.
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : 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"
@@ -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