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 10:28:36 -04:00
commit c0ce124f8c
12 changed files with 374 additions and 98 deletions

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}`

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>

View file

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

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"

View file

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