Skip to content
Open
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export type Platform = {

/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null>

/** Write text to clipboard (desktop only) */
writeClipboardText?(value: string): Promise<boolean> | boolean
}

export type DisplayBackend = "auto" | "wayland"
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
109 changes: 83 additions & 26 deletions packages/app/src/pages/session/use-session-commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 = {
Expand All @@ -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()
Expand All @@ -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")
Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion packages/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
9 changes: 8 additions & 1 deletion packages/desktop/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -400,6 +400,13 @@ const createPlatform = (): Platform => {
}, "image/png")
})
},

writeClipboardText: async (value: string) => {
return writeText(value).then(
() => true,
() => false,
)
},
}
}

Expand Down
4 changes: 4 additions & 0 deletions packages/desktop/src/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 89 additions & 0 deletions packages/util/src/session-transcript.ts
Original file line number Diff line number Diff line change
@@ -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}` : ""}`
}
63 changes: 63 additions & 0 deletions script/pr-create.ts
Original file line number Diff line number Diff line change
@@ -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> 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)
Loading