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:
parent
4027dbb4d8
commit
c0ce124f8c
12 changed files with 374 additions and 98 deletions
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue