Repository navigation
Description
Summary
The built-in /copy (messages.copy, <leader>y) copies the text parts of the last assistant message only. A turn is usually spread across several assistant messages (tool calls, continued turns, reasoning splits). When that last assistant message has no text parts, /copy fails with "No text content found in last assistant message", even though the turn's actual final prose sits in an earlier assistant message of the same answer block.
It would also be very useful to copy older replies or only code blocks, which the current command can't do at all.
Environment
- opencode version: 2.0.32 (latest)
- OS: macOS 27.0.1, arm64 (Darwin 27.0.0)
- Terminal: iTerm2 (TERM_PROGRAM=iTerm.app), COLORTERM=truecolor
- Shell: zsh
- Install/channel: official binary (
~/.opencode/bin/opencode), latest channel - Active plugins:
@tarquinen/opencode-dcp@latest,opencode-notificator,opencode-queue, local plugin./plugins/copy1(see below)
Reproduction
- Run a turn where the model calls tools and writes the final answer afterwards (easy with reasoning-heavy / interleaved models: the turn gets split into multiple assistant messages).
- Make sure the very last assistant message in that turn has no
textparts (a tool-only part). - Press
<leader>y(or type/copy).
Expected Behavior
/copy should take the current answer block — the assistant messages between two consecutive user messages — and copy the text of its last text-bearing assistant message. Reasoning and tool parts stay excluded. That is what a "copy last reply" command means in practice with thinking models.
Actual Behavior
Toast "No text content found in last assistant message"; nothing is copied, even though the block contains the final prose in an earlier assistant part.
Additional Context
I maintain a local CLI plugin that implements exactly this block-based extraction plus argument support; happy to upstream the logic:
/copy(no args): final prose of the most recent answer block (last text-bearing assistant message within the block)/copy N | A-B | A,B: pick older block(s);1= most recent/copy code: copy only the fenced code blocks of the chosen reply- user turns that are themselves copy-command invocations are filtered out
- warning when the extracted body is very short (< 50 chars, likely not the intended reply)
Reference implementation (pure client-side, no model calls):
/**
* copy1 纯逻辑:不依赖任何运行时,可被 tui.ts 和单测直接引用。
* 口径与 v1 一致:回答块 = 两条 user 消息之间的 assistant 消息集合;
* 每块只取最后一条含文本的 assistant 消息 = 该轮最终正文;
* 只取 type=text,reasoning/tool 等一律丢弃(思考开 max 也不怕)。
*/
export const MIN_WARN = 50
export const COMMAND_MARKERS = [
"任务:把「本命令消息之前",
"运行 `python3 ~/.config/opencode/scripts/copy-last-reply.py",
]
export type Role = "user" | "assistant" | string
export type Msg = { id: string; sessionID?: string; role: Role; time: number; texts: string[] }
export type Block = { userTime: number | null; userMid: string | null; msgs: Msg[] }
export type ExtractOk = {
body: string
total: number
picks: number[]
spec: string
wantCode: boolean
when: string
session: string
}
export type ExtractErr = { error: string }
export type ExtractResult = ExtractOk | ExtractErr
function dieResult(msg: string): ExtractErr {
return { error: msg }
}
function formatWhen(ms: number): string {
const d = new Date(ms)
const p = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
export function parseSpec(spec: string, total: number): number[] | string {
const s = spec.trim()
let picks: number[]
if (/^\d+$/.test(s)) picks = [Number(s)]
else if (/^(\d+)\s*[-]\s*(\d+)$/.test(s)) {
const [, a, b] = s.match(/^(\d+)\s*[-]\s*(\d+)$/)!
picks = []
for (let n = Number(a); n <= Number(b); n++) picks.push(n)
} else if (/^\d+([,/]\d+)+$/.test(s)) picks = s.split(/[,/]/).map(Number)
else return `无法识别的参数: ${JSON.stringify(spec)}(支持 N、A-B、A,B/A/B、code)`
const bad = picks.filter((p) => !(p >= 1 && p <= total))
if (bad.length) return `只有 ${total} 条最终正文,序号越界: ${bad}`
return picks.slice().sort((a, b) => a - b)
}
export function extractCode(md: string): string {
const blocks: string[] = []
const re = /^```[^\n]*\n([\s\S]*?)^```/gm
let m: RegExpExecArray | null
while ((m = re.exec(md))) blocks.push(m[1].replace(/\n$/, ""))
return blocks.join("\n\n")
}
function commandUserText(block: Block, byId: Map<string, Msg>): boolean {
if (!block.userMid) return false
const msg = byId.get(block.userMid)
const head = ((msg?.texts[0] ?? "") as string).replace(/^\s+/, "")
return COMMAND_MARKERS.some((m) => head.startsWith(m))
}
export function extractFromMessages(messages: Msg[], rawSpec: string): ExtractResult {
let spec = rawSpec.trim()
const wantCode = spec === "code"
if (wantCode) spec = ""
if (!messages.length) return dieResult("会话里没有消息")
const byId = new Map(messages.map((m) => [m.id, m]))
const blocks: Block[] = []
let cur: Block = { userTime: null, userMid: null, msgs: [] }
for (const m of messages) {
if (m.role === "user") {
if (cur.msgs.length) blocks.push(cur)
cur = { userTime: m.time, userMid: m.id, msgs: [] }
} else if (m.role === "assistant") {
cur.msgs.push(m)
}
}
if (cur.msgs.length) blocks.push(cur)
const usable = blocks.filter((b) => !commandUserText(b, byId))
if (!usable.length) return dieResult("过滤命令轮后没有可复制的回答块")
const picks = spec ? parseSpec(spec, usable.length) : [1]
if (typeof picks === "string") return dieResult(picks)
const chosen = picks.map((p) => usable[usable.length - p])
const bodies: string[] = []
let last: Msg | undefined
for (let i = 0; i < chosen.length; i++) {
const block = chosen[i]
let body = ""
for (let j = block.msgs.length - 1; j >= 0; j--) {
const msg = block.msgs[j]
if (msg.texts.length) {
body = msg.texts.join("\n\n").trim()
last = msg
break
}
}
if (!body) return dieResult(`第 ${picks[i]} 块没有任何文本内容`)
bodies.push(wantCode ? extractCode(body) : body)
}
let body = bodies.join("\n\n").trim()
if (wantCode && !body) return dieResult("所选最终正文里没有围栏代码块")
if (!body) return dieResult("该回答块没有任何文本内容")
if (!last) return dieResult("该回答块没有任何文本内容")
const when = formatWhen(last.time)
return {
body,
total: usable.length,
picks,
spec,
wantCode,
when,
session: last.sessionID ?? "",
}
}
Activity
opencode-agent commentedon Oct 17, 2026
Thanks for the detailed report! I reproduced this on v2.0.32 with a simulated model:
ls)./copythen shows "No text content found in last assistant message". The reply is still on screen in that same turn.It still happens on the latest
dev(65932e8). The recording and screenshots below show it, and the script below reproduces it.The cause is that
/copyonly looks at the last assistant message, even when that message has no text. Your proposed arguments (/copy N,A-B,code) are a separate feature request; this confirmation covers only the bug.Related: #56053 made
/copycopy the last assistant message, and #56055 added compaction summaries to/copy.recording.mp4
Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.