feat(tui): add v2 terminal interface
This commit is contained in:
parent
0e2dd4ad15
commit
e6f660fecf
73 changed files with 3028 additions and 2035 deletions
79
packages/tui/test/cli/cmd/tui/integration-options.test.ts
Normal file
79
packages/tui/test/cli/cmd/tui/integration-options.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { IntegrationInfo } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
connectionSummary,
|
||||
connectMethods,
|
||||
credentialConnections,
|
||||
integrationOptions,
|
||||
} from "../../../../src/component/dialog-integration"
|
||||
|
||||
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
|
||||
methods: [],
|
||||
connections: [],
|
||||
...value,
|
||||
})
|
||||
|
||||
describe("integrationOptions", () => {
|
||||
test("keeps popular integrations first and sorts the rest alphabetically", () => {
|
||||
expect(
|
||||
integrationOptions([
|
||||
integration({ id: "mistral", name: "Mistral" }),
|
||||
integration({ id: "openai", name: "OpenAI" }),
|
||||
integration({ id: "custom-z", name: "Zebra" }),
|
||||
integration({ id: "anthropic", name: "Anthropic" }),
|
||||
]).map((item) => item.id),
|
||||
).toEqual(["openai", "anthropic", "mistral", "custom-z"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("connectMethods", () => {
|
||||
test("offers key and OAuth methods but not environment discovery", () => {
|
||||
expect(
|
||||
connectMethods(
|
||||
integration({
|
||||
id: "example",
|
||||
name: "Example",
|
||||
methods: [
|
||||
{ type: "env", names: ["EXAMPLE_KEY"] },
|
||||
{ type: "key", label: "API key" },
|
||||
{ type: "oauth", id: "account", label: "Account" },
|
||||
],
|
||||
}),
|
||||
).map((method) => method.type),
|
||||
).toEqual(["oauth", "key"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("credentialConnections", () => {
|
||||
test("returns removable credential connections only", () => {
|
||||
expect(
|
||||
credentialConnections(
|
||||
integration({
|
||||
id: "example",
|
||||
name: "Example",
|
||||
connections: [
|
||||
{ type: "env", name: "EXAMPLE_KEY" },
|
||||
{ type: "credential", id: "cred_1", label: "Work" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toEqual([{ type: "credential", id: "cred_1", label: "Work" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("connectionSummary", () => {
|
||||
test("shows credential labels and environment variables", () => {
|
||||
expect(
|
||||
connectionSummary(
|
||||
integration({
|
||||
id: "example",
|
||||
name: "Example",
|
||||
connections: [
|
||||
{ type: "credential", id: "cred_1", label: "Work" },
|
||||
{ type: "env", name: "EXAMPLE_KEY" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe("Work, $EXAMPLE_KEY")
|
||||
})
|
||||
})
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = `
|
||||
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
|
|
@ -14,12 +13,10 @@ exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read row
|
|||
exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = `
|
||||
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
No LSP server available for this file type.
|
||||
|
||||
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
||||
`;
|
||||
|
|
@ -33,7 +30,6 @@ exports[`TUI inline tool wrapping keeps separation after a shell output block 1`
|
|||
|
||||
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
|
|
@ -48,7 +44,6 @@ exports[`TUI inline tool wrapping keeps separation after a padded user message 1
|
|||
|
||||
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
|
||||
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
|
||||
|
||||
✱ Glob "**/*db*" in packages/opencode (6 matches)
|
||||
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
|
||||
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
|
||||
|
|
@ -76,7 +71,7 @@ exports[`TUI inline tool wrapping separates a task row from a preceding inline d
|
|||
`;
|
||||
|
||||
exports[`TUI inline tool wrapping separates an inline row from the previous assistant summary 1`] = `
|
||||
" ▣ Build · Little Frank · 53.1s
|
||||
" Build · Little Frank · 53.1s
|
||||
|
||||
✓ Build Task — Review changes
|
||||
↳ 48 toolcalls · 1m 40s"
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ test("refreshes resources into reactive getters", async () => {
|
|||
location: { directory },
|
||||
},
|
||||
})
|
||||
if (url.pathname === "/api/session/ses_test/message")
|
||||
return json({
|
||||
data: [
|
||||
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
|
||||
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({
|
||||
location,
|
||||
|
|
@ -60,7 +68,7 @@ test("refreshes resources into reactive getters", async () => {
|
|||
function Probe() {
|
||||
data = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
return <text>{data.session.message.get("ses_test", "msg_second")?.id ?? "missing"}</text>
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
|
|
@ -82,9 +90,14 @@ test("refreshes resources into reactive getters", async () => {
|
|||
expect(data.location.agent.list(location)).toBeUndefined()
|
||||
|
||||
await data.session.refresh("ses_test")
|
||||
await data.session.message.refresh("ses_test")
|
||||
await data.location.agent.refresh()
|
||||
|
||||
expect(data.session.get("ses_test")?.title).toBe("Test session")
|
||||
expect(data.session.message.ids("ses_test")).toEqual(["msg_first", "msg_second"])
|
||||
expect(data.session.message.get("ses_test", "msg_second")?.id).toBe("msg_second")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("msg_second")
|
||||
expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
|
||||
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
|
||||
} finally {
|
||||
|
|
@ -92,6 +105,74 @@ test("refreshes resources into reactive getters", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("reconnects the event stream and bootstraps fresh data", async () => {
|
||||
const events = createEventSource()
|
||||
const requests = { event: 0, model: 0 }
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/event") {
|
||||
requests.event++
|
||||
return events.response()
|
||||
}
|
||||
if (url.pathname !== "/api/model") return
|
||||
requests.model++
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory } },
|
||||
data: [
|
||||
{
|
||||
id: `model-${requests.model}`,
|
||||
providerID: "provider",
|
||||
name: `Model ${requests.model}`,
|
||||
api: { type: "native" },
|
||||
capabilities: { tools: false, input: [], output: [] },
|
||||
cost: [],
|
||||
limit: { context: 1, output: 1 },
|
||||
request: { headers: {}, body: {} },
|
||||
status: "active",
|
||||
time: { released: 0 },
|
||||
variants: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
|
||||
expect(data.connection.status()).toBe("connected")
|
||||
expect(data.connection.attempt()).toBe(0)
|
||||
|
||||
events.disconnect()
|
||||
await wait(() => data.connection.status() === "reconnecting")
|
||||
expect(data.connection.attempt()).toBe(1)
|
||||
expect(data.connection.error()).toBe("Event stream disconnected")
|
||||
|
||||
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
||||
expect(requests.event).toBe(2)
|
||||
expect(data.connection.status()).toBe("connected")
|
||||
expect(data.connection.attempt()).toBe(0)
|
||||
expect(data.connection.error()).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("refreshes integrations after integration updates", async () => {
|
||||
const events = createEventSource()
|
||||
const requests = { integration: 0, model: 0, provider: 0 }
|
||||
|
|
@ -241,6 +322,134 @@ test("refreshes references after updates", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("adds and dismisses permission requests from live events", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch(undefined, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => data.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_asked_1",
|
||||
type: "permission.v2.asked",
|
||||
properties: {
|
||||
id: "per_1",
|
||||
sessionID: "ses_1",
|
||||
action: "bash",
|
||||
resources: ["bun test"],
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_asked_2",
|
||||
type: "permission.v2.asked",
|
||||
properties: {
|
||||
id: "per_2",
|
||||
sessionID: "ses_1",
|
||||
action: "read",
|
||||
resources: [".env"],
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.permission.list("ses_1")?.length === 2)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_replied_1",
|
||||
type: "permission.v2.replied",
|
||||
properties: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
|
||||
})
|
||||
await wait(() => data.session.permission.list("ses_1")?.length === 1)
|
||||
expect(data.session.permission.list("ses_1")?.[0]?.id).toBe("per_2")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_permission_replied_2",
|
||||
type: "permission.v2.replied",
|
||||
properties: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
|
||||
})
|
||||
await wait(() => data.session.permission.list("ses_1")?.length === 0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("adds and dismisses question requests from live events", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch(undefined, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => data.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_question_asked_1",
|
||||
type: "question.v2.asked",
|
||||
properties: {
|
||||
id: "que_1",
|
||||
sessionID: "ses_1",
|
||||
questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_question_asked_2",
|
||||
type: "question.v2.asked",
|
||||
properties: {
|
||||
id: "que_2",
|
||||
sessionID: "ses_1",
|
||||
questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.question.list("ses_1")?.length === 2)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_question_replied_1",
|
||||
type: "question.v2.replied",
|
||||
properties: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
|
||||
})
|
||||
await wait(() => data.session.question.list("ses_1")?.length === 1)
|
||||
expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_question_rejected_2",
|
||||
type: "question.v2.rejected",
|
||||
properties: { sessionID: "ses_1", requestID: "que_2" },
|
||||
})
|
||||
await wait(() => data.session.question.list("ses_1")?.length === 0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventSource()
|
||||
const calls = createFetch(undefined, events)
|
||||
|
|
@ -334,7 +543,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
})
|
||||
|
||||
await wait(() => {
|
||||
const assistant = sync.session.message.list("session-1")?.[0]
|
||||
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
|
||||
return (
|
||||
assistant?.type === "assistant" &&
|
||||
assistant.content[0]?.type === "tool" &&
|
||||
|
|
@ -342,7 +551,7 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
)
|
||||
})
|
||||
|
||||
const assistant = sync.session.message.list("session-1")?.[0]
|
||||
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
|
||||
expect(assistant?.type).toBe("assistant")
|
||||
if (assistant?.type !== "assistant") return
|
||||
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
||||
|
|
@ -360,10 +569,10 @@ test("settles pending tools when a live failure arrives", async () => {
|
|||
metadata: { fake: { call: true } },
|
||||
resultMetadata: { fake: { result: true } },
|
||||
})
|
||||
expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([
|
||||
"assistant",
|
||||
"model-switched",
|
||||
expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"assistant",
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
|
@ -399,6 +608,8 @@ test("renders admitted prompts only after they become model-visible", async () =
|
|||
|
||||
try {
|
||||
await mounted
|
||||
const received: string[] = []
|
||||
const unsubscribe = sync.listen((event) => received.push(event.name))
|
||||
emitEvent(events, {
|
||||
id: "evt_admitted_1",
|
||||
type: "session.next.prompt.admitted",
|
||||
|
|
@ -425,10 +636,17 @@ test("renders admitted prompts only after they become model-visible", async () =
|
|||
})
|
||||
|
||||
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||
expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"])
|
||||
unsubscribe()
|
||||
const message = sync.session.message.list("session-1")?.[0]
|
||||
expect(message?.type).toBe("user")
|
||||
if (message?.type !== "user") return
|
||||
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
|
||||
expect(sync.session.message.ids("session-1")).toEqual(["msg_user_1"])
|
||||
expect(sync.session.message.ids("missing")).toEqual([])
|
||||
expect(sync.session.message.get("session-1", "msg_user_1")).toBe(message)
|
||||
expect(sync.session.message.get("session-1", "missing")).toBeUndefined()
|
||||
expect(received).toHaveLength(3)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { For } from "solid-js"
|
||||
import { testRender, type JSX } from "@opentui/solid"
|
||||
import {
|
||||
formatCompletedSubagentDetail,
|
||||
|
|
@ -13,7 +12,6 @@ import {
|
|||
parseQuestionAnswers,
|
||||
parseQuestions,
|
||||
parseTodos,
|
||||
alwaysSeparate,
|
||||
toolDisplay,
|
||||
} from "../../../src/routes/session"
|
||||
|
||||
|
|
@ -52,40 +50,10 @@ const tools: readonly ToolFixture[] = [
|
|||
},
|
||||
] as const
|
||||
|
||||
function ShellOutput() {
|
||||
return (
|
||||
<box
|
||||
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
|
||||
marginTop={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
gap={1}
|
||||
>
|
||||
<box gap={1}>
|
||||
<text>$ ls</text>
|
||||
<text>file.ts</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function UserMessage() {
|
||||
return (
|
||||
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2}>
|
||||
<text>Check whether the next tool remains separated.</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
|
||||
function Fixture(props: { errorExpanded?: boolean }) {
|
||||
return (
|
||||
<box flexDirection="column" width={72}>
|
||||
<box flexDirection="column">
|
||||
{props.before === "shell" && <ShellOutput />}
|
||||
{props.before === "user" && <UserMessage />}
|
||||
<For each={tools}>
|
||||
{(item) => (
|
||||
<InlineToolRow
|
||||
|
|
@ -105,94 +73,6 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" })
|
|||
)
|
||||
}
|
||||
|
||||
function TaskRowsFixture() {
|
||||
return (
|
||||
<box flexDirection="column" width={72}>
|
||||
<InlineToolRow icon="✱" complete={true} pending="">
|
||||
Grep "Task" (2 matches)
|
||||
</InlineToolRow>
|
||||
<InlineToolRow icon="⠙" complete={true} pending="" separate={true}>
|
||||
Explore Task — Inspect active task spacing
|
||||
</InlineToolRow>
|
||||
<InlineToolRow icon="✓" complete={true} pending="" separate={true}>
|
||||
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
|
||||
</InlineToolRow>
|
||||
<InlineToolRow icon="→" complete={true} pending="">
|
||||
Read src/cli/cmd/tui/routes/session/index.tsx
|
||||
</InlineToolRow>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadedReadBeforeTaskFixture() {
|
||||
return (
|
||||
<box flexDirection="column" width={72}>
|
||||
<InlineToolRow icon="→" complete={true} pending="">
|
||||
Read src/cli/cmd/tui/routes/session/index.tsx
|
||||
</InlineToolRow>
|
||||
<box paddingLeft={3}>
|
||||
<text paddingLeft={3}>↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
|
||||
</box>
|
||||
<InlineToolRow icon="✓" complete={true} pending="" separate={true}>
|
||||
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
|
||||
</InlineToolRow>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantSummaryBeforeInlineFixture() {
|
||||
return (
|
||||
<box flexDirection="column" width={72}>
|
||||
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
|
||||
<text>▣ Build · Little Frank · 53.1s</text>
|
||||
</box>
|
||||
<InlineToolRow icon="✓" complete={true} pending="">
|
||||
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
|
||||
</InlineToolRow>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantErrorBeforeInlineFixture() {
|
||||
return (
|
||||
<box flexDirection="column" width={72}>
|
||||
<box
|
||||
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
>
|
||||
<text>Managed inference requires an active Member plan</text>
|
||||
</box>
|
||||
<InlineToolRow icon="✓" complete={true} pending="">
|
||||
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
|
||||
</InlineToolRow>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) {
|
||||
return (
|
||||
<scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" height={3} width={72}>
|
||||
<box height={1}>
|
||||
<text>First row</text>
|
||||
</box>
|
||||
<box height={1}>
|
||||
<text>Second row</text>
|
||||
</box>
|
||||
<Show when={props.separated}>
|
||||
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
|
||||
<text>Assistant text</text>
|
||||
</box>
|
||||
</Show>
|
||||
<InlineToolRow icon="→" complete={true} pending="">
|
||||
Read src/cli/cmd/tui/routes/session/index.tsx
|
||||
</InlineToolRow>
|
||||
</scrollbox>
|
||||
)
|
||||
}
|
||||
|
||||
function FailedPendingToolFixture() {
|
||||
return (
|
||||
<InlineToolRow icon="%" complete={false} pending="Preparing patch..." failed={true} failure="Patch failed">
|
||||
|
|
@ -245,10 +125,18 @@ describe("TUI inline tool wrapping", () => {
|
|||
parseApplyPatchFiles([
|
||||
null,
|
||||
{ type: "add" },
|
||||
{ type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0 },
|
||||
{ file: "a.ts", patch: "diff", additions: 1, deletions: 0, status: "added" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0, movePath: undefined },
|
||||
{
|
||||
type: "add",
|
||||
relativePath: "a.ts",
|
||||
filePath: "a.ts",
|
||||
patch: "diff",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
movePath: undefined,
|
||||
},
|
||||
])
|
||||
expect(parseTodos([null, { status: "pending" }, { status: "pending", content: "Safe" }])).toEqual([
|
||||
{ status: "pending", content: "Safe" },
|
||||
|
|
@ -299,53 +187,4 @@ describe("TUI inline tool wrapping", () => {
|
|||
expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("keeps separation after a shell output block", async () => {
|
||||
expect(await renderFrame(() => <Fixture before="shell" />, { width: 72, height: 16 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("keeps separation after a padded user message", async () => {
|
||||
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("separates after a multi-line task row", async () => {
|
||||
expect(await renderFrame(() => <TaskRowsFixture />, { width: 72, height: 10 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("separates a task row from a preceding inline detail", async () => {
|
||||
expect(await renderFrame(() => <LoadedReadBeforeTaskFixture />, { width: 72, height: 8 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("separates an inline row from the previous assistant summary", async () => {
|
||||
expect(await renderFrame(() => <AssistantSummaryBeforeInlineFixture />, { width: 72, height: 5 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("separates an inline row from the previous assistant error", async () => {
|
||||
expect(await renderFrame(() => <AssistantErrorBeforeInlineFixture />, { width: 72, height: 7 })).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => {
|
||||
const [separated, setSeparated] = createSignal(false)
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
testSetup = await testRender(
|
||||
() => <StickyScrollFixture separated={separated()} scroll={(value) => (scroll = value)} />,
|
||||
{
|
||||
width: 72,
|
||||
height: 3,
|
||||
},
|
||||
)
|
||||
|
||||
await testSetup.renderOnce()
|
||||
expect(scroll?.scrollHeight).toBe(3)
|
||||
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
|
||||
|
||||
setSeparated(true)
|
||||
await testSetup.renderOnce()
|
||||
expect(scroll?.scrollHeight).toBe(5)
|
||||
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
|
||||
|
||||
setSeparated(false)
|
||||
await testSetup.renderOnce()
|
||||
expect(scroll?.scrollHeight).toBe(3)
|
||||
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
105
packages/tui/test/cli/tui/session-rows.test.ts
Normal file
105
packages/tui/test/cli/tui/session-rows.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
|
||||
import { reduceSessionRows } from "../../../src/routes/session/rows"
|
||||
|
||||
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", id: "text-1", text: "Looking" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
{ type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
|
||||
{ type: "text", id: "text-2", text: "Done" },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text-1" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
refs: [
|
||||
{ messageID: "assistant-1", partID: "read-1" },
|
||||
{ messageID: "assistant-1", partID: "glob-1" },
|
||||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text-2" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps non-exploration tools as individual part rows", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
|
||||
{ type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } },
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "bash-1" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
refs: [{ messageID: "assistant-1", partID: "grep-1" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("groups across empty assistant reasoning parts", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "reasoning", id: "reasoning-1", text: "Looking" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
]),
|
||||
assistant("assistant-2", [
|
||||
{ type: "reasoning", id: "reasoning-2", text: "" },
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
]),
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
refs: [
|
||||
{ messageID: "assistant-1", partID: "read-1" },
|
||||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
|
||||
return {
|
||||
type: "assistant",
|
||||
id,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content,
|
||||
time: { created: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function pending() {
|
||||
return { status: "pending" as const, input: "" }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue