opencode/packages/voice/test/ui-layout.test.ts
2026-07-28 21:00:35 -04:00

331 lines
12 KiB
TypeScript

import { expect, mock, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
test("voice rows fill the viewport, wrap under content, and keep tools after their preamble", async () => {
const setup = await createTestRenderer({ width: 64, height: 18, useThread: false })
const core = await import("@opentui/core")
void mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const { createVoiceTUI } = await import("../src/ui")
let interrupts = 0
let voiceCycles = 0
let microphoneToggles = 0
let speakerToggles = 0
const ui = await createVoiceTUI({
onInterrupt: () => interrupts++,
onExit: () => {},
onCycleVoice: () => voiceCycles++,
onToggleMicrophone: () => microphoneToggles++,
onToggleSpeaker: () => speakerToggles++,
reducedMotion: true,
})
try {
ui.setStatus({ audio: "duplex", voice: "marin", model: "gpt-live" })
ui.meta("[session] created session-1")
ui.userCommitted("user-1")
ui.userTranscript("user-1", "Please inspect the current session", true)
ui.toolStart("tool-1", "opencode", { request: "inspect" })
ui.meta("[session] adopted session-1")
ui.assistantDelta("Let me check. This sentence is")
ui.assistantDelta(" long enough to wrap cleanly under its column.")
ui.assistantDone()
ui.assistantDelta("Continued in the same assistant row.")
ui.assistantDone()
await Bun.sleep(200)
await setup.flush()
const rows = frameRows(setup.captureCharFrame())
expect(rows).toHaveLength(18)
expect(rows.every((row) => row.length === 64)).toBe(true)
expect(rows[16]).toStartWith(" ▂▂▂▁ duplex marin gpt-live")
expect(rows[16]).not.toContain("no session")
expect(rows[17]).toContain("esc interrupt")
expect(rows[17]).not.toContain("any key")
expect(rows[15].trim()).toBe("")
expect(rows.filter((row) => row.includes("Let me check"))).toHaveLength(1)
expect(rows.findIndex((row) => row.includes("opencode"))).toBeGreaterThan(
rows.findIndex((row) => row.includes("Let me check")),
)
expect(rows.findIndex((row) => row.includes("[session] adopted"))).toBeGreaterThan(
rows.findIndex((row) => row.includes("opencode")),
)
expect(rows.find((row) => row.includes("under its column"))?.indexOf("under")).toBe(
rows.find((row) => row.includes("Let me check"))?.indexOf("Let"),
)
// Deltas inside one turn concatenate raw; a delta after assistantDone starts a new
// message and must gain exactly one space at the boundary.
const assistant = rows.join("")
expect(assistant).toContain("This sentence is long enough")
expect(assistant).toContain("column. Continued")
expect(assistant).not.toContain("column.Continued")
expect(rows.find((row) => row.includes("[session] created"))).toStartWith(" · [session]")
setup.resize(96, 22)
await setup.flush()
const wide = frameRows(setup.captureCharFrame())
expect(wide).toHaveLength(22)
expect(wide.every((row) => row.length === 96)).toBe(true)
expect(wide.at(-2)).toStartWith(" ▂▂▂▁ duplex marin gpt-live")
setup.resize(48, 16)
await setup.flush()
const narrow = frameRows(setup.captureCharFrame())
expect(narrow).toHaveLength(16)
expect(narrow.every((row) => row.length === 48)).toBe(true)
expect(narrow.at(-1)).toContain("esc interrupt")
setup.resize(64, 18)
await setup.flush()
ui.userSpeaking(true)
await setup.flush()
expect(setup.captureCharFrame()).not.toContain("│ transcribing")
expect(frameRows(setup.captureCharFrame()).at(-2)).toStartWith(" ▂▂▂▂")
ui.userReset()
await setup.flush()
expect(setup.captureCharFrame()).not.toContain("│ transcribing")
ui.userSpeaking(true)
ui.userSpeaking(false)
ui.userCommitted("user-2")
ui.userTranscript("user-2", "partial transcript", false)
await setup.flush()
const partial = setup
.captureCharFrame()
.split("\n")
.find((row) => row.includes("partial transcript"))
expect(partial).toContain("│ partial transcript")
expect(partial).not.toContain("you")
ui.userSpeaking(true)
await setup.flush()
expect(
setup
.captureCharFrame()
.split("\n")
.filter((row) => row.includes("│ transcribing")),
).toHaveLength(0)
ui.userReset()
await setup.flush()
ui.setStatus({ microphoneMuted: true })
await setup.flush()
expect(frameRows(setup.captureCharFrame()).at(-2)).toStartWith(" ────")
const muted = setup
.captureCharFrame()
.split("\n")
.find((row) => row.includes("partial transcript"))
expect(muted).toContain("│ partial transcript")
expect(muted).not.toContain("│ ...")
ui.userSpeaking(false)
ui.setStatus({ microphoneMuted: false })
ui.userTranscript("user-2", "partial transcript", true)
await setup.flush()
const complete = setup
.captureCharFrame()
.split("\n")
.find((row) => row.includes("partial transcript"))
expect(complete).toContain("│ partial transcript")
ui.assistantDelta("First response.")
ui.userCommitted("boundary-user")
ui.userTranscript("boundary-user", "A new request", true)
ui.assistantDelta("Second response.")
await setup.flush()
const boundaries = setup.captureCharFrame().split("\n")
expect(boundaries.findIndex((row) => row.includes("First response."))).toBeLessThan(
boundaries.findIndex((row) => row.includes("A new request")),
)
expect(boundaries.findIndex((row) => row.includes("A new request"))).toBeLessThan(
boundaries.findIndex((row) => row.includes("Second response.")),
)
await setup.mockInput.typeText(" x")
setup.mockInput.pressArrow("right")
await setup.flush()
expect(interrupts).toBe(0)
expect(voiceCycles).toBe(0)
expect(microphoneToggles).toBe(0)
expect(speakerToggles).toBe(0)
setup.mockInput.pressKey("v")
setup.mockInput.pressKey("m")
setup.mockInput.pressKey("s")
await setup.flush()
expect(voiceCycles).toBe(1)
expect(microphoneToggles).toBe(1)
expect(speakerToggles).toBe(1)
setup.mockInput.pressEscape()
await Bun.sleep(50)
await setup.flush()
expect(interrupts).toBe(1)
} finally {
ui.close()
mock.restore()
}
})
// In audio mode ui.assistantDone() is scheduled off the AudioSession playback clock,
// so the next turn's transcript deltas arrive while the row is still streaming. The turn boundary
// therefore has to survive in the delta text itself, not in the row's streaming flag.
test("keeps the turn boundary when the next turn streams before playback finishes", async () => {
const setup = await createTestRenderer({ width: 72, height: 12, useThread: false })
const core = await import("@opentui/core")
void mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const { createVoiceTUI } = await import("../src/ui")
const ui = await createVoiceTUI({
onInterrupt: () => {},
onExit: () => {},
onCycleVoice: () => {},
onToggleMicrophone: () => {},
onToggleSpeaker: () => {},
reducedMotion: true,
})
try {
// Verbatim projector deltas: each turn's first fragment carries the boundary space.
ui.assistantDelta(' Session "Fix auth bug"')
ui.assistantDelta(" is ready.")
// turn.done reaches the protocol, but playback is still draining, so no assistantDone() yet.
ui.assistantDelta(" Next up, tests.")
await setup.flush()
const frame = setup.captureCharFrame()
expect(frame).toContain('Session "Fix auth bug" is ready. Next up, tests.')
expect(frame).not.toContain("ready.Next")
// The row itself must not open with the boundary space.
expect(frame).toContain('│ Session "Fix auth bug"')
expect(frame).not.toContain('│ Session "Fix auth bug"')
ui.assistantDone()
await setup.flush()
expect(setup.captureCharFrame()).toContain("is ready. Next up, tests.")
} finally {
ui.close()
mock.restore()
}
})
test("keeps animated text mounted after its reveal completes", async () => {
const setup = await createTestRenderer({ width: 72, height: 10, useThread: false })
const core = await import("@opentui/core")
void mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const { createVoiceTUI } = await import("../src/ui")
const ui = await createVoiceTUI({
onInterrupt: () => {},
onExit: () => {},
onCycleVoice: () => {},
onToggleMicrophone: () => {},
onToggleSpeaker: () => {},
})
try {
ui.assistantDelta("Animated text remains visible.")
await Bun.sleep(500)
await setup.flush()
expect(setup.captureCharFrame()).toContain("Animated text remains visible.")
} finally {
ui.close()
mock.restore()
}
})
test("renders consecutive tools as compact single-spaced rows", async () => {
const setup = await createTestRenderer({ width: 52, height: 8, useThread: false })
const core = await import("@opentui/core")
void mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const { createVoiceTUI } = await import("../src/ui")
const ui = await createVoiceTUI({
onInterrupt: () => {},
onExit: () => {},
onCycleVoice: () => {},
onToggleMicrophone: () => {},
onToggleSpeaker: () => {},
reducedMotion: true,
})
try {
ui.toolStart("tool-1", "read_session", {})
ui.toolDone("tool-1", { status: "ok" })
ui.toolStart("tool-2", "read_session", {})
ui.toolDone("tool-2", { status: "ok" })
await Bun.sleep(200)
await setup.flush()
const rows = frameRows(setup.captureCharFrame())
const tools = rows.flatMap((row, index) => (row.includes("read_session") ? [{ row, index }] : []))
expect(tools).toHaveLength(2)
expect(tools[1]?.index).toBe((tools[0]?.index ?? 0) + 1)
expect(tools.every((tool) => tool.row.includes("✓ read_session ok"))).toBe(true)
expect(rows.at(-3)?.trim()).toBe("")
} finally {
ui.close()
mock.restore()
}
})
test("shows the reason a tool failed", async () => {
const setup = await createTestRenderer({ width: 72, height: 8, useThread: false })
const core = await import("@opentui/core")
void mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const { createVoiceTUI } = await import("../src/ui")
const ui = await createVoiceTUI({
onInterrupt: () => {},
onExit: () => {},
onCycleVoice: () => {},
onToggleMicrophone: () => {},
onToggleSpeaker: () => {},
reducedMotion: true,
})
try {
ui.toolStart("tool-error", "opencode", {})
ui.toolDone("tool-error", { status: "error", message: "Invalid tool schema" })
await Bun.sleep(200)
await setup.flush()
expect(setup.captureCharFrame()).toContain("opencode error · Invalid tool schema")
} finally {
ui.close()
mock.restore()
}
})
test("paints the full viewport and keeps mic activity in the footer", async () => {
const setup = await createTestRenderer({ width: 40, height: 12, useThread: false })
const core = await import("@opentui/core")
void mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const { createVoiceTUI } = await import("../src/ui")
const ui = await createVoiceTUI({
onInterrupt: () => {},
onExit: () => {},
onCycleVoice: () => {},
onToggleMicrophone: () => {},
onToggleSpeaker: () => {},
reducedMotion: true,
})
try {
ui.userSpeaking(true)
await setup.flush()
const rows = frameRows(setup.captureCharFrame())
expect(rows.some((row) => row.includes("│ transcribing"))).toBe(false)
expect(rows.at(-2)).toStartWith(" ▂▂▂▂")
expect(setup.captureSpans().lines.every((line) => line.spans.every((span) => span.bg.a === 1))).toBe(true)
setup.resize(56, 16)
await setup.flush()
expect(setup.captureSpans().lines.every((line) => line.spans.every((span) => span.bg.a === 1))).toBe(true)
} finally {
ui.close()
mock.restore()
}
})
function frameRows(frame: string) {
return frame.split("\n").slice(0, -1)
}