diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 2b54f21671b3..4366b31f03ab 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -55,6 +55,7 @@ import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { SubagentFooter } from "./subagent-footer.tsx" import { filetype } from "../../util/filetype" +import { formatAnswer } from "../../util/format" import parsers from "../../parsers-config" import { errorMessage } from "../../util/error" import { Toast, useToast } from "../../ui/toast" @@ -913,6 +914,57 @@ export function Session() { dialog.clear() }, }, + { + title: "Copy last assistant message formatted", + value: "messages.copy.formatted", + category: "Session", + run: () => { + const lastAssistantMessage = messagesBeforeRevert().findLast((message) => message.role === "assistant") + if (!lastAssistantMessage) { + toast.show({ message: "No assistant messages found", variant: "error" }) + dialog.clear() + return + } + if ( + lastAssistantMessage.error || + !lastAssistantMessage.finish || + ["tool-calls", "unknown"].includes(lastAssistantMessage.finish) + ) { + toast.show({ message: "Last assistant message is not complete", variant: "error" }) + dialog.clear() + return + } + + const parts = sync.data.part[lastAssistantMessage.id] ?? [] + const textParts = parts.filter((part) => part.type === "text") + if (textParts.length === 0) { + toast.show({ message: "No text parts found in last assistant message", variant: "error" }) + dialog.clear() + return + } + + const text = formatAnswer( + textParts + .map((part) => part.text) + .join("\n") + .trim(), + ) + if (!text) { + toast.show({ + message: "No text content found in last assistant message", + variant: "error", + }) + dialog.clear() + return + } + + clipboard + .write?.(text) + .then(() => toast.show({ message: "Formatted message copied to clipboard!", variant: "success" })) + .catch(() => toast.show({ message: "Failed to copy to clipboard", variant: "error" })) + dialog.clear() + }, + }, { title: "Copy session transcript", value: "session.copy", diff --git a/packages/tui/src/util/format.ts b/packages/tui/src/util/format.ts index 4ae62eac450a..b979c244b911 100644 --- a/packages/tui/src/util/format.ts +++ b/packages/tui/src/util/format.ts @@ -1,3 +1,41 @@ +export function formatAnswer(text: string) { + const lines = text.replace(/\r\n?/g, "\n").split("\n") + const out: string[] = [] + let fenceChar = "" + let fenceLen = 0 + let blanks = 0 + for (const line of lines) { + const fence = line.match(/^\s*(`{3,}|~{3,})/) + if (fence) { + const mark = fence[1] ?? "" + const char = mark[0] ?? "" + if (!fenceChar) { + fenceChar = char + fenceLen = mark.length + } else if (char === fenceChar && mark.length >= fenceLen) { + fenceChar = "" + fenceLen = 0 + } + blanks = 0 + out.push(line) + continue + } + if (fenceChar) { + out.push(line) + continue + } + if (line.trim() === "") { + blanks += 1 + if (blanks <= 1 && out.length > 0) out.push("") + continue + } + blanks = 0 + out.push(line.replace(/[ \t]+$/, "")) + } + while (out.length > 0 && out[out.length - 1] === "") out.pop() + return out.join("\n") +} + export function formatDuration(secs: number) { if (secs <= 0) return "" if (secs < 60) return `${secs}s` diff --git a/packages/tui/test/util/format.test.ts b/packages/tui/test/util/format.test.ts index 5b346e7f6bcd..7a2a7b70a941 100644 --- a/packages/tui/test/util/format.test.ts +++ b/packages/tui/test/util/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { formatDuration } from "../../src/util/format" +import { formatAnswer, formatDuration } from "../../src/util/format" describe("util.format", () => { describe("formatDuration", () => { @@ -56,4 +56,28 @@ describe("util.format", () => { expect(formatDuration(604800)).toBe("~1 week") }) }) + + describe("formatAnswer", () => { + test("keeps prose text unchanged", () => { + expect(formatAnswer("hello world")).toBe("hello world") + expect(formatAnswer("")).toBe("") + }) + + test("trims trailing whitespace outside code blocks", () => { + expect(formatAnswer("hello \nworld\t ")).toBe("hello\nworld") + }) + + test("collapses repeated blank lines and trims ends", () => { + expect(formatAnswer("\n\na\n\n\n\nb\n\n")).toBe("a\n\nb") + }) + + test("preserves fenced code blocks byte-for-byte", () => { + const code = "```js\nline one \n\n\nline two\t\n```" + expect(formatAnswer(`before \n\n${code}\n\nafter `)).toBe(`before\n\n${code}\n\nafter`) + }) + + test("treats unclosed fences as code to the end", () => { + expect(formatAnswer("text \n```\nkeep \n\n\nkeep")).toBe("text\n```\nkeep \n\n\nkeep") + }) + }) })