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