1
0
Эх сурвалжийг харах

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)
Kit Langton 4 долоо хоног өмнө
parent
commit
1a28b03e05

+ 195 - 0
packages/quark/bench/collection.ts

@@ -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
packages/quark/src/keyed.ts

@@ -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
packages/quark/src/layout.ts

@@ -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
packages/quark/src/reactivity.ts

@@ -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
packages/quark/src/solid.ts

@@ -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
packages/quark/test/layout.test.ts

@@ -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
packages/quark/test/solid.test.ts

@@ -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
packages/tui/src/routes/session/content.ts

@@ -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 - 40
packages/tui/src/routes/session/index.tsx

@@ -42,9 +42,9 @@ import { useLocal } from "../../context/local"
 import { Locale } from "../../util/locale"
 import { FilePath } from "../../ui/file-path"
 import {
-  canonicalToolName,
   finiteNumber,
   primitiveInputSummary,
+  toolDisplay,
   toolDisplayMetadata,
   webSearchProviderLabel,
 } from "../../util/tool-display"
@@ -1120,24 +1120,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) => (
@@ -1182,7 +1171,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: {
@@ -1298,7 +1287,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
@@ -2949,27 +2938,6 @@ 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>

+ 1 - 1
packages/tui/src/routes/session/permission.tsx

@@ -28,7 +28,7 @@ function usePermissionSource(request: PermissionV2Request) {
   const data = useData()
   const tool = request.source
   if (!tool) return () => ({ input: undefined, structured: undefined })
-  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") {

+ 21 - 0
packages/tui/src/util/tool-display.ts

@@ -19,6 +19,27 @@ export function primitiveInputSummary(input: Record<string, unknown>, omit: read
   return `[${entries.map(([key, value]) => `${key}=${String(value)}`).join(", ")}]`
 }
 
+const toolDisplays = new Set([
+  "shell",
+  "glob",
+  "read",
+  "grep",
+  "webfetch",
+  "websearch",
+  "write",
+  "edit",
+  "subagent",
+  "execute",
+  "patch",
+  "question",
+  "skill",
+])
+
+export function toolDisplay(tool: string) {
+  const normalized = canonicalToolName(tool)
+  return toolDisplays.has(normalized) ? normalized : "generic"
+}
+
 export function webSearchProviderLabel(provider: unknown) {
   if (provider === "parallel") return "Parallel Web Search"
   if (provider === "exa") return "Exa Web Search"

+ 1 - 1
packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx

@@ -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