From 3a4d5e5d81eff27e6254af83921571c444fede27 Mon Sep 17 00:00:00 2001 From: leoncheng57 Date: Thu, 26 Feb 2026 00:20:53 -0500 Subject: [PATCH] feat: implement copy command with options This commit includes: - Add selective copy dialog for User msgs and AI responses - Expand copy dialog to support both user and assistant messages - Fix scrolling issues and improve type safety --- .../src/cli/cmd/tui/routes/session/index.tsx | 47 ++ .../cli/cmd/tui/ui/dialog-copy-messages.tsx | 247 +++++++ .../src/cli/cmd/tui/util/transcript.ts | 15 +- .../test/cli/tui/dialog-copy-messages.test.ts | 133 ++++ .../cli/tui/session-copy-with-options.test.ts | 617 ++++++++++++++++++ .../opencode/test/cli/tui/transcript.test.ts | 8 +- 6 files changed, 1058 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/ui/dialog-copy-messages.tsx create mode 100644 packages/opencode/test/cli/tui/dialog-copy-messages.test.ts create mode 100644 packages/opencode/test/cli/tui/session-copy-with-options.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f20267e0820..34704537684 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -76,6 +76,7 @@ import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" +import { DialogCopyMessages } from "../../ui/dialog-copy-messages" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" @@ -812,6 +813,7 @@ export function Session() { thinking: showThinking(), toolDetails: showDetails(), assistantMetadata: showAssistantMetadata(), + includeMetadata: true, }, ) await Clipboard.copy(transcript) @@ -822,6 +824,50 @@ export function Session() { dialog.clear() }, }, + { + title: "Copy selective session messages with options", + value: "session.copyWithOptions", + category: "Session", + slash: { + name: "copy-with-options", + }, + onSelect: async (dialog) => { + try { + const sessionData = session() + if (!sessionData) return + const sessionMessages = messages() + + const allMessages = sessionMessages.map((msg) => ({ + info: msg as AssistantMessage | UserMessage, + parts: sync.data.part[msg.id] ?? [], + })) + + if (allMessages.length === 0) { + toast.show({ message: "No messages to copy", variant: "info" }) + dialog.clear() + return + } + + const selectedMessages = await DialogCopyMessages.show(dialog, allMessages) + if (!selectedMessages || selectedMessages.length === 0) { + dialog.clear() + return + } + + const transcript = formatTranscript(sessionData, selectedMessages, { + thinking: true, + toolDetails: true, + assistantMetadata: true, + includeMetadata: false, + }) + await Clipboard.copy(transcript) + toast.show({ message: `${selectedMessages.length} message(s) copied to clipboard!`, variant: "success" }) + } catch (error) { + toast.show({ message: "Failed to copy messages", variant: "error" }) + } + dialog.clear() + }, + }, { title: "Export session transcript", value: "session.export", @@ -856,6 +902,7 @@ export function Session() { thinking: options.thinking, toolDetails: options.toolDetails, assistantMetadata: options.assistantMetadata, + includeMetadata: true, }, ) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-copy-messages.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-copy-messages.tsx new file mode 100644 index 00000000000..f3fe6fa8766 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-copy-messages.tsx @@ -0,0 +1,247 @@ +import { TextAttributes, ScrollBoxRenderable } from "@opentui/core" +import { useDialog, type DialogContext } from "./dialog" +import { createStore } from "solid-js/store" +import { For, createMemo, onMount, Show } from "solid-js" +import { useTheme } from "../context/theme" +import { useKeyboard } from "@opentui/solid" +import { Locale } from "@/util/locale" +import type { AssistantMessage, UserMessage, Part, TextPart } from "@opencode-ai/sdk/v2" + +// Internal representation of a single message with its metadata and content parts +type Message = { + info: AssistantMessage | UserMessage + parts: (Part | TextPart)[] +} + +// Display option for each message in the checklist +type Option = { + id: string + title: string + footerNode: any +} + +export type DialogCopyMessagesProps = { + messages: Message[] + onConfirm: (selected: Message[]) => void + onCancel?: () => void +} + +export function DialogCopyMessages(props: DialogCopyMessagesProps) { + const dialog = useDialog() + const { theme } = useTheme() + const [store, setStore] = createStore({ + selected: new Set(), // Set of currently selected message IDs + scrollIndex: 0, // Index of currently highlighted item in the list + warningNoMessagesSelected: false, // Show warning when confirm is pressed with no selection + }) + + let scroll: ScrollBoxRenderable | undefined + + // Set dialog to large size + onMount(() => { + dialog.setSize("large") + }) + + // Transform raw messages into display-friendly options + const options = createMemo(() => + props.messages.map((msg, i) => { + // Extract text content: filter out synthetic parts, and limit to first 60 chars + const textContent = msg.parts + .filter((p): p is TextPart => p.type === "text" && !p.synthetic) + .map((p) => p.text) + .join("") + .slice(0, 60) + + const isAssistant = msg.info.role === "assistant" + + // Get source identifier with color (model name for assistant, "user" for user messages) + const modelName = isAssistant ? (msg.info as AssistantMessage).modelID : "user" + const modelColor = isAssistant ? theme.primary : theme.secondary + + // Calculate execution duration for assistant messages + const duration = isAssistant + ? Locale.duration( + (msg.info as AssistantMessage).time.completed + ? (msg.info as AssistantMessage).time.completed! - (msg.info as AssistantMessage).time.created + : 0, + ) + : "" + + // Show absolute timestamp for user messages + const userTimestamp = !isAssistant ? new Date(msg.info.time.created).toLocaleTimeString() : "" + + return { + id: msg.info.id, + title: textContent || "(empty)", + footerNode: ( + + {modelName} + {duration && {` · ${duration}`}} + {userTimestamp && {` · ${userTimestamp}`}} + + ), + } + }), + ) + + // Flat list of all options (no grouping) + const flatOptions = createMemo((): Option[] => options()) + + // Toggle selection state for a message + function toggle(id: string) { + const newSelected = new Set(store.selected) + if (newSelected.has(id)) { + newSelected.delete(id) + } else { + newSelected.add(id) + } + setStore("selected", newSelected) + } + + // Navigate through list with wrapping (loops to end when at start and vice versa) + function move(offset: number) { + const opts = flatOptions() + let next = store.scrollIndex + offset + if (next < 0) next = opts.length - 1 // Wrap to end + if (next >= opts.length) next = 0 // Wrap to start + setStore("scrollIndex", next) + scrollToActive() + } + + // Scroll to keep active item visible + function scrollToActive() { + if (!scroll) return + const children = scroll.getChildren() + if (children.length === 0) return + const target = children[store.scrollIndex] + if (!target) return + const y = target.y - scroll.y + // Keep item visible: scroll down if item is below viewport, up if above + if (y >= scroll.height) { + scroll.scrollBy(y - scroll.height + 1) + } else if (y < 0) { + scroll.scrollBy(y) + } + } + + // Confirm selection: filter messages by selected IDs and invoke callback + function confirm() { + const selected = props.messages.filter((m) => store.selected.has(m.info.id)) + props.onConfirm(selected) + } + + // Show warning and auto-dismiss after 2 seconds + function showwarningNoMessagesSelected() { + setStore("warningNoMessagesSelected", true) + setTimeout(() => setStore("warningNoMessagesSelected", false), 2000) + } + + // Handle keyboard input for navigation and actions + useKeyboard((evt) => { + if (evt.name === "up") move(-1) // Navigate up + if (evt.name === "down") move(1) // Navigate down + if (evt.name === "space") { + // Toggle selection of currently highlighted item + const opt = flatOptions()[store.scrollIndex] + if (opt) toggle(opt.id) + evt.preventDefault() + } + if (evt.name === "return") { + // Confirm and copy selected messages (only if at least one is selected) + if (store.selected.size > 0) confirm() + else showwarningNoMessagesSelected() + } + if (evt.name === "a" && evt.ctrl) { + // Select all messages (Ctrl+A) + const allIds = new Set(flatOptions().map((o) => o.id)) + setStore("selected", allIds) + } + }) + + return ( + + {/* Header with title and close hint */} + + + Copy Messages + + dialog.clear()}> + esc + + + + {/* Status line showing selection count or warning message */} + Please select at least one message} + > + Select messages to copy ({store.selected.size} selected) + + + {/* Scrollable list of messages */} + (scroll = r)}> + + {(opt, i) => { + const msgIndex = createMemo(() => props.messages.findIndex((m) => m.info.id === opt.id)) + const isSelected = createMemo(() => store.selected.has(opt.id)) + const isActive = createMemo(() => msgIndex() === store.scrollIndex) + + return ( + toggle(opt.id)} + // Hover to change active item + onMouseOver={() => setStore("scrollIndex", msgIndex())} + > + {/* Checkbox indicator */} + {isSelected() ? "[x]" : "[ ]"} + {/* Message preview (title) */} + + {opt.title} + + {/* Model/duration info */} + {opt.footerNode} + + ) + }} + + + + {/* Keyboard shortcuts legend */} + + + space toggle + + + ctrl+a all + + + return copy + + + + ) +} + +// Static helper method to show this dialog and get selected messages +// Returns: Promise resolving to selected messages array or null if cancelled +DialogCopyMessages.show = (dialog: DialogContext, messages: Message[]): Promise => { + return new Promise((resolve) => { + dialog.replace( + () => ( + resolve(selected)} // Resolve with selected messages + onCancel={() => resolve(null)} // Resolve with null on cancel + /> + ), + () => resolve(null), // Close callback: resolve with null + ) + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/util/transcript.ts b/packages/opencode/src/cli/cmd/tui/util/transcript.ts index 420c9dde1bf..af0560e4c34 100644 --- a/packages/opencode/src/cli/cmd/tui/util/transcript.ts +++ b/packages/opencode/src/cli/cmd/tui/util/transcript.ts @@ -2,6 +2,7 @@ import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "@/util/locale" export type TranscriptOptions = { + includeMetadata: boolean thinking: boolean toolDetails: boolean assistantMetadata: boolean @@ -26,11 +27,15 @@ export function formatTranscript( messages: MessageWithParts[], options: TranscriptOptions, ): string { - let transcript = `# ${session.title}\n\n` - transcript += `**Session ID:** ${session.id}\n` - transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n` - transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n` - transcript += `---\n\n` + let transcript = `` + + if (options.includeMetadata) { + transcript += `# ${session.title}\n\n` + transcript += `**Session ID:** ${session.id}\n` + transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n` + transcript += `**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n` + transcript += `---\n\n` + } for (const msg of messages) { transcript += formatMessage(msg.info, msg.parts, options) diff --git a/packages/opencode/test/cli/tui/dialog-copy-messages.test.ts b/packages/opencode/test/cli/tui/dialog-copy-messages.test.ts new file mode 100644 index 00000000000..501ec2c2ef1 --- /dev/null +++ b/packages/opencode/test/cli/tui/dialog-copy-messages.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test" +import type { AssistantMessage, UserMessage, Part, TextPart } from "@opencode-ai/sdk/v2" + +type Message = { info: AssistantMessage | UserMessage; parts: Part[] } + +describe("DialogCopyMessages", () => { + describe("selection state management", () => { + test("should toggle and manage message selection with Set", () => { + const selected = new Set() + + selected.add("msg_1") + expect(selected.has("msg_1")).toBe(true) + expect(selected.size).toBe(1) + + selected.add("msg_2") + expect(selected.has("msg_1")).toBe(true) + expect(selected.has("msg_2")).toBe(true) + expect(selected.size).toBe(2) + + selected.delete("msg_1") + expect(selected.has("msg_1")).toBe(false) + expect(selected.has("msg_2")).toBe(true) + expect(selected.size).toBe(1) + }) + + test("should support select-all pattern", () => { + const messageIds = ["msg_1", "msg_2", "msg_3", "msg_4"] + const selected = new Set(messageIds) + + expect(selected.size).toBe(4) + messageIds.forEach((id) => expect(selected.has(id)).toBe(true)) + }) + + test("should start empty", () => { + const selected = new Set() + expect(selected.size).toBe(0) + }) + }) + + describe("part filtering logic", () => { + test("should filter synthetic parts from text content", () => { + const parts: Part[] = [ + { id: "p1", type: "text", text: "Real content", synthetic: false } as any, + { id: "p2", type: "text", text: "Synthetic", synthetic: true } as any, + ] + + const realText = parts + .filter((p): p is TextPart => p.type === "text" && !p.synthetic) + .map((p) => p.text) + .join("") + + expect(realText).toBe("Real content") + }) + + test("should handle mixed part types (text and tool)", () => { + const parts: Part[] = [ + { id: "p1", type: "text", text: "Output" } as any, + { id: "p2", type: "tool", tool: "bash" } as any, + ] + + const textParts = parts.filter((p) => p.type === "text") + const toolParts = parts.filter((p) => p.type === "tool") + + expect(textParts).toHaveLength(1) + expect(toolParts).toHaveLength(1) + }) + }) + + describe("navigation wrapping", () => { + test("should wrap forward and backward through list", () => { + const listSize = 3 + let index = 0 + + const moveForward = () => { + index = (index + 1) % listSize + } + + const moveBackward = () => { + index = (index - 1 + listSize) % listSize + } + + // Forward navigation + moveForward() + expect(index).toBe(1) + moveForward() + expect(index).toBe(2) + moveForward() + expect(index).toBe(0) + + // Backward navigation + moveBackward() + expect(index).toBe(2) + moveBackward() + expect(index).toBe(1) + }) + }) + + describe("message filtering and ordering", () => { + test("should filter selected messages while preserving order", () => { + const messages = [ + { info: { id: "msg_1", role: "user" as const } }, + { info: { id: "msg_2", role: "assistant" as const } }, + { info: { id: "msg_3", role: "user" as const } }, + { info: { id: "msg_4", role: "assistant" as const } }, + ] as Message[] + + const selected = new Set(["msg_1", "msg_3", "msg_4"]) + const filtered = messages.filter((m) => selected.has(m.info.id)) + + expect(filtered).toHaveLength(3) + expect(filtered.map((m) => m.info.id)).toEqual(["msg_1", "msg_3", "msg_4"]) + }) + + test("should handle empty selection returning no messages", () => { + const messages = [{ info: { id: "msg_1" } }, { info: { id: "msg_2" } }] as Message[] + + const selected = new Set() + const filtered = messages.filter((m) => selected.has(m.info.id)) + + expect(filtered).toHaveLength(0) + }) + + test("should require non-empty selection to confirm", () => { + const selected = new Set() + const canConfirm = selected.size > 0 + + expect(canConfirm).toBe(false) + + selected.add("msg_1") + expect(selected.size > 0).toBe(true) + }) + }) +}) diff --git a/packages/opencode/test/cli/tui/session-copy-with-options.test.ts b/packages/opencode/test/cli/tui/session-copy-with-options.test.ts new file mode 100644 index 00000000000..1b6545b1519 --- /dev/null +++ b/packages/opencode/test/cli/tui/session-copy-with-options.test.ts @@ -0,0 +1,617 @@ +import { describe, expect, test, beforeEach, mock } from "bun:test" +import type { AssistantMessage, UserMessage, Part } from "@opencode-ai/sdk/v2" + +// Mock types and interfaces +type Message = { + info: AssistantMessage | UserMessage + parts: Part[] +} + +type SessionData = { + id: string + title: string +} + +type ToastOptions = { + message: string + variant: "success" | "error" | "info" | "warning" +} + +describe("session.copyWithOptions command", () => { + let dialog: any + let toast: any + let session: () => SessionData | undefined + let messages: () => (AssistantMessage | UserMessage)[] + let sync: any + let clipboard: any + let formatTranscript: any + let dialogCopyMessages: any + + beforeEach(() => { + dialog = { clear: mock(() => {}) } + toast = { show: mock((options: ToastOptions) => {}) } + clipboard = { copy: mock(async (text: string) => {}) } + formatTranscript = mock((data, msgs, opts) => "formatted transcript") + dialogCopyMessages = { + show: mock(async (d, msgs) => msgs.slice(0, 1)), + } + }) + + describe("initial validation", () => { + test("should handle missing session data gracefully", async () => { + session = () => undefined + messages = () => [] + sync = { data: { part: {} } } + + // Simulate command onSelect + const sessionData = session() + if (!sessionData) { + expect(sessionData).toBeUndefined() + } + }) + + test("should show info toast when no messages available", async () => { + session = () => ({ id: "ses_123", title: "Test" }) + messages = () => [] + sync = { data: { part: {} } } + + const sessionData = session() + const sessionMessages = messages() + const allMessages = sessionMessages.map((msg) => ({ + info: msg, + parts: sync.data.part[(msg as any).id] ?? [], + })) + + if (allMessages.length === 0) { + toast.show({ message: "No messages to copy", variant: "info" }) + } + + expect(toast.show).toHaveBeenCalledWith( + expect.objectContaining({ + message: "No messages to copy", + variant: "info", + }), + ) + }) + + test("should clear dialog when no messages available", async () => { + session = () => ({ id: "ses_123", title: "Test" }) + messages = () => [] + sync = { data: { part: {} } } + + const sessionData = session() + const sessionMessages = messages() + + if (sessionMessages.length === 0) { + dialog.clear() + } + + expect(dialog.clear).toHaveBeenCalled() + }) + }) + + describe("message collection", () => { + test("should collect all session messages with their parts", async () => { + const msgs: (AssistantMessage | UserMessage)[] = [ + { + id: "msg_1", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1000000 }, + } as UserMessage, + { + id: "msg_2", + sessionID: "ses_123", + role: "assistant", + agent: "build", + modelID: "claude-sonnet", + providerID: "anthropic", + mode: "", + parentID: "msg_1", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000000, completed: 1005400 }, + } as AssistantMessage, + ] + + session = () => ({ id: "ses_123", title: "Test" }) + messages = () => msgs + sync = { + data: { + part: { + msg_1: [{ id: "p1", type: "text", text: "User input" }], + msg_2: [{ id: "p2", type: "text", text: "Assistant response" }], + }, + }, + } + + const sessionData = session() + const sessionMessages = messages() + const allMessages = sessionMessages.map((msg) => ({ + info: msg, + parts: sync.data.part[msg.id] ?? [], + })) + + expect(allMessages).toHaveLength(2) + expect(allMessages[0].parts).toHaveLength(1) + expect(allMessages[1].parts).toHaveLength(1) + }) + + test("should handle messages without parts", async () => { + const msgs: (AssistantMessage | UserMessage)[] = [ + { + id: "msg_1", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1000000 }, + } as UserMessage, + ] + + session = () => ({ id: "ses_123", title: "Test" }) + messages = () => msgs + sync = { data: { part: {} } } + + const sessionData = session() + const sessionMessages = messages() + const allMessages = sessionMessages.map((msg) => ({ + info: msg, + parts: sync.data.part[msg.id] ?? [], + })) + + expect(allMessages[0].parts).toEqual([]) + }) + }) + + describe("dialog interaction", () => { + test("should show dialog and wait for message selection", async () => { + const msgs: (AssistantMessage | UserMessage)[] = [ + { + id: "msg_1", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1000000 }, + } as UserMessage, + ] + + session = () => ({ id: "ses_123", title: "Test" }) + messages = () => msgs + sync = { data: { part: { msg_1: [] } } } + + const sessionData = session() + const sessionMessages = messages() + const allMessages = sessionMessages.map((msg) => ({ + info: msg, + parts: sync.data.part[msg.id] ?? [], + })) + + const selectedMessages = await dialogCopyMessages.show(dialog, allMessages) + + expect(dialogCopyMessages.show).toHaveBeenCalledWith(dialog, expect.any(Array)) + expect(selectedMessages).toBeDefined() + }) + + test("should clear dialog when user cancels (no selection)", async () => { + const msgs: (AssistantMessage | UserMessage)[] = [ + { + id: "msg_1", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1000000 }, + } as UserMessage, + ] + + session = () => ({ id: "ses_123", title: "Test" }) + messages = () => msgs + sync = { data: { part: { msg_1: [] } } } + dialogCopyMessages.show = mock(async () => null) + + const sessionData = session() + const sessionMessages = messages() + const allMessages = sessionMessages.map((msg) => ({ + info: msg, + parts: sync.data.part[msg.id] ?? [], + })) + + const selectedMessages = await dialogCopyMessages.show(dialog, allMessages) + + if (!selectedMessages || selectedMessages.length === 0) { + dialog.clear() + } + + expect(dialog.clear).toHaveBeenCalled() + }) + + test("should clear dialog when selection is empty array", async () => { + const msgs: (AssistantMessage | UserMessage)[] = [ + { + id: "msg_1", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1000000 }, + } as UserMessage, + ] + + session = () => ({ id: "ses_123", title: "Test" }) + messages = () => msgs + sync = { data: { part: { msg_1: [] } } } + dialogCopyMessages.show = mock(async () => []) + + const sessionData = session() + const sessionMessages = messages() + const allMessages = sessionMessages.map((msg) => ({ + info: msg, + parts: sync.data.part[msg.id] ?? [], + })) + + const selectedMessages = await dialogCopyMessages.show(dialog, allMessages) + + if (!selectedMessages || selectedMessages.length === 0) { + dialog.clear() + } + + expect(dialog.clear).toHaveBeenCalled() + }) + }) + + describe("transcript formatting", () => { + test("should format transcript with correct options", async () => { + const msgs: (AssistantMessage | UserMessage)[] = [ + { + id: "msg_1", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1000000 }, + } as UserMessage, + ] + + const sessionData = { id: "ses_123", title: "Test" } + const selectedMessages = [{ info: msgs[0], parts: [] }] + + formatTranscript(sessionData, selectedMessages, { + thinking: true, + toolDetails: true, + assistantMetadata: true, + includeMetadata: false, + }) + + expect(formatTranscript).toHaveBeenCalledWith( + sessionData, + selectedMessages, + expect.objectContaining({ + thinking: true, + toolDetails: true, + assistantMetadata: true, + includeMetadata: false, + }), + ) + }) + + test("should use consistent formatting options across all calls", async () => { + const msgs: (AssistantMessage | UserMessage)[] = [ + { + id: "msg_1", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1000000 }, + } as UserMessage, + { + id: "msg_2", + sessionID: "ses_123", + role: "assistant", + agent: "build", + modelID: "claude-sonnet", + providerID: "anthropic", + mode: "", + parentID: "msg_1", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000000, completed: 1005400 }, + } as AssistantMessage, + ] + + const sessionData = { id: "ses_123", title: "Test" } + const selectedMessages = [ + { info: msgs[0], parts: [] }, + { info: msgs[1], parts: [] }, + ] + + const transcript = formatTranscript(sessionData, selectedMessages, { + thinking: true, + toolDetails: true, + assistantMetadata: true, + includeMetadata: false, + }) + + expect(transcript).toBeDefined() + }) + }) + + describe("clipboard operations", () => { + test("should copy formatted transcript to clipboard", async () => { + const selectedMessages = [ + { + info: { + id: "msg_1", + role: "user", + } as UserMessage, + parts: [], + }, + ] + + const transcript = "formatted transcript" + await clipboard.copy(transcript) + + expect(clipboard.copy).toHaveBeenCalledWith(transcript) + }) + + test("should show success toast after copy", async () => { + const selectedMessages = [ + { + info: { + id: "msg_1", + role: "user", + } as UserMessage, + parts: [], + }, + ] + + const selectedCount = selectedMessages.length + await clipboard.copy("transcript") + toast.show({ message: `${selectedCount} message(s) copied to clipboard!`, variant: "success" }) + + expect(toast.show).toHaveBeenCalledWith( + expect.objectContaining({ + message: "1 message(s) copied to clipboard!", + variant: "success", + }), + ) + }) + + test("should include correct message count in success notification", async () => { + const selectedMessages = Array.from({ length: 5 }, (_, i) => ({ + info: { + id: `msg_${i}`, + role: "user", + } as UserMessage, + parts: [], + })) + + const selectedCount = selectedMessages.length + toast.show({ message: `${selectedCount} message(s) copied to clipboard!`, variant: "success" }) + + expect(toast.show).toHaveBeenCalledWith( + expect.objectContaining({ + message: "5 message(s) copied to clipboard!", + variant: "success", + }), + ) + }) + }) + + describe("error handling", () => { + test("should show error toast on copy failure", async () => { + const error = new Error("Copy failed") + + try { + throw error + } catch (err) { + toast.show({ message: "Failed to copy messages", variant: "error" }) + } + + expect(toast.show).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Failed to copy messages", + variant: "error", + }), + ) + }) + + test("should clear dialog after error", async () => { + try { + throw new Error("Copy failed") + } catch (error) { + dialog.clear() + } + + expect(dialog.clear).toHaveBeenCalled() + }) + + test("should clear dialog on generic error", async () => { + const sessionData = { id: "ses_123", title: "Test" } + try { + throw new Error("Unexpected error") + } catch (error) { + toast.show({ message: "Failed to copy messages", variant: "error" }) + dialog.clear() + } + + expect(dialog.clear).toHaveBeenCalled() + expect(toast.show).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "error", + }), + ) + }) + }) + + describe("workflow integration", () => { + test("should complete full copy workflow", async () => { + const msgs: (AssistantMessage | UserMessage)[] = [ + { + id: "msg_1", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1000000 }, + } as UserMessage, + { + id: "msg_2", + sessionID: "ses_123", + role: "assistant", + agent: "build", + modelID: "claude-sonnet", + providerID: "anthropic", + mode: "", + parentID: "msg_1", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000000, completed: 1005400 }, + } as AssistantMessage, + ] + + session = () => ({ id: "ses_123", title: "Test" }) + messages = () => msgs + sync = { + data: { + part: { + msg_1: [{ id: "p1", type: "text", text: "Hello" }], + msg_2: [{ id: "p2", type: "text", text: "Hi there" }], + }, + }, + } + + // Simulate workflow + const sessionData = session() + const sessionMessages = messages() + const allMessages = sessionMessages.map((msg) => ({ + info: msg, + parts: sync.data.part[msg.id] ?? [], + })) + + const selectedMessages = await dialogCopyMessages.show(dialog, allMessages) + + if (selectedMessages && selectedMessages.length > 0) { + const transcript = formatTranscript(sessionData, selectedMessages, { + thinking: true, + toolDetails: true, + assistantMetadata: true, + includeMetadata: false, + }) + await clipboard.copy(transcript) + toast.show({ message: `${selectedMessages.length} message(s) copied to clipboard!`, variant: "success" }) + } + + dialog.clear() + + expect(dialogCopyMessages.show).toHaveBeenCalled() + expect(formatTranscript).toHaveBeenCalled() + expect(clipboard.copy).toHaveBeenCalled() + expect(toast.show).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "success", + }), + ) + expect(dialog.clear).toHaveBeenCalled() + }) + + test("should handle mixed user and assistant message selection", async () => { + const msgs: (AssistantMessage | UserMessage)[] = [ + { + id: "msg_1", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1000000 }, + } as UserMessage, + { + id: "msg_2", + sessionID: "ses_123", + role: "assistant", + agent: "build", + modelID: "claude-sonnet", + providerID: "anthropic", + mode: "", + parentID: "msg_1", + path: { cwd: "/test", root: "/test" }, + cost: 0.001, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1000000, completed: 1005400 }, + } as AssistantMessage, + { + id: "msg_3", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1010000 }, + } as UserMessage, + ] + + const sessionData = { id: "ses_123", title: "Test" } + const selectedMessages = [ + { info: msgs[0], parts: [] }, + { info: msgs[1], parts: [] }, + { info: msgs[2], parts: [] }, + ] + + const userMessages = selectedMessages.filter((m) => m.info.role === "user") + const assistantMessages = selectedMessages.filter((m) => m.info.role === "assistant") + + expect(userMessages).toHaveLength(2) + expect(assistantMessages).toHaveLength(1) + }) + + test("should always clear dialog at end of workflow", async () => { + const msgs: (AssistantMessage | UserMessage)[] = [ + { + id: "msg_1", + sessionID: "ses_123", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude-sonnet" }, + time: { created: 1000000 }, + } as UserMessage, + ] + + session = () => ({ id: "ses_123", title: "Test" }) + messages = () => msgs + sync = { data: { part: { msg_1: [] } } } + + try { + const sessionData = session() + const sessionMessages = messages() + const allMessages = sessionMessages.map((msg) => ({ + info: msg, + parts: sync.data.part[msg.id] ?? [], + })) + const selectedMessages = await dialogCopyMessages.show(dialog, allMessages) + + if (selectedMessages && selectedMessages.length > 0) { + const transcript = formatTranscript(sessionData, selectedMessages, { + thinking: true, + toolDetails: true, + assistantMetadata: true, + includeMetadata: false, + }) + await clipboard.copy(transcript) + toast.show({ message: `${selectedMessages.length} message(s) copied to clipboard!`, variant: "success" }) + } + } catch (error) { + toast.show({ message: "Failed to copy messages", variant: "error" }) + } + + dialog.clear() + + expect(dialog.clear).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts index 7a5fa6b8f1c..57a24a1b837 100644 --- a/packages/opencode/test/cli/tui/transcript.test.ts +++ b/packages/opencode/test/cli/tui/transcript.test.ts @@ -48,7 +48,7 @@ describe("transcript", () => { }) describe("formatPart", () => { - const options = { thinking: true, toolDetails: true, assistantMetadata: true } + const options = { includeMetadata: true, thinking: true, toolDetails: true, assistantMetadata: true } test("formats text part", () => { const part: Part = { @@ -196,7 +196,7 @@ describe("transcript", () => { }) describe("formatMessage", () => { - const options = { thinking: true, toolDetails: true, assistantMetadata: true } + const options = { includeMetadata: true, thinking: true, toolDetails: true, assistantMetadata: true } test("formats user message", () => { const msg: UserMessage = { @@ -272,7 +272,7 @@ describe("transcript", () => { parts: [{ id: "p2", sessionID: "ses_abc123", messageID: "msg_2", type: "text" as const, text: "Hi!" }], }, ] - const options = { thinking: false, toolDetails: false, assistantMetadata: true } + const options = { includeMetadata: true, thinking: false, toolDetails: false, assistantMetadata: true } const result = formatTranscript(session, messages, options) @@ -310,7 +310,7 @@ describe("transcript", () => { parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }], }, ] - const options = { thinking: false, toolDetails: false, assistantMetadata: false } + const options = { includeMetadata: true, thinking: false, toolDetails: false, assistantMetadata: false } const result = formatTranscript(session, messages, options)