Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions packages/tui/src/util/format.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
26 changes: 25 additions & 1 deletion packages/tui/test/util/format.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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")
})
})
})
Loading