refactor(tui): simplify quark migration after review

- fix permission input losing reactivity (raw slot read; regression test)
- BackgroundToolHint tracks structure + tool slots instead of whole-collection values
- group views share mapArray-backed usePartSlots (per-ref node reuse)
- dedupe streaming handlers behind variant-scoped modify/insert helpers
- part ID scheme has one definition; hasText required in message navigation
- expose read-only PartsView to view components; misc cleanups
This commit is contained in:
Kit Langton 2026-07-18 00:09:44 -04:00
commit 4027dbb4d8
11 changed files with 379 additions and 196 deletions

View file

@ -7,6 +7,10 @@ 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 },
@ -24,6 +28,7 @@ test("finds the next user message without stopping at an assistant message", ()
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
userOnly: true,
@ -37,6 +42,7 @@ 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,
@ -50,6 +56,7 @@ test("preserves navigation across both user and assistant messages", () => {
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
}),
@ -59,6 +66,7 @@ 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,
}),
@ -71,6 +79,7 @@ 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",
@ -82,6 +91,7 @@ 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",
@ -96,6 +106,7 @@ test("stops at the first and last selected user message", () => {
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
@ -107,6 +118,7 @@ test("stops at the first and last selected user message", () => {
direction: "prev",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
@ -121,6 +133,7 @@ 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",
@ -135,6 +148,7 @@ test("stops at the first and last message", () => {
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
@ -145,6 +159,7 @@ test("stops at the first and last message", () => {
direction: "prev",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",

View file

@ -0,0 +1,115 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { OpenCodeEvent, PermissionV2Request } from "@opencode-ai/client"
import { createEffect, 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 request = {
id: "perm_1",
sessionID,
permission: "shell",
resources: [],
metadata: {},
source: { messageID: "message-assistant", callID: "call-permission" },
time: { created: 1 },
} as unknown as PermissionV2Request
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"
})
} finally {
app.renderer.destroy()
}
})