diff --git a/package.json b/package.json index bd9dbac414c..f7047e0c790 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", - "test": "echo 'do not run tests from root' && exit 1" + "test": "echo 'do not run tests from root' && exit 1", + "pr:create": "bun ./script/pr-create.ts" }, "workspaces": { "packages": [ diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 86f3321e464..23014606a2f 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -86,6 +86,9 @@ export type Platform = { /** Read image from clipboard (desktop only) */ readClipboardImage?(): Promise + + /** Write text to clipboard (desktop only) */ + writeClipboardText?(value: string): Promise | boolean } export type DisplayBackend = "auto" | "wayland" diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739df..ed55868668b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -81,6 +81,8 @@ export const dict = { "command.session.redo.description": "Redo the last undone message", "command.session.compact": "Compact session", "command.session.compact.description": "Summarize the session to reduce context size", + "command.session.copy": "Copy session transcript", + "command.session.copy.description": "Copy this session transcript to your clipboard", "command.session.fork": "Fork from message", "command.session.fork.description": "Create a new session from a previous message", "command.session.share": "Share session", @@ -436,6 +438,11 @@ export const dict = { "toast.session.share.failed.title": "Failed to share session", "toast.session.share.failed.description": "An error occurred while sharing the session", + "toast.session.copy.success.title": "Session transcript copied", + "toast.session.copy.success.description": "The transcript has been copied to your clipboard", + "toast.session.copy.failed.title": "Failed to copy session transcript", + "toast.session.copy.failed.description": "Could not copy the transcript to your clipboard", + "toast.session.unshare.success.title": "Session unshared", "toast.session.unshare.success.description": "Session unshared successfully!", "toast.session.unshare.failed.title": "Failed to unshare session", diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 461351878b6..a7578b54a7d 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -7,6 +7,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" +import { usePlatform } from "@/context/platform" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" @@ -18,7 +19,8 @@ import { DialogFork } from "@/components/dialog-fork" import { showToast } from "@opencode-ai/ui/toast" import { findLast } from "@opencode-ai/util/array" import { extractPromptFromParts } from "@/utils/prompt" -import { UserMessage } from "@opencode-ai/sdk/v2" +import { formatSessionTranscript } from "@opencode-ai/util/session-transcript" +import { type UserMessage } from "@opencode-ai/sdk/v2" import { canAddSelectionContext } from "@/pages/session/session-command-helpers" export type SessionCommandContext = { @@ -41,6 +43,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const language = useLanguage() const local = useLocal() const permission = usePermission() + const platform = usePlatform() const prompt = usePrompt() const sdk = useSDK() const sync = useSync() @@ -64,6 +67,41 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return userMessages().filter((m) => m.id < revert) }) + const writeClipboard = (value: string) => { + if (platform.writeClipboardText) { + const result = platform.writeClipboardText(value) + if (result instanceof Promise) { + return result.then( + (ok) => ok, + () => false, + ) + } + return Promise.resolve(result) + } + + const body = typeof document === "undefined" ? undefined : document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = value + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.pointerEvents = "none" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return Promise.resolve(true) + } + + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.writeText) return Promise.resolve(false) + return clipboard.writeText(value).then( + () => true, + () => false, + ) + } + const showAllFiles = () => { if (layout.fileTree.tab() !== "changes") return layout.fileTree.setTab("all") @@ -360,6 +398,49 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }) }, }), + sessionCommand({ + id: "session.copy", + title: language.t("command.session.copy"), + description: language.t("command.session.copy.description"), + slash: "copy", + disabled: !params.id, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + + const session = info() + if (!session) return + + const rows = await sdk.client.session.messages({ sessionID }).then( + (res) => res.data, + () => undefined, + ) + if (!rows) { + showToast({ + title: language.t("toast.session.copy.failed.title"), + description: language.t("toast.session.copy.failed.description"), + variant: "error", + }) + return + } + + const ok = await writeClipboard(formatSessionTranscript(session, rows)) + if (!ok) { + showToast({ + title: language.t("toast.session.copy.failed.title"), + description: language.t("toast.session.copy.failed.description"), + variant: "error", + }) + return + } + + showToast({ + title: language.t("toast.session.copy.success.title"), + description: language.t("toast.session.copy.success.description"), + variant: "success", + }) + }, + }), sessionCommand({ id: "session.fork", title: language.t("command.session.fork"), @@ -384,32 +465,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { onSelect: async () => { if (!params.id) return - const write = (value: string) => { - const body = typeof document === "undefined" ? undefined : document.body - if (body) { - const textarea = document.createElement("textarea") - textarea.value = value - textarea.setAttribute("readonly", "") - textarea.style.position = "fixed" - textarea.style.opacity = "0" - textarea.style.pointerEvents = "none" - body.appendChild(textarea) - textarea.select() - const copied = document.execCommand("copy") - body.removeChild(textarea) - if (copied) return Promise.resolve(true) - } - - const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard - if (!clipboard?.writeText) return Promise.resolve(false) - return clipboard.writeText(value).then( - () => true, - () => false, - ) - } - const copy = async (url: string, existing: boolean) => { - const ok = await write(url) + const ok = await writeClipboard(url) if (!ok) { showToast({ title: language.t("toast.session.share.copyFailed.title"), diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 4d0276c832e..460ce861909 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -47,6 +47,7 @@ "identifier": "http:default", "allow": [{ "url": "http://*" }, { "url": "https://*" }, { "url": "http://*:*/*" }] }, - "clipboard-manager:allow-read-image" + "clipboard-manager:allow-read-image", + "clipboard-manager:allow-write-text" ] } diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 9afabe918b1..57bb5eeb849 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -12,7 +12,7 @@ import { import { Splash } from "@opencode-ai/ui/logo" import type { AsyncStorage } from "@solid-primitives/storage" import { getCurrentWindow } from "@tauri-apps/api/window" -import { readImage } from "@tauri-apps/plugin-clipboard-manager" +import { readImage, writeText } from "@tauri-apps/plugin-clipboard-manager" import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" import { open, save } from "@tauri-apps/plugin-dialog" import { fetch as tauriFetch } from "@tauri-apps/plugin-http" @@ -400,6 +400,13 @@ const createPlatform = (): Platform => { }, "image/png") }) }, + + writeClipboardText: async (value: string) => { + return writeText(value).then( + () => true, + () => false, + ) + }, } } diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index de6a1d6a76c..414c48a9095 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -69,6 +69,10 @@ export async function createMenu(trigger: (id: string) => void) { accelerator: "Shift+Cmd+S", action: () => trigger("session.new"), }), + await MenuItem.new({ + text: t("command.session.copy"), + action: () => trigger("session.copy"), + }), await MenuItem.new({ text: t("desktop.menu.file.openProject"), accelerator: "Cmd+O", diff --git a/packages/util/src/session-transcript.ts b/packages/util/src/session-transcript.ts new file mode 100644 index 00000000000..1c4677b597b --- /dev/null +++ b/packages/util/src/session-transcript.ts @@ -0,0 +1,89 @@ +type Session = { + id: string + title: string + time: { + created: number + updated: number + } +} + +type Message = { + role: "user" | "assistant" + agent?: string + modelID?: string + time: { + created?: number + completed?: number + } +} + +type Part = { + type: string + synthetic?: boolean + text?: string + tool?: string + state?: { + input?: unknown + output?: string + error?: string + status: "pending" | "running" | "completed" | "error" + } +} + +const titlecase = (value: string) => (value ? value.charAt(0).toUpperCase() + value.slice(1) : value) + +const formatAssistant = (msg: Message) => { + const duration = + msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : "" + const agent = msg.agent ? titlecase(msg.agent) : "Assistant" + const model = msg.modelID ?? "" + return `## Assistant (${agent}${model ? ` · ${model}` : ""}${duration ? ` · ${duration}` : ""})\n\n` +} + +const formatPart = (part: Part) => { + if (part.type === "text" && part.text && !part.synthetic) return `${part.text}\n\n` + if (part.type === "reasoning" && part.text) return `_Thinking:_\n\n${part.text}\n\n` + if (part.type !== "tool" || !part.tool || !part.state) return "" + + const input = part.state.input + ? `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n` + : "" + const output = + part.state.status === "completed" && part.state.output + ? `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n` + : "" + const error = + part.state.status === "error" && part.state.error ? `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n` : "" + return `**Tool: ${part.tool}**\n${input}${output}${error}\n` +} + +const formatMessage = (msg: Message, parts: Part[]) => { + const header = msg.role === "assistant" ? formatAssistant(msg) : "## User\n\n" + return `${header}${parts.map(formatPart).join("")}` +} + +export const formatSessionTranscript = ( + session: Session, + rows: Array<{ + info: Message + parts: Part[] + }>, +) => { + const header = [ + `# ${session.title}`, + "", + `**Session ID:** ${session.id}`, + `**Created:** ${new Date(session.time.created).toLocaleString()}`, + `**Updated:** ${new Date(session.time.updated).toLocaleString()}`, + "", + "---", + "", + ].join("\n") + + const body = rows + .map((row) => `${formatMessage(row.info, row.parts)}---\n`) + .join("\n") + .trimEnd() + + return `${header}${body ? `\n${body}` : ""}` +} diff --git a/script/pr-create.ts b/script/pr-create.ts new file mode 100644 index 00000000000..67a63be4699 --- /dev/null +++ b/script/pr-create.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env bun + +import path from "node:path" + +const need = [ + "### Issue for this PR", + "### Type of change", + "### What does this PR do?", + "### How did you verify your code works?", + "### Screenshots / recordings", + "### Checklist", +] + +const help = () => { + console.log(`Usage: bun run pr:create -- [gh pr create args] + +Required: + --body-file Path to PR body markdown file + +Examples: + bun run pr:create -- --base dev --title "feat: add foo" --body-file /tmp/pr.md + bun run pr:create -- --base dev --head my-branch --body-file .github/pull_request_template.md +`) +} + +const fail = (msg: string) => { + console.error(msg) + process.exit(1) +} + +const args = Bun.argv.slice(2) +if (args.includes("--help") || args.includes("-h")) { + help() + process.exit(0) +} + +const bodyIndex = args.findIndex((x) => x === "--body-file" || x === "-F") +if (bodyIndex === -1) fail("Missing --body-file/-F. This wrapper validates PR template before creating PR.") + +const bodyArg = args[bodyIndex + 1] +if (!bodyArg) fail("Missing value for --body-file/-F.") + +const bodyPath = path.resolve(process.cwd(), bodyArg) +const bodyFile = Bun.file(bodyPath) +if (!(await bodyFile.exists())) fail(`PR body file not found: ${bodyArg}`) + +const body = await bodyFile.text() +for (const section of need) { + if (body.includes(section)) continue + fail(`Missing required section: ${section}`) +} + +const checked = /- \[x\] (Bug fix|New feature|Refactor \/ code improvement|Documentation)/.test(body) +if (!checked) fail("No checked 'Type of change' checkbox found.") + +const run = Bun.spawnSync(["gh", "pr", "create", ...args], { + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + env: process.env, +}) + +process.exit(run.exitCode)