import { mkdir, writeFile } from 'fs/promises' import { marked, type Tokens } from 'marked' import { tmpdir } from 'os' import { join } from 'path' import React, { useRef } from 'react' import type { CommandResultDisplay } from '../../commands.js' import type { OptionWithDescription } from '../../components/CustomSelect/select.js' import { Select } from '../../components/CustomSelect/select.js' import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink' import { Box, setClipboard, Text, stringWidth, type KeyboardEvent } from '@anthropic/ink' import { logEvent } from '../../services/analytics/index.js' import type { LocalJSXCommandCall } from '../../types/command.js' import type { AssistantMessage, Message } from '../../types/message.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js' import { countCharInString } from '../../utils/stringUtils.js' const COPY_DIR = join(tmpdir(), 'claude') const RESPONSE_FILENAME = 'response.md' const MAX_LOOKBACK = 20 type CodeBlock = { code: string lang: string | undefined } function extractCodeBlocks(markdown: string): CodeBlock[] { const tokens = marked.lexer(stripPromptXMLTags(markdown)) const blocks: CodeBlock[] = [] for (const token of tokens) { if (token.type === 'code') { const codeToken = token as Tokens.Code blocks.push({ code: codeToken.text, lang: codeToken.lang }) } } return blocks } /** * Walk messages newest-first, returning text from assistant messages that * actually said something (skips tool-use-only turns and API errors). * Index 0 = latest, 1 = second-to-latest, etc. Caps at MAX_LOOKBACK. */ export function collectRecentAssistantTexts(messages: Message[]): string[] { const texts: string[] = [] for ( let i = messages.length - 1; i >= 0 && texts.length < MAX_LOOKBACK; i-- ) { const msg = messages[i] if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue const content = (msg as AssistantMessage).message.content if (!Array.isArray(content)) continue const text = extractTextContent(content, '\n\n') if (text) texts.push(text) } return texts } export function fileExtension(lang: string | undefined): string { if (lang) { // Sanitize to prevent path traversal (e.g. ```../../etc/passwd) // Language identifiers are alphanumeric: python, tsx, jsonc, etc. const sanitized = lang.replace(/[^a-zA-Z0-9]/g, '') if (sanitized && sanitized !== 'plaintext') { return `.${sanitized}` } } return '.txt' } async function writeToFile(text: string, filename: string): Promise { const filePath = join(COPY_DIR, filename) await mkdir(COPY_DIR, { recursive: true }) await writeFile(filePath, text, 'utf-8') return filePath } async function copyOrWriteToFile( text: string, filename: string, ): Promise { const raw = await setClipboard(text) if (raw) process.stdout.write(raw) const lineCount = countCharInString(text, '\n') + 1 const charCount = text.length // Also write to a temp file — clipboard paths are best-effort (OSC 52 needs // terminal support), so the file provides a reliable fallback. try { const filePath = await writeToFile(text, filename) return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}` } catch { return `Copied to clipboard (${charCount} characters, ${lineCount} lines)` } } function truncateLine(text: string, maxLen: number): string { const firstLine = text.split('\n')[0] ?? '' if (stringWidth(firstLine) <= maxLen) { return firstLine } let result = '' let width = 0 const targetWidth = maxLen - 1 for (const char of firstLine) { const charWidth = stringWidth(char) if (width + charWidth > targetWidth) break result += char width += charWidth } return result + '\u2026' } type PickerProps = { fullText: string codeBlocks: CodeBlock[] messageAge: number onDone: ( result?: string, options?: { display?: CommandResultDisplay }, ) => void } type PickerSelection = number | 'full' | 'always' function CopyPicker({ fullText, codeBlocks, messageAge, onDone, }: PickerProps): React.ReactNode { const focusedRef = useRef('full') const options: OptionWithDescription[] = [ { label: 'Full response', value: 'full' as const, description: `${fullText.length} chars, ${countCharInString(fullText, '\n') + 1} lines`, }, ...codeBlocks.map((block, index) => { const blockLines = countCharInString(block.code, '\n') + 1 return { label: truncateLine(block.code, 60), value: index, description: [block.lang, blockLines > 1 ? `${blockLines} lines` : undefined] .filter(Boolean) .join(', ') || undefined, } }), { label: 'Always copy full response', value: 'always' as const, description: 'Skip this picker in the future (revert via /config)', }, ] function getSelectionContent(selected: PickerSelection): { text: string filename: string blockIndex?: number } { if (selected === 'full' || selected === 'always') { return { text: fullText, filename: RESPONSE_FILENAME } } const block = codeBlocks[selected]! return { text: block.code, filename: `copy${fileExtension(block.lang)}`, blockIndex: selected, } } async function handleSelect(selected: PickerSelection): Promise { const content = getSelectionContent(selected) if (selected === 'always') { if (!getGlobalConfig().copyFullResponse) { saveGlobalConfig(c => ({ ...c, copyFullResponse: true })) } logEvent('tengu_copy', { block_count: codeBlocks.length, always: true, message_age: messageAge, }) const result = await copyOrWriteToFile(content.text, content.filename) onDone( `${result}\nPreference saved. Use /config to change copyFullResponse`, ) return } logEvent('tengu_copy', { selected_block: content.blockIndex, block_count: codeBlocks.length, message_age: messageAge, }) const result = await copyOrWriteToFile(content.text, content.filename) onDone(result) } async function handleWrite(selected: PickerSelection): Promise { const content = getSelectionContent(selected) logEvent('tengu_copy', { selected_block: content.blockIndex, block_count: codeBlocks.length, message_age: messageAge, write_shortcut: true, }) try { const filePath = await writeToFile(content.text, content.filename) onDone(`Written to ${filePath}`) } catch (e) { onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`) } } function handleKeyDown(e: KeyboardEvent): void { if (e.key === 'w') { e.preventDefault() void handleWrite(focusedRef.current) } } return ( Select content to copy: options={options} hideIndexes={false} onFocus={value => { focusedRef.current = value }} onChange={selected => { void handleSelect(selected) }} onCancel={() => { onDone('Copy cancelled', { display: 'system' }) }} /> ) } export const call: LocalJSXCommandCall = async (onDone, context, args) => { const texts = collectRecentAssistantTexts(context.messages) if (texts.length === 0) { onDone('No assistant message to copy') return null } // /copy N reaches back N-1 messages (1 = latest, 2 = second-to-latest, ...) let age = 0 const arg = args?.trim() if (arg) { const n = Number(arg) if (!Number.isInteger(n) || n < 1) { onDone(`Usage: /copy [N] where N is 1 (latest), 2, 3, \u2026 Got: ${arg}`) return null } if (n > texts.length) { onDone( `Only ${texts.length} assistant ${texts.length === 1 ? 'message' : 'messages'} available to copy`, ) return null } age = n - 1 } const text = texts[age]! const codeBlocks = extractCodeBlocks(text) const config = getGlobalConfig() if (codeBlocks.length === 0 || config.copyFullResponse) { logEvent('tengu_copy', { always: config.copyFullResponse, block_count: codeBlocks.length, message_age: age, }) const result = await copyOrWriteToFile(text, RESPONSE_FILENAME) onDone(result) return null } return ( ) }