diff --git a/.github/workflows/freebuff-e2e.yml b/.github/workflows/freebuff-e2e.yml index acf0a63e3f..8d144d5d1a 100644 --- a/.github/workflows/freebuff-e2e.yml +++ b/.github/workflows/freebuff-e2e.yml @@ -6,9 +6,10 @@ on: pull_request: branches: ['main'] workflow_dispatch: # Manual trigger + workflow_call: # Called by freebuff-release.yml concurrency: - group: freebuff-e2e-${{ github.ref }} + group: freebuff-e2e-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/freebuff-release.yml b/.github/workflows/freebuff-release.yml index c9e0c2bc39..1dea91df46 100644 --- a/.github/workflows/freebuff-release.yml +++ b/.github/workflows/freebuff-release.yml @@ -12,6 +12,11 @@ on: - patch - minor - major + checkout_ref: + description: 'Git ref to build from (commit SHA, branch, or tag). Defaults to latest main.' + required: false + default: '' + type: string concurrency: group: freebuff-release @@ -71,6 +76,11 @@ jobs: name: freebuff-updated-package path: freebuff/cli/release/ + e2e-tests: + needs: prepare-and-commit + uses: ./.github/workflows/freebuff-e2e.yml + secrets: inherit + build-binaries: needs: prepare-and-commit uses: ./.github/workflows/cli-release-build.yml @@ -78,12 +88,12 @@ jobs: binary-name: freebuff new-version: ${{ needs.prepare-and-commit.outputs.new_version }} artifact-name: freebuff-updated-package - checkout-ref: ${{ github.sha }} + checkout-ref: ${{ inputs.checkout_ref || github.sha }} env-overrides: '{"FREEBUFF_MODE": "true", "NEXT_PUBLIC_CB_ENVIRONMENT": "prod"}' secrets: inherit create-release: - needs: [prepare-and-commit, build-binaries] + needs: [prepare-and-commit, build-binaries, e2e-tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/agents/librarian/librarian.test.ts b/agents/librarian/librarian.test.ts new file mode 100644 index 0000000000..bd2d29d955 --- /dev/null +++ b/agents/librarian/librarian.test.ts @@ -0,0 +1,294 @@ +/** + * E2E test script for the librarian agent. + * + * Runs the agent on repo-analysis tasks one at a time, writing full event traces + * to files for analysis. Each task produces a trace file in debug/librarian-traces/. + * + * Usage: + * bun agents/librarian/librarian.test.ts [taskIndex] + * + * If taskIndex is provided, runs only that task (0-based). Otherwise runs all tasks. + */ + +import * as fs from 'fs' +import * as path from 'path' + +import { CodebuffClient, loadLocalAgents } from '@codebuff/sdk' + +import type { AgentDefinition } from '@codebuff/sdk' + +const TRACE_DIR = path.join(process.cwd(), 'debug', 'librarian-traces') + +interface TaskDefinition { + name: string + prompt: string + repoUrl: string +} + +const TASKS: TaskDefinition[] = [ + { + name: 'express-overview', + prompt: + 'What is the main entry point of this project? What are its key dependencies and what does it do?', + repoUrl: 'https://github.com/expressjs/express', + }, + { + name: 'zod-api-surface', + prompt: + 'What are the main public API exports of this library? List the key functions and types a user would import.', + repoUrl: 'https://github.com/colinhacks/zod', + }, +] + +interface TraceEvent { + timestamp: string + type: string + data: Record +} + +interface LibrarianOutput { + answer: string + relevantFiles: string[] + cloneDir: string +} + +async function runTask( + client: CodebuffClient, + task: TaskDefinition, + agentDefinitions: AgentDefinition[], + taskIndex: number, +): Promise<{ + success: boolean + traceFile: string + output: unknown + validationErrors: string[] +}> { + const events: TraceEvent[] = [] + const validationErrors: string[] = [] + const startTime = Date.now() + + console.log(`\n${'='.repeat(60)}`) + console.log(`Task ${taskIndex}: ${task.name}`) + console.log(`Repo: ${task.repoUrl}`) + console.log(`Prompt: ${task.prompt}`) + console.log(`${'='.repeat(60)}\n`) + + const runState = await client.run({ + agent: 'librarian', + prompt: task.prompt, + params: { repoUrl: task.repoUrl }, + agentDefinitions, + maxAgentSteps: 40, + handleEvent: (event) => { + events.push({ + timestamp: new Date().toISOString(), + type: event.type, + data: event as Record, + }) + + if (event.type === 'text') { + process.stdout.write(event.text ?? '') + } else if (event.type === 'tool_call') { + console.log(`\n[Tool Call] ${event.toolName}`) + } else if (event.type === 'tool_result') { + const preview = JSON.stringify(event.output)?.slice(0, 200) + console.log(`[Tool Result] ${preview}...`) + } else if (event.type === 'error') { + console.error(`[Error] ${event.message}`) + } else if (event.type === 'subagent_start') { + console.log(`[Subagent Start] ${event.agentType}`) + } else if (event.type === 'subagent_finish') { + console.log(`[Subagent Finish] ${event.agentType}`) + } + }, + }) + + const duration = ((Date.now() - startTime) / 1000).toFixed(1) + const output = runState.output + + // Validate structured output + if (output?.type === 'structuredOutput' && output.value !== null) { + const data = output.value as Record + + if (typeof data.answer !== 'string' || !data.answer) { + validationErrors.push('Missing or empty "answer" field in output') + } + + if (!Array.isArray(data.relevantFiles)) { + validationErrors.push('Missing "relevantFiles" array in output') + } else { + if (data.relevantFiles.length === 0) { + validationErrors.push('"relevantFiles" array is empty') + } + for (const f of data.relevantFiles) { + if (typeof f !== 'string') { + validationErrors.push( + `relevantFiles contains non-string: ${JSON.stringify(f)}`, + ) + } + } + } + + if (typeof data.cloneDir !== 'string' || !data.cloneDir) { + validationErrors.push('Missing or empty "cloneDir" field in output') + } + + // Verify cloneDir exists and files are readable + if (typeof data.cloneDir === 'string' && data.cloneDir) { + if (!fs.existsSync(data.cloneDir)) { + validationErrors.push(`cloneDir does not exist: ${data.cloneDir}`) + } else if (Array.isArray(data.relevantFiles)) { + for (const filePath of data.relevantFiles as string[]) { + if (!fs.existsSync(filePath)) { + validationErrors.push(`relevantFile not found: ${filePath}`) + } + } + } + } + } else if (output?.type === 'error') { + validationErrors.push(`Agent returned error: ${output.message}`) + } else { + validationErrors.push( + `Expected structuredOutput, got: ${output?.type ?? 'null'}`, + ) + } + + const trace = { + task: { + name: task.name, + prompt: task.prompt, + repoUrl: task.repoUrl, + }, + duration: `${duration}s`, + output, + validationErrors, + eventCount: events.length, + events, + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const traceFile = path.join(TRACE_DIR, `${timestamp}_${task.name}.json`) + fs.writeFileSync(traceFile, JSON.stringify(trace, null, 2)) + + const success = validationErrors.length === 0 + + console.log(`\n${'─'.repeat(60)}`) + console.log(`Result: ${success ? '✅ SUCCESS' : '❌ FAILURE'}`) + console.log(`Duration: ${duration}s`) + console.log(`Events: ${events.length}`) + console.log(`Trace: ${traceFile}`) + + if (validationErrors.length > 0) { + console.log(`Validation Errors:`) + for (const err of validationErrors) { + console.log(` ❌ ${err}`) + } + } + + if ( + output?.type === 'structuredOutput' && + output.value !== null + ) { + const data = output.value as LibrarianOutput + console.log(`Answer length: ${data.answer?.length ?? 0} chars`) + console.log(`Relevant files: ${data.relevantFiles?.length ?? 0}`) + console.log(`Clone dir: ${data.cloneDir}`) + } + console.log(`${'─'.repeat(60)}`) + + // Clean up the cloned repo after validation + if ( + output?.type === 'structuredOutput' && + output.value !== null + ) { + const data = output.value as LibrarianOutput + if (data.cloneDir && fs.existsSync(data.cloneDir)) { + console.log(`Cleaning up ${data.cloneDir}...`) + fs.rmSync(data.cloneDir, { recursive: true, force: true }) + } + } + + return { success, traceFile, output, validationErrors } +} + +async function main() { + fs.mkdirSync(TRACE_DIR, { recursive: true }) + + const taskIndexArg = process.argv[2] + const tasksToRun = + taskIndexArg !== undefined + ? [ + { + task: TASKS[parseInt(taskIndexArg, 10)], + index: parseInt(taskIndexArg, 10), + }, + ] + : TASKS.map((task, index) => ({ task, index })) + + if (tasksToRun.some((t) => !t.task)) { + console.error( + `Invalid task index: ${taskIndexArg}. Available: 0-${TASKS.length - 1}`, + ) + process.exit(1) + } + + const agents = await loadLocalAgents({ + agentsPath: path.join(process.cwd(), 'agents'), + verbose: true, + }) + const agentDefinitions = Object.values(agents) as AgentDefinition[] + + const librarianAgent = agentDefinitions.find((a) => a.id === 'librarian') + if (!librarianAgent) { + console.error('librarian agent not found in agents/ directory') + process.exit(1) + } + console.log(`Loaded librarian agent (model: ${librarianAgent.model})`) + + const client = new CodebuffClient({ + apiKey: process.env.CODEBUFF_API_KEY, + cwd: process.cwd(), + }) + + const results: Array<{ + name: string + success: boolean + traceFile: string + validationErrors: string[] + }> = [] + + for (const { task, index } of tasksToRun) { + const result = await runTask(client, task, agentDefinitions, index) + results.push({ + name: task.name, + success: result.success, + traceFile: result.traceFile, + validationErrors: result.validationErrors, + }) + } + + console.log(`\n${'='.repeat(60)}`) + console.log('SUMMARY') + console.log(`${'='.repeat(60)}`) + for (const r of results) { + console.log(` ${r.success ? '✅' : '❌'} ${r.name} → ${r.traceFile}`) + if (r.validationErrors.length > 0) { + for (const err of r.validationErrors) { + console.log(` ❌ ${err}`) + } + } + } + const passed = results.filter((r) => r.success).length + console.log(`\n${passed}/${results.length} tasks passed`) + + if (passed < results.length) { + process.exit(1) + } +} + +if (import.meta.main) { + main().catch((err) => { + console.error('Fatal error:', err) + process.exit(1) + }) +} diff --git a/agents/librarian/librarian.ts b/agents/librarian/librarian.ts new file mode 100644 index 0000000000..69dd157181 --- /dev/null +++ b/agents/librarian/librarian.ts @@ -0,0 +1,155 @@ +import { publisher } from '../constants' + +import type { + AgentDefinition, + AgentStepContext, +} from '../types/agent-definition' + +const librarian: AgentDefinition = { + id: 'librarian', + publisher, + displayName: 'Librarian', + model: 'minimax/minimax-m2.5', + + spawnerPrompt: + 'Spawn the librarian agent to shallow-clone a GitHub repository into /tmp and answer questions about its code, structure, or documentation. The agent returns structured output with `answer`, `relevantFiles` (absolute paths in the cloned repo), and `cloneDir`. You can use `run_terminal_command` with `cat` to read the returned `relevantFiles` paths. Clean up `cloneDir` with `rm -rf` when done.', + + inputSchema: { + prompt: { + type: 'string', + description: 'Question to answer about the cloned repository', + }, + params: { + type: 'object', + properties: { + repoUrl: { + type: 'string', + description: + 'GitHub repository URL to clone (e.g. https://github.com/owner/repo)', + }, + }, + required: ['repoUrl'], + }, + }, + + outputMode: 'structured_output', + outputSchema: { + type: 'object', + properties: { + answer: { + type: 'string', + description: 'Full answer to the question about the repository', + }, + relevantFiles: { + type: 'array', + items: { type: 'string' }, + description: 'Absolute file paths in the cloned repo that are relevant to the answer', + }, + cloneDir: { + type: 'string', + description: 'The clone directory path so the caller can read files or clean up', + }, + }, + required: ['answer', 'relevantFiles', 'cloneDir'], + }, + includeMessageHistory: false, + + toolNames: [ + 'run_terminal_command', + 'set_output', + ], + + systemPrompt: `You are the Librarian, an expert at quickly understanding codebases. You have been given access to a freshly cloned repository in a /tmp directory. Your job is to explore its structure, read relevant files, and answer the user's question thoroughly and accurately. + +CRITICAL RULES: +- The cloned repo is OUTSIDE the project directory in /tmp. +- You MUST use run_terminal_command for ALL file operations. Use shell commands like: + - \`ls -la \` or \`tree -L 2 \` to list directory contents + - \`cat \` to read file contents + - \`head -100 \` to preview large files + - \`find -name '*.ts' -type f\` to find files by pattern + - \`grep -rn 'pattern' --include='*.ts'\` to search file contents + - \`wc -l \` to check file sizes +- NEVER copy files from /tmp into the project directory. This will overwrite project files and cause damage. +- NEVER modify files in the project directory. + +When exploring a repo: +- Start with \`ls -la\` and \`cat README.md\` (or similar) at the repo root +- Check package.json, pyproject.toml, Cargo.toml, or similar entry points with \`cat\` +- Use \`find\` and \`grep\` to search for specific patterns or files +- Read the most relevant files with \`cat\` +- Provide clear, well-structured answers with references to specific files + +When you are done, call set_output with your answer, all relevant file paths (absolute), and the cloneDir. Include every file you read or referenced in relevantFiles.`, + + instructionsPrompt: `Answer the user's question about the cloned repository. Be thorough but concise. Reference specific files and code when relevant. When finished, call set_output with your answer, relevantFiles, and cloneDir.`, + + handleSteps: function* ({ prompt, params, logger }: AgentStepContext) { + const repoUrl = params?.repoUrl + if (!repoUrl) { + yield { + toolName: 'set_output', + input: { + message: + 'Error: repoUrl is required. Provide a GitHub repository URL in params.', + }, + } + return + } + + const timestamp = Date.now() + const repoName = + String(repoUrl).split('/').pop()?.replace(/\.git$/, '') || 'repo' + const cloneDir = '/tmp/librarian-' + repoName + '-' + timestamp + + logger.info('Cloning ' + repoUrl + ' into ' + cloneDir) + + const { toolResult } = yield { + toolName: 'run_terminal_command', + input: { + command: + "git clone --depth 1 '" + repoUrl + "' '" + cloneDir + "'", + timeout_seconds: 180, + }, + } + + const result = toolResult?.[0] + if (result && result.type === 'json') { + const value = result.value as Record + const exitCode = + typeof value?.exitCode === 'number' ? value.exitCode : undefined + if (exitCode !== 0) { + const stderr = + typeof value?.stderr === 'string' ? value.stderr : 'Unknown error' + logger.error('Clone failed: ' + stderr) + yield { + toolName: 'set_output', + input: { + message: 'Failed to clone repository: ' + stderr, + }, + } + return + } + } + + logger.info('Clone complete. Exploring repo...') + + yield { + toolName: 'add_message', + input: { + role: 'user', + content: + 'The repository has been cloned to `' + + cloneDir + + '`. Use run_terminal_command with shell commands (ls, cat, find, grep, head, tree) to explore it. Do NOT use read_files, list_directory, glob, or code_search — they cannot access /tmp paths. Do NOT copy files into the project directory.\n\nNow answer this question about the repo:\n\n' + + (prompt || 'Provide an overview of this repository.') + + '\n\nWhen done, call set_output with your answer, relevantFiles (absolute paths), and cloneDir: "' + cloneDir + '".', + }, + includeToolCall: false, + } + + yield 'STEP_ALL' + }, +} + +export default librarian diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 793dd121a2..bb9bcd7fd4 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -66,6 +66,7 @@ import { loadLocalAgents } from './utils/local-agent-registry' import { logger } from './utils/logger' import { addClipboardPlaceholder, + addPendingFileFromPath, addPendingImageFromFile, validateAndAddImage, } from './utils/pending-attachments' @@ -1133,6 +1134,9 @@ export const Chat = ({ showClipboardMessage('Failed to add image', { durationMs: 3000 }) }) }, + onPasteFilePath: (filePath: string, isDirectory: boolean) => { + addPendingFileFromPath(filePath, isDirectory) + }, onPasteText: (text: string) => { setInputValue((prev) => { const before = prev.text.slice(0, prev.cursorPosition) @@ -1494,6 +1498,7 @@ export const Chat = ({ onChange: setInputValue, onPasteImage: chatKeyboardHandlers.onPasteImage, onPasteImagePath: chatKeyboardHandlers.onPasteImagePath, + onPasteFilePath: chatKeyboardHandlers.onPasteFilePath, onPasteLongText: (pastedText) => { const id = crypto.randomUUID() const preview = pastedText.slice(0, 100).replace(/\n/g, ' ') diff --git a/cli/src/commands/router.ts b/cli/src/commands/router.ts index 126531e09d..b0c8b9915c 100644 --- a/cli/src/commands/router.ts +++ b/cli/src/commands/router.ts @@ -32,6 +32,7 @@ import { getSystemProcessEnv } from '../utils/env' import { getSystemMessage, getUserMessage } from '../utils/message-history' import { capturePendingAttachments, + hasProcessingFiles, hasProcessingImages, validateAndAddImage, } from '../utils/pending-attachments' @@ -522,9 +523,9 @@ export async function routeUserPrompt( // Regular message or unknown slash command - send to agent - // Block sending if images are still processing - if (hasProcessingImages()) { - showClipboardMessage('processing images...', { + // Block sending if attachments are still processing + if (hasProcessingImages() || hasProcessingFiles()) { + showClipboardMessage('processing attachments...', { durationMs: 2000, }) return diff --git a/cli/src/components/blocks/agent-branch-item.tsx b/cli/src/components/blocks/agent-branch-item.tsx index 95a9dafda8..90573fe51c 100644 --- a/cli/src/components/blocks/agent-branch-item.tsx +++ b/cli/src/components/blocks/agent-branch-item.tsx @@ -288,18 +288,20 @@ export const AgentBranchItem = memo((props: AgentBranchItemProps) => { )} {isStreaming && isExpanded && ( - - - + + + + )} diff --git a/cli/src/components/file-attachment-card.tsx b/cli/src/components/file-attachment-card.tsx new file mode 100644 index 0000000000..d30f64a97b --- /dev/null +++ b/cli/src/components/file-attachment-card.tsx @@ -0,0 +1,98 @@ +import { AttachmentCard } from './attachment-card' +import { useTheme } from '../hooks/use-theme' + +import type { FileAttachment } from '../types/chat' +import type { PendingFileAttachment } from '../types/store' + +const FILE_CARD_WIDTH = 20 +const MAX_FILENAME_LENGTH = 16 + +const FILE_ICON_LINES = [ + ' ┌───╮', + ' │ ≡ │', + ' └───╯', +] + +const FOLDER_ICON_LINES = [ + ' ╭──╮ ', + ' │ ╰──╮', + ' ╰─────╯', +] + +const truncateFilename = (filename: string): string => { + if (filename.length <= MAX_FILENAME_LENGTH) return filename + // Find extension — ignore leading dot (dotfiles like .gitignore) + const lastDot = filename.lastIndexOf('.') + const hasExtension = lastDot > 0 + const ext = hasExtension ? filename.slice(lastDot) : '' + const baseName = hasExtension ? filename.slice(0, lastDot) : filename + const maxBaseLength = MAX_FILENAME_LENGTH - ext.length - 1 // -1 for ellipsis + if (maxBaseLength <= 0) return filename.slice(0, MAX_FILENAME_LENGTH - 1) + '…' + return baseName.slice(0, maxBaseLength) + '…' + ext +} + +interface FileAttachmentCardProps { + attachment: PendingFileAttachment | FileAttachment + onRemove?: () => void + showRemoveButton?: boolean +} + +export const FileAttachmentCard = ({ + attachment, + onRemove, + showRemoveButton = true, +}: FileAttachmentCardProps) => { + const theme = useTheme() + const iconLines = attachment.isDirectory ? FOLDER_ICON_LINES : FILE_ICON_LINES + const truncatedName = truncateFilename(attachment.filename) + const status = 'status' in attachment ? attachment.status : undefined + + return ( + + {/* ASCII art icon area */} + + + {iconLines.join('\n')} + + + + {/* Filename and note */} + + + {truncatedName} + + {(status === 'processing' || attachment.note) && ( + + {status === 'processing' ? 'reading…' : attachment.note} + + )} + + + ) +} diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 90fbc89533..d9f9fe27cb 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -4,6 +4,7 @@ import { memo, useState } from 'react' import { BlocksRenderer } from './blocks/blocks-renderer' import { UserContentWithCopyButton } from './blocks/user-content-copy' import { Button } from './button' +import { FileAttachmentCard } from './file-attachment-card' import { ImageCard } from './image-card' import { MessageFooter } from './message-footer' import { TextAttachmentCard } from './text-attachment-card' @@ -19,6 +20,7 @@ import type { FeedbackCategory } from '@codebuff/common/constants/feedback' import type { ContentBlock, + FileAttachment, ImageAttachment, TextAttachment, ChatMessageMetadata, @@ -58,6 +60,7 @@ interface MessageBlockProps { }) => void attachments?: ImageAttachment[] textAttachments?: TextAttachment[] + fileAttachments?: FileAttachment[] metadata?: ChatMessageMetadata isLastMessage?: boolean } @@ -65,11 +68,13 @@ interface MessageBlockProps { const MessageAttachments = memo(({ imageAttachments, textAttachments, + fileAttachments, }: { imageAttachments: ImageAttachment[] textAttachments: TextAttachment[] + fileAttachments: FileAttachment[] }) => { - if (imageAttachments.length === 0 && textAttachments.length === 0) { + if (imageAttachments.length === 0 && textAttachments.length === 0 && fileAttachments.length === 0) { return null } @@ -95,6 +100,13 @@ const MessageAttachments = memo(({ showRemoveButton={false} /> ))} + {fileAttachments.map((attachment) => ( + + ))} ) }) @@ -127,6 +139,7 @@ export const MessageBlock = memo(({ onOpenFeedback, attachments, textAttachments, + fileAttachments, metadata, isLastMessage, }: MessageBlockProps) => { @@ -301,10 +314,12 @@ export const MessageBlock = memo(({ {/* Show attachments for user messages */} {isUser && ((attachments && attachments.length > 0) || - (textAttachments && textAttachments.length > 0)) && ( + (textAttachments && textAttachments.length > 0) || + (fileAttachments && fileAttachments.length > 0)) && ( )} diff --git a/cli/src/components/message-with-agents.tsx b/cli/src/components/message-with-agents.tsx index b67923fa34..844b1045e2 100644 --- a/cli/src/components/message-with-agents.tsx +++ b/cli/src/components/message-with-agents.tsx @@ -268,6 +268,7 @@ export const MessageWithAgents = memo( onOpenFeedback={onOpenFeedback} attachments={message.attachments} textAttachments={message.textAttachments} + fileAttachments={message.fileAttachments} metadata={message.metadata} isLastMessage={isLastMessage} /> @@ -303,6 +304,7 @@ export const MessageWithAgents = memo( onOpenFeedback={onOpenFeedback} attachments={message.attachments} textAttachments={message.textAttachments} + fileAttachments={message.fileAttachments} metadata={message.metadata} isLastMessage={isLastMessage} /> diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index 3ef65afdf4..23387c4b86 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -1,5 +1,5 @@ import { TextAttributes } from '@opentui/core' -import { useKeyboard, useRenderer } from '@opentui/react' +import { useAppContext, useKeyboard, useRenderer } from '@opentui/react' import { forwardRef, useCallback, @@ -20,6 +20,7 @@ import type { InputValue } from '../types/store' import type { KeyEvent, MouseEvent, + PasteEvent, ScrollBoxRenderable, TextBufferView, TextRenderable, @@ -189,6 +190,8 @@ export const MultilineInput = forwardRef< ) { const theme = useTheme() const renderer = useRenderer() + const appContext = useAppContext() + const { keyHandler } = appContext const hookBlinkValue = useChatStore((state) => state.isFocusSupported) const effectiveShouldBlinkCursor = shouldBlinkCursor ?? hookBlinkValue @@ -1005,6 +1008,50 @@ export const MultilineInput = forwardRef< [insertTextAtCursor], ) + // Increase StdinParser timeout from default 10ms to 100ms. + // Some terminals (Ghostty, iTerm2, VS Code) split bracketed paste sequences + // across multiple stdin reads when drag-dropping files. The default 10ms + // timeout causes the parser to flush partial escape sequences as keypresses, + // corrupting paste detection. 100ms is still fast for keyboard input but + // gives enough time for split paste sequences to arrive. + useEffect(() => { + const cliRenderer = appContext.renderer as Record | null + const stdinBuffer = cliRenderer?._stdinBuffer as Record | undefined + if (stdinBuffer && typeof stdinBuffer.timeoutMs === 'number') { + stdinBuffer.timeoutMs = 100 + } + }, [appContext]) + + // Global paste event listener — catches paste events (e.g. from drag-and-drop) + // at the global level, plus a scrollbox-level backup. Some terminals may not + // deliver paste events reliably via one mechanism alone, so we use both with + // dedup to prevent double-handling. + const onPasteRef = useRef(onPaste) + onPasteRef.current = onPaste + const pasteHandledRef = useRef(false) + + // Always listen for paste events regardless of terminal focus state. + // Drag-and-drop inherently causes the terminal to lose focus (the file + // manager has focus during the drag), so the paste listener must stay + // active even when `focused` is false. + useEffect(() => { + if (!keyHandler) return + + const handlePaste = (event: PasteEvent) => { + pasteHandledRef.current = true + onPasteRef.current(event.text) + // Reset dedup flag after microtask so scrollbox handler (which fires + // synchronously after global listeners) sees it as handled, but future + // paste events are not blocked. + queueMicrotask(() => { pasteHandledRef.current = false }) + } + + keyHandler.on('paste', handlePaste) + return () => { + keyHandler.off('paste', handlePaste) + } + }, [keyHandler]) + // Main keyboard handler - delegates to specialized handlers useKeyboard( useCallback( @@ -1087,7 +1134,12 @@ export const MultilineInput = forwardRef< visible: showScrollbar && layoutMetrics.isScrollable, trackOptions: { width: 1 }, }} - onPaste={(event) => onPaste(event.text)} + onPaste={(event) => { + // Backup paste handler: fires if the global keyHandler listener + // didn't catch this event (dedup prevents double-handling) + if (pasteHandledRef.current) return + onPasteRef.current(event.text) + }} onMouseDown={handleMouseDown} style={{ flexGrow: 0, diff --git a/cli/src/components/pending-attachments-banner.tsx b/cli/src/components/pending-attachments-banner.tsx index 9f7240ac81..f7582dcea7 100644 --- a/cli/src/components/pending-attachments-banner.tsx +++ b/cli/src/components/pending-attachments-banner.tsx @@ -1,10 +1,15 @@ import { BottomBanner } from './bottom-banner' +import { FileAttachmentCard } from './file-attachment-card' import { ImageCard } from './image-card' import { TextAttachmentCard } from './text-attachment-card' import { useTheme } from '../hooks/use-theme' import { useChatStore } from '../state/chat-store' -import type { PendingImageAttachment, PendingTextAttachment } from '../types/store' +import type { + PendingFileAttachment, + PendingImageAttachment, + PendingTextAttachment, +} from '../types/store' /** * Combined banner for both image and text attachments. @@ -24,6 +29,9 @@ export const PendingAttachmentsBanner = () => { const pendingTextAttachments = pendingAttachments.filter( (a): a is PendingTextAttachment => a.kind === 'text', ) + const pendingFileAttachments = pendingAttachments.filter( + (a): a is PendingFileAttachment => a.kind === 'file', + ) // Separate error messages from actual images const errorImages: PendingImageAttachment[] = [] @@ -38,10 +46,11 @@ export const PendingAttachmentsBanner = () => { const hasValidImages = validImages.length > 0 const hasTextAttachments = pendingTextAttachments.length > 0 - const hasErrorsOnly = errorImages.length > 0 && !hasValidImages && !hasTextAttachments + const hasFileAttachments = pendingFileAttachments.length > 0 + const hasErrorsOnly = errorImages.length > 0 && !hasValidImages && !hasTextAttachments && !hasFileAttachments // Nothing to show - if (!hasValidImages && !hasTextAttachments && errorImages.length === 0) { + if (!hasValidImages && !hasTextAttachments && !hasFileAttachments && errorImages.length === 0) { return null } @@ -92,6 +101,15 @@ export const PendingAttachmentsBanner = () => { onRemove={() => removePendingAttachment(attachment.id)} /> ))} + + {/* File/folder attachment cards */} + {pendingFileAttachments.map((attachment) => ( + removePendingAttachment(attachment.path)} + /> + ))} ) diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index 9755bda013..db204849f5 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -26,6 +26,7 @@ import { usageQueryKeys } from '../use-usage-query' import type { PendingAttachment, + PendingFileAttachment, PendingImageAttachment, PendingTextAttachment, } from '../../types/store' @@ -144,6 +145,10 @@ export const prepareUserMessage = async (params: { (a): a is PendingTextAttachment => a.kind === 'text', ) + const pendingFileAttachments = allAttachments.filter( + (a): a is PendingFileAttachment => a.kind === 'file', + ) + // Append text attachments to the content let finalContent = content if (pendingTextAttachments.length > 0) { @@ -155,6 +160,23 @@ export const prepareUserMessage = async (params: { : textAttachmentContent } + // Append file/folder attachments to the content + if (pendingFileAttachments.length > 0) { + const fileAttachmentContent = pendingFileAttachments + .filter((att) => att.status === 'ready') + .map((att) => + att.isDirectory + ? `[Directory: ${att.path}]\n${att.content}` + : `[File: ${att.path}]\n${att.content}`, + ) + .join('\n\n') + if (fileAttachmentContent) { + finalContent = finalContent + ? `${finalContent}\n\n${fileAttachmentContent}` + : fileAttachmentContent + } + } + const { attachments: imageAttachments, messageContent } = await processImagesForMessage({ content: finalContent, pendingImages, @@ -172,8 +194,18 @@ export const prepareUserMessage = async (params: { charCount: att.charCount, })) + // Convert pending file attachments to stored file attachments for display + const fileAttachmentsForMessage = pendingFileAttachments + .filter((att) => att.status === 'ready') + .map((att) => ({ + path: att.path, + filename: att.filename, + isDirectory: att.isDirectory, + note: att.note, + })) + // Pass original content (not finalContent) for display, but finalContent goes to agent - const userMessage = getUserMessage(content, imageAttachments, textAttachmentsForMessage) + const userMessage = getUserMessage(content, imageAttachments, textAttachmentsForMessage, fileAttachmentsForMessage) const userMessageId = userMessage.id if (imageAttachments.length > 0) { userMessage.attachments = imageAttachments diff --git a/cli/src/hooks/use-chat-keyboard.ts b/cli/src/hooks/use-chat-keyboard.ts index ebd71a8b54..e770cdac8d 100644 --- a/cli/src/hooks/use-chat-keyboard.ts +++ b/cli/src/hooks/use-chat-keyboard.ts @@ -1,9 +1,12 @@ +import { statSync } from 'fs' + import { useKeyboard } from '@opentui/react' import { useCallback, useRef } from 'react' import { getProjectRoot } from '../project-files' import { reportActivity } from '../utils/activity-tracker' -import { hasClipboardImage, readClipboardText, readClipboardImageFilePath, getImageFilePathFromText } from '../utils/clipboard-image' +import { hasClipboardImage, readClipboardText, readClipboardFilePath, getImageFilePathFromText } from '../utils/clipboard-image' +import { isImageFile } from '../utils/image-handler' import { resolveChatKeyboardAction, type ChatKeyboardState, @@ -73,6 +76,7 @@ export type ChatKeyboardHandlers = { // Clipboard handlers onPasteImage: () => void onPasteImagePath: (imagePath: string) => void + onPasteFilePath: (filePath: string, isDirectory: boolean) => void onPasteText: (text: string) => void // Scroll handlers @@ -201,18 +205,29 @@ function dispatchAction( case 'paste': { const cwd = getProjectRoot() ?? process.cwd() - // First, check if clipboard contains a copied image file (e.g., from Finder) + // First, check if clipboard contains a copied file (e.g., from Finder) // This is different from text - it's when you Cmd+C a file in Finder - const copiedImagePath = readClipboardImageFilePath() - if (copiedImagePath) { - handlers.onPasteImagePath(copiedImagePath) - return true + const copiedFilePath = readClipboardFilePath() + if (copiedFilePath) { + if (isImageFile(copiedFilePath)) { + handlers.onPasteImagePath(copiedFilePath) + return true + } + // Non-image file or directory + try { + const fileStats = statSync(copiedFilePath) + handlers.onPasteFilePath(copiedFilePath, fileStats.isDirectory()) + return true + } catch { + // Fall through to other paste handlers + } } // Next, read clipboard text to check if it's a file path // This handles the case where a file is dragged/dropped - we want to use // the file path, not any stale image data that might be in the clipboard - const text = readClipboardText() + const rawText = readClipboardText() + const text = rawText ? Bun.stripANSI(rawText) : null if (text) { // Check if the text is a path to an image file const imagePath = getImageFilePathFromText(text, cwd) diff --git a/cli/src/hooks/use-clipboard.ts b/cli/src/hooks/use-clipboard.ts index a67c916b90..daf05ca907 100644 --- a/cli/src/hooks/use-clipboard.ts +++ b/cli/src/hooks/use-clipboard.ts @@ -4,7 +4,9 @@ import { useEffect, useRef, useState } from 'react' import { CURSOR_CHAR } from '../components/multiline-input' import { copyTextToClipboard, + registerClipboardRenderer, subscribeClipboardMessages, + unregisterClipboardRenderer, } from '../utils/clipboard' function formatDefaultClipboardMessage(text: string): string | null { @@ -30,6 +32,18 @@ export const useClipboard = () => { return subscribeClipboardMessages(setStatusMessage) }, []) + // Register the renderer globally so all copyTextToClipboard callers + // can use the renderer's OSC 52 method when available. + useEffect(() => { + if (renderer) { + registerClipboardRenderer(renderer as unknown as Record) + return () => { + unregisterClipboardRenderer() + } + } + return undefined + }, [renderer]) + useEffect(() => { const handleSelection = (selectionEvent: any) => { const selectionObj = selectionEvent ?? (renderer as any)?.getSelection?.() diff --git a/cli/src/hooks/use-logo.tsx b/cli/src/hooks/use-logo.tsx index d777a6b325..4c1251f924 100644 --- a/cli/src/hooks/use-logo.tsx +++ b/cli/src/hooks/use-logo.tsx @@ -1,16 +1,8 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useMemo } from 'react' import { LOGO, LOGO_SMALL, SHADOW_CHARS } from '../login/constants' import { parseLogoLines } from '../login/utils' import { IS_FREEBUFF } from '../utils/constants' -import { useTheme } from './use-theme' - -const SUBTITLE_SHIMMER_STEPS = 10 -const SUBTITLE_SHIMMER_INTERVAL_MS = 180 -const SUBTITLE_SHIMMER_COLORS = { - dark: { base: '#9EFC62', bright: '#CCFF99', peak: '#ffffff' }, - light: { base: '#65A83E', bright: '#88D458', peak: '#ffffff' }, -} as const interface UseLogoOptions { /** @@ -145,54 +137,5 @@ export const useLogo = ({ ) }, [rawLogoString, availableWidth, applySheenToChar, textColor, accentColor, blockColor]) - // Freebuff subtitle: "The free coding agent" with shimmer wave on "free" - const theme = useTheme() - const [shimmerPos, setShimmerPos] = useState(0) - - useEffect(() => { - if (!IS_FREEBUFF) return - const interval = setInterval(() => { - setShimmerPos(prev => (prev + 1) % SUBTITLE_SHIMMER_STEPS) - }, SUBTITLE_SHIMMER_INTERVAL_MS) - return () => clearInterval(interval) - }, []) - - const componentWithSubtitle = useMemo(() => { - if (!IS_FREEBUFF) return component - - const colors = SUBTITLE_SHIMMER_COLORS[theme.name] ?? SUBTITLE_SHIMMER_COLORS.dark - - // Calculate logo width to center the subtitle - const subtitleText = 'The free coding agent' - const logoLines = rawLogoString === 'CODEBUFF' || rawLogoString === 'FREEBUFF' - ? [rawLogoString] - : parseLogoLines(rawLogoString).map((line) => line.slice(0, availableWidth)) - const logoWidth = Math.max(...logoLines.map((l) => l.length)) - const padding = Math.max(0, Math.floor((logoWidth - subtitleText.length) / 2)) - const pad = ' '.repeat(padding) - - const subtitle = ( - - {pad} - The - - {'free'.split('').map((char, i) => { - const distance = Math.abs(shimmerPos - 1 - i) - const color = distance === 0 ? colors.peak : distance === 1 ? colors.bright : colors.base - return {char} - })} - - coding agent - - ) - - return ( - <> - {component} - {subtitle} - - ) - }, [component, shimmerPos, theme.name, theme.foreground, rawLogoString, availableWidth]) - - return { component: componentWithSubtitle, textBlock } + return { component, textBlock } } diff --git a/cli/src/state/chat-store.ts b/cli/src/state/chat-store.ts index dbbb843047..42913a5d5a 100644 --- a/cli/src/state/chat-store.ts +++ b/cli/src/state/chat-store.ts @@ -21,6 +21,7 @@ import type { PendingImageStatus, PendingImageAttachment, PendingTextAttachment, + PendingFileAttachment, PendingAttachment, PendingImage, PendingBashMessage, @@ -39,6 +40,7 @@ export type { PendingImageStatus, PendingImageAttachment, PendingTextAttachment, + PendingFileAttachment, PendingAttachment, PendingImage, PendingBashMessage, @@ -152,6 +154,7 @@ type ChatStoreActions = { addPendingTextAttachment: (attachment: Omit) => void removePendingTextAttachment: (id: string) => void clearPendingTextAttachments: () => void + addPendingFileAttachment: (attachment: Omit) => void addPendingBashMessage: (message: PendingBashMessage) => void updatePendingBashMessage: ( id: string, @@ -330,10 +333,10 @@ export const useChatStore = create()( addPendingAttachment: (attachment) => set((state) => { - // Don't add duplicates - const id = attachment.kind === 'image' ? attachment.path : attachment.id + // Don't add duplicates — use path for image/file, id for text + const id = attachment.kind === 'text' ? attachment.id : attachment.path const isDuplicate = state.pendingAttachments.some((a) => - a.kind === 'image' ? a.path === id : a.id === id, + a.kind === 'text' ? a.id === id : a.path === id, ) if (!isDuplicate) { state.pendingAttachments.push(attachment) @@ -343,7 +346,7 @@ export const useChatStore = create()( removePendingAttachment: (id) => set((state) => { state.pendingAttachments = state.pendingAttachments.filter((a) => - a.kind === 'image' ? a.path !== id : a.id !== id, + a.kind === 'text' ? a.id !== id : a.path !== id, ) }), @@ -392,6 +395,10 @@ export const useChatStore = create()( ) }), + addPendingFileAttachment: (attachment) => { + useChatStore.getState().addPendingAttachment({ ...attachment, kind: 'file' }) + }, + updateAskUserAnswer: (questionIndex, optionIndex) => set((state) => { if (!state.askUserState) return diff --git a/cli/src/types/chat.ts b/cli/src/types/chat.ts index b8f0946273..248b606550 100644 --- a/cli/src/types/chat.ts +++ b/cli/src/types/chat.ts @@ -133,6 +133,13 @@ export type TextAttachment = { charCount: number } +export type FileAttachment = { + path: string + filename: string + isDirectory: boolean + note?: string +} + export type ContentBlock = | AgentContentBlock | AgentListContentBlock @@ -184,6 +191,7 @@ export type ChatMessage = { userError?: string attachments?: ImageAttachment[] textAttachments?: TextAttachment[] + fileAttachments?: FileAttachment[] } // Type guard functions for safe type narrowing diff --git a/cli/src/types/store.ts b/cli/src/types/store.ts index c6a44bd14f..516b903ce1 100644 --- a/cli/src/types/store.ts +++ b/cli/src/types/store.ts @@ -61,8 +61,20 @@ export type PendingTextAttachment = { charCount: number } +/** File or folder attachment (dragged or copied from file manager) */ +export type PendingFileAttachment = { + kind: 'file' + id: string + path: string + filename: string + isDirectory: boolean + content: string + status: 'processing' | 'ready' | 'error' + note?: string // e.g. "3.2 KB" / "12 items" / error message +} + /** Unified attachment type with discriminator */ -export type PendingAttachment = PendingImageAttachment | PendingTextAttachment +export type PendingAttachment = PendingImageAttachment | PendingTextAttachment | PendingFileAttachment /** @deprecated Use PendingImageAttachment instead */ export type PendingImage = PendingImageAttachment diff --git a/cli/src/utils/__tests__/clipboard.test.ts b/cli/src/utils/__tests__/clipboard.test.ts index 3fc46ac131..e977f3f9f4 100644 --- a/cli/src/utils/__tests__/clipboard.test.ts +++ b/cli/src/utils/__tests__/clipboard.test.ts @@ -8,6 +8,8 @@ import { showClipboardMessage, subscribeClipboardMessages, clearClipboardMessage, + registerClipboardRenderer, + unregisterClipboardRenderer, } from '../clipboard' import { logger } from '../logger' @@ -399,6 +401,139 @@ describe('clipboard', () => { }) }) + describe('registerClipboardRenderer and renderer-based copy', () => { + let originalPlatform: PropertyDescriptor | undefined + let originalEnv: Record + let loggerErrorSpy: ReturnType + + beforeEach(() => { + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + originalEnv = { + SSH_CLIENT: process.env.SSH_CLIENT, + SSH_TTY: process.env.SSH_TTY, + SSH_CONNECTION: process.env.SSH_CONNECTION, + TERM: process.env.TERM, + TMUX: process.env.TMUX, + STY: process.env.STY, + } + loggerErrorSpy = spyOn(logger, 'error').mockImplementation(() => {}) + + // Use freebsd + dumb terminal to disable platform tools and OSC52, + // isolating the renderer path. + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + delete process.env.SSH_CLIENT + delete process.env.SSH_TTY + delete process.env.SSH_CONNECTION + process.env.TERM = 'dumb' + delete process.env.TMUX + delete process.env.STY + + clearClipboardMessage() + unregisterClipboardRenderer() + }) + + afterEach(() => { + unregisterClipboardRenderer() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + for (const [key, value] of Object.entries(originalEnv)) { + if (value !== undefined) process.env[key] = value + else delete process.env[key] + } + loggerErrorSpy.mockRestore() + clearClipboardMessage() + }) + + test('renderer with copyToClipboardOSC52 returning true succeeds', async () => { + const calls: string[] = [] + registerClipboardRenderer({ + copyToClipboardOSC52: (text: string) => { + calls.push(text) + return true + }, + }) + + await copyTextToClipboard('test text', { suppressGlobalMessage: true }) + + expect(calls).toEqual(['test text']) + }) + + test('renderer with copyToClipboardOSC52 returning false falls through and fails', async () => { + registerClipboardRenderer({ copyToClipboardOSC52: () => false }) + + await expect( + copyTextToClipboard('test text', { suppressGlobalMessage: true }) + ).rejects.toThrow('No clipboard method available') + }) + + test('renderer without copyToClipboardOSC52 falls through and fails', async () => { + registerClipboardRenderer({ someOtherMethod: () => true }) + + await expect( + copyTextToClipboard('test text', { suppressGlobalMessage: true }) + ).rejects.toThrow('No clipboard method available') + }) + + test('renderer whose copyToClipboardOSC52 throws falls through gracefully', async () => { + registerClipboardRenderer({ + copyToClipboardOSC52: () => { throw new Error('renderer error') }, + }) + + await expect( + copyTextToClipboard('test text', { suppressGlobalMessage: true }) + ).rejects.toThrow('No clipboard method available') + }) + + test('unregisterClipboardRenderer removes renderer so it is no longer used', async () => { + const calls: string[] = [] + registerClipboardRenderer({ + copyToClipboardOSC52: (text: string) => { + calls.push(text) + return true + }, + }) + unregisterClipboardRenderer() + + await expect( + copyTextToClipboard('test text', { suppressGlobalMessage: true }) + ).rejects.toThrow('No clipboard method available') + + expect(calls).toEqual([]) + }) + + test('renderer is tried in remote sessions (SSH) before manual OSC52', async () => { + // Set up as remote session + process.env.SSH_CLIENT = '192.168.1.100 54321 22' + process.env.TERM = 'xterm-256color' + + const calls: string[] = [] + registerClipboardRenderer({ + copyToClipboardOSC52: () => { + calls.push('renderer') + return true + }, + }) + + await copyTextToClipboard('test text', { suppressGlobalMessage: true }) + + expect(calls).toEqual(['renderer']) + }) + + test('shows success message when renderer copy succeeds', async () => { + registerClipboardRenderer({ copyToClipboardOSC52: () => true }) + + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + + await copyTextToClipboard('Hello world') + + expect(messages).toContain('Copied: "Hello world"') + + unsubscribe() + }) + }) + describe('copyTextToClipboard - SSH session detection behavior', () => { // These tests verify the copy behavior changes based on SSH environment variables. // In remote sessions (SSH), OSC52 is tried first; in local sessions, platform tools are tried first. diff --git a/cli/src/utils/__tests__/strings.test.ts b/cli/src/utils/__tests__/strings.test.ts index 67258adb73..e87d50e589 100644 --- a/cli/src/utils/__tests__/strings.test.ts +++ b/cli/src/utils/__tests__/strings.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from 'bun:test' -import { truncateToLines, MAX_COLLAPSED_LINES } from '../strings' +import { + truncateToLines, + MAX_COLLAPSED_LINES, + createTextPasteHandler, + createPasteHandler, + LONG_TEXT_THRESHOLD, +} from '../strings' + +import type { InputValue } from '../../types/store' describe('MAX_COLLAPSED_LINES', () => { test('is set to 3', () => { @@ -63,3 +71,122 @@ describe('truncateToLines', () => { expect(truncateToLines(text, 3)).toBe('line 1\nline 2\nline 3...') }) }) + +describe('createTextPasteHandler - ANSI stripping', () => { + test('strips ANSI escape sequences from pasted text', () => { + let result: InputValue | null = null + const handler = createTextPasteHandler('', 0, (value) => { result = value }) + + handler('\x1b[31mred text\x1b[0m') + + expect(result).not.toBeNull() + expect(result!.text).toBe('red text') + expect(result!.cursorPosition).toBe(8) + }) + + test('passes through plain text unchanged', () => { + let result: InputValue | null = null + const handler = createTextPasteHandler('', 0, (value) => { result = value }) + + handler('plain text') + + expect(result).not.toBeNull() + expect(result!.text).toBe('plain text') + }) + + test('strips complex ANSI sequences (bold, 256-color)', () => { + let result: InputValue | null = null + const handler = createTextPasteHandler('', 0, (value) => { result = value }) + + handler('\x1b[1m\x1b[38;5;196mbold colored\x1b[0m') + + expect(result).not.toBeNull() + expect(result!.text).toBe('bold colored') + }) + + test('does not insert when text is only ANSI codes (empty after stripping)', () => { + let result: InputValue | null = null + const handler = createTextPasteHandler('', 0, (value) => { result = value }) + + handler('\x1b[31m\x1b[0m') + + expect(result).toBeNull() + }) + + test('inserts stripped text at cursor position in existing text', () => { + let result: InputValue | null = null + const handler = createTextPasteHandler('hello world', 5, (value) => { result = value }) + + handler('\x1b[32m pasted\x1b[0m') + + expect(result).not.toBeNull() + expect(result!.text).toBe('hello pasted world') + expect(result!.cursorPosition).toBe(12) + }) +}) + +describe('createPasteHandler - ANSI stripping', () => { + test('strips ANSI from eventText for regular text paste', () => { + let result: InputValue | null = null + const handler = createPasteHandler({ + text: '', + cursorPosition: 0, + onChange: (value) => { result = value }, + }) + + handler('\x1b[31mhello\x1b[0m') + + expect(result).not.toBeNull() + expect(result!.text).toBe('hello') + expect(result!.cursorPosition).toBe(5) + }) + + test('strips ANSI from eventText before checking long text threshold', () => { + let longTextResult: string | null = null + const handler = createPasteHandler({ + text: '', + cursorPosition: 0, + onChange: () => {}, + onPasteLongText: (text) => { longTextResult = text }, + }) + + // Create text that is over threshold BEFORE stripping but under AFTER + const ansiOverhead = '\x1b[31m'.repeat(400) + '\x1b[0m'.repeat(400) + const shortContent = 'a'.repeat(100) + handler(ansiOverhead + shortContent) + + // Should NOT be treated as long text since stripped content is short + expect(longTextResult).toBeNull() + }) + + test('strips ANSI but preserves plain text content', () => { + let result: InputValue | null = null + const handler = createPasteHandler({ + text: 'existing ', + cursorPosition: 9, + onChange: (value) => { result = value }, + }) + + handler('\x1b[1m\x1b[34mblue bold text\x1b[0m') + + expect(result).not.toBeNull() + expect(result!.text).toBe('existing blue bold text') + expect(result!.cursorPosition).toBe(23) + }) + + test('long text handler receives stripped text', () => { + let longTextResult: string | null = null + const handler = createPasteHandler({ + text: '', + cursorPosition: 0, + onChange: () => {}, + onPasteLongText: (text) => { longTextResult = text }, + }) + + const longContent = 'x'.repeat(LONG_TEXT_THRESHOLD + 1) + handler(`\x1b[31m${longContent}\x1b[0m`) + + expect(longTextResult).not.toBeNull() + expect(longTextResult!).toBe(longContent) + }) +}) diff --git a/cli/src/utils/clipboard-image.ts b/cli/src/utils/clipboard-image.ts index 161ca14735..73c71b849d 100644 --- a/cli/src/utils/clipboard-image.ts +++ b/cli/src/utils/clipboard-image.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'child_process' -import { existsSync, mkdirSync, writeFileSync } from 'fs' +import { existsSync, mkdirSync, statSync, writeFileSync } from 'fs' import os from 'os' import path from 'path' @@ -310,6 +310,48 @@ export function readClipboardImage(): ClipboardImageResult { } } +/** + * Check if text looks like a single file path pointing to an existing non-image + * file or folder. Used to detect drag-drop of files/folders into the terminal. + * Returns the resolved path and whether it's a directory, or null. + */ +export function getFileOrFolderPathFromText(text: string, cwd: string): { path: string; isDirectory: boolean } | null { + // Must be single line + if (text.includes('\n') || text.includes('\r')) return null + + let trimmed = text.trim() + if (!trimmed) return null + + // Handle file:// URLs + if (trimmed.startsWith('file://')) { + trimmed = decodeURIComponent(trimmed.slice(7)) + } + + // Skip other URLs + if (trimmed.includes('://')) return null + + // Remove surrounding quotes + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + trimmed = trimmed.slice(1, -1) + } + + try { + const resolvedPath = resolveFilePath(trimmed, cwd) + if (!existsSync(resolvedPath)) return null + // Skip images — they're handled by image-specific logic + if (isImageFile(resolvedPath)) return null + + const stats = statSync(resolvedPath) + return { + path: resolvedPath, + isDirectory: stats.isDirectory(), + } + } catch { + return null + } +} + /** * Check if text looks like a single file path pointing to an existing image. * Used to detect drag-drop of image files into the terminal. diff --git a/cli/src/utils/clipboard.ts b/cli/src/utils/clipboard.ts index 9c723eaaf0..02d6f8eb28 100644 --- a/cli/src/utils/clipboard.ts +++ b/cli/src/utils/clipboard.ts @@ -4,6 +4,20 @@ import { createRequire } from 'module' import { getCliEnv } from './env' import { logger } from './logger' +// Global renderer reference for clipboard operations. +// Registered once by the useClipboard hook so all callers of +// copyTextToClipboard automatically benefit from renderer-based +// OSC 52 without threading the renderer through every call site. +let registeredRenderer: Record | null = null + +export function registerClipboardRenderer(renderer: Record): void { + registeredRenderer = renderer +} + +export function unregisterClipboardRenderer(): void { + registeredRenderer = null +} + const require = createRequire(import.meta.url) type ClipboardListener = (message: string | null) => void @@ -85,11 +99,13 @@ export async function copyTextToClipboard( try { let copied: boolean if (isRemoteSession()) { - // Remote/SSH: prefer OSC 52 (copies to client terminal's clipboard) - copied = tryCopyViaOsc52(text) || tryCopyViaPlatformTool(text) + // Remote/SSH: prefer renderer OSC 52 (through render pipeline), + // then our manual OSC 52, then platform tools + copied = tryCopyViaRenderer(text) || tryCopyViaOsc52(text) || tryCopyViaPlatformTool(text) } else { - // Local: prefer platform tools (reliable with tmux), OSC 52 as fallback - copied = tryCopyViaPlatformTool(text) || tryCopyViaOsc52(text) + // Local: prefer platform tools (reliable with tmux), + // then renderer OSC 52, then our manual OSC 52 as fallback + copied = tryCopyViaPlatformTool(text) || tryCopyViaRenderer(text) || tryCopyViaOsc52(text) } if (!copied) { @@ -161,6 +177,17 @@ function tryCopyViaPlatformTool(text: string): boolean { } } +function tryCopyViaRenderer(text: string): boolean { + if (!registeredRenderer) return false + const copyFn = registeredRenderer.copyToClipboardOSC52 + if (typeof copyFn !== 'function') return false + try { + return Boolean(copyFn.call(registeredRenderer, text)) + } catch { + return false + } +} + // 32KB is safe for all environments (tmux is the strictest) const OSC52_MAX_PAYLOAD = 32_000 diff --git a/cli/src/utils/message-history.ts b/cli/src/utils/message-history.ts index 1c6d8624e6..11c3497bf5 100644 --- a/cli/src/utils/message-history.ts +++ b/cli/src/utils/message-history.ts @@ -5,7 +5,7 @@ import { getConfigDir } from './auth' import { formatTimestamp } from './helpers' import { logger } from './logger' -import type { ChatMessage, ContentBlock, ImageAttachment, TextAttachment } from '../types/chat' +import type { ChatMessage, ContentBlock, FileAttachment, ImageAttachment, TextAttachment } from '../types/chat' const MAX_HISTORY_SIZE = 1000 @@ -13,6 +13,7 @@ export function getUserMessage( message: string | ContentBlock[], attachments?: ImageAttachment[], textAttachments?: TextAttachment[], + fileAttachments?: FileAttachment[], ): ChatMessage { return { id: `user-${Date.now()}`, @@ -28,6 +29,7 @@ export function getUserMessage( timestamp: formatTimestamp(), ...(attachments && attachments.length > 0 ? { attachments } : {}), ...(textAttachments && textAttachments.length > 0 ? { textAttachments } : {}), + ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), } } diff --git a/cli/src/utils/pending-attachments.ts b/cli/src/utils/pending-attachments.ts index 0d91113750..595bda3b94 100644 --- a/cli/src/utils/pending-attachments.ts +++ b/cli/src/utils/pending-attachments.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import path from 'node:path' import { processImageFile, resolveFilePath, isImageFile } from './image-handler' @@ -209,6 +209,124 @@ export async function validateAndAddImage( return { success: true } } +// --------------------------------------------------------------------------- +// File / folder attachments +// --------------------------------------------------------------------------- + +const MAX_FILE_READ_SIZE = 1024 * 1024 // 1 MB – don't read files larger than this +const MAX_CONTENT_CHARS = 100 * 1024 // 100 KB of text content +const MAX_DIR_ENTRIES = 100 + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb.toFixed(1)} KB` + const mb = kb / 1024 + return `${mb.toFixed(1)} MB` +} + +function isBinaryBuffer(buffer: Buffer): boolean { + const sampleSize = Math.min(buffer.length, 8192) + for (let i = 0; i < sampleSize; i++) { + if (buffer[i] === 0) return true + } + return false +} + +/** + * Add a file or folder as a pending attachment. + * Reads the content in the background and updates the store. + */ +export function addPendingFileFromPath( + filePath: string, + isDirectory: boolean, +): void { + const id = crypto.randomUUID() + const filename = path.basename(filePath) || filePath + + useChatStore.getState().addPendingFileAttachment({ + id, + path: filePath, + filename, + isDirectory, + content: '', + status: 'processing', + }) + + // Read content asynchronously (via setTimeout) so the UI shows immediately + setTimeout(() => { + try { + let content: string + let note: string + + if (isDirectory) { + const entries = readdirSync(filePath, { withFileTypes: true }) + const count = entries.length + note = `${count} item${count !== 1 ? 's' : ''}` + + if (count === 0) { + content = '(empty directory)' + } else { + // Sort: directories first, then files, alphabetically within each group + const sorted = [...entries].sort((a, b) => { + const aIsDir = a.isDirectory() + const bIsDir = b.isDirectory() + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1 + return a.name.localeCompare(b.name) + }) + const listing = sorted + .slice(0, MAX_DIR_ENTRIES) + .map((e) => (e.isDirectory() ? `${e.name}/` : e.name)) + .join('\n') + content = listing + if (count > MAX_DIR_ENTRIES) { + content += `\n… and ${count - MAX_DIR_ENTRIES} more` + } + } + } else { + const stats = statSync(filePath) + + if (stats.size === 0) { + content = '(empty file)' + note = '0 B' + } else if (stats.size > MAX_FILE_READ_SIZE) { + content = `(file too large to preview: ${formatFileSize(stats.size)})` + note = formatFileSize(stats.size) + } else { + const buffer = readFileSync(filePath) + if (isBinaryBuffer(buffer)) { + content = '(binary file)' + note = `${formatFileSize(stats.size)} (binary)` + } else { + const text = buffer.toString('utf-8') + if (text.length > MAX_CONTENT_CHARS) { + content = text.slice(0, MAX_CONTENT_CHARS) + '\n… (truncated)' + note = formatFileSize(stats.size) + } else { + content = text + note = formatFileSize(stats.size) + } + } + } + } + + useChatStore.setState((state) => ({ + pendingAttachments: state.pendingAttachments.map((att) => { + if (att.kind !== 'file' || att.id !== id) return att + return { ...att, content, status: 'ready' as const, note } + }), + })) + } catch { + useChatStore.setState((state) => ({ + pendingAttachments: state.pendingAttachments.map((att) => { + if (att.kind !== 'file' || att.id !== id) return att + return { ...att, status: 'error' as const, note: 'Failed to read' } + }), + })) + } + }, 0) +} + /** * Check if any pending images are still processing. */ @@ -218,6 +336,15 @@ export function hasProcessingImages(): boolean { ) } +/** + * Check if any pending file attachments are still processing. + */ +export function hasProcessingFiles(): boolean { + return useChatStore.getState().pendingAttachments.some( + (att) => att.kind === 'file' && att.status === 'processing', + ) +} + /** * Capture and clear all pending attachments so they can be passed to the queue * without duplicating state handling logic in multiple callers. diff --git a/cli/src/utils/strings.ts b/cli/src/utils/strings.ts index 73037a670c..e761e5646c 100644 --- a/cli/src/utils/strings.ts +++ b/cli/src/utils/strings.ts @@ -19,11 +19,15 @@ export function truncateToLines( return lines.slice(0, maxLines).join('\n').trimEnd() + '...' } +import { statSync } from 'fs' + import { + getFileOrFolderPathFromText, + getImageFilePathFromText, hasClipboardImage, - readClipboardText, + readClipboardFilePath, readClipboardImageFilePath, - getImageFilePathFromText, + readClipboardText, } from './clipboard-image' import { isImageFile } from './image-handler' @@ -85,7 +89,9 @@ export function createTextPasteHandler( onChange: (value: InputValue) => void, ): (eventText?: string) => void { return (eventText) => { - const pasteText = eventText || readClipboardText() + const rawPaste = eventText || readClipboardText() + if (!rawPaste) return + const pasteText = Bun.stripANSI(rawPaste) if (!pasteText) return const { newText, newCursor } = insertTextAtCursor( text, @@ -116,6 +122,7 @@ export function createPasteHandler(options: { onChange: (value: InputValue) => void onPasteImage?: () => void onPasteImagePath?: (imagePath: string) => void + onPasteFilePath?: (filePath: string, isDirectory: boolean) => void onPasteLongText?: (text: string) => void cwd?: string }): (eventText?: string) => void { @@ -125,10 +132,17 @@ export function createPasteHandler(options: { onChange, onPasteImage, onPasteImagePath, + onPasteFilePath, onPasteLongText, cwd, } = options return (eventText) => { + // Strip ANSI escape sequences from pasted text — terminal paste events + // (bracketed paste) may include ANSI sequences from the source content. + if (eventText) { + eventText = Bun.stripANSI(eventText) + } + // If we have direct input text from the paste event (e.g., from terminal paste), // check if it looks like an image filename and if we can get the full path from clipboard if (eventText && onPasteImagePath) { @@ -163,6 +177,15 @@ export function createPasteHandler(options: { } } + // Check if eventText is a path to a file or folder (drag-and-drop) + if (eventText && onPasteFilePath && cwd) { + const fileInfo = getFileOrFolderPathFromText(eventText, cwd) + if (fileInfo) { + onPasteFilePath(fileInfo.path, fileInfo.isDirectory) + return + } + } + // eventText provided but not an image - check if it's long text if (eventText) { // If text is long, treat it as an attachment @@ -187,16 +210,28 @@ export function createPasteHandler(options: { // No direct text provided - read from clipboard - // First, check if clipboard contains a copied image file (e.g., from Finder) - if (onPasteImagePath) { - const copiedImagePath = readClipboardImageFilePath() - if (copiedImagePath) { - onPasteImagePath(copiedImagePath) - return + // First, check if clipboard contains a copied file (e.g., from Finder) + if (onPasteImagePath || onPasteFilePath) { + const copiedFilePath = readClipboardFilePath() + if (copiedFilePath) { + if (isImageFile(copiedFilePath) && onPasteImagePath) { + onPasteImagePath(copiedFilePath) + return + } + if (!isImageFile(copiedFilePath) && onPasteFilePath) { + try { + const stats = statSync(copiedFilePath) + onPasteFilePath(copiedFilePath, stats.isDirectory()) + return + } catch { + // Fall through to other paste handlers + } + } } } - const clipboardText = readClipboardText() + const rawClipboardText = readClipboardText() + const clipboardText = rawClipboardText ? Bun.stripANSI(rawClipboardText) : null // Check if clipboard text is a path to an image file if (clipboardText && onPasteImagePath && cwd) { diff --git a/freebuff/cli/release.ts b/freebuff/cli/release.ts index 3d1cbfbf22..e3e92ef673 100644 --- a/freebuff/cli/release.ts +++ b/freebuff/cli/release.ts @@ -7,7 +7,7 @@ * to build, publish, and release the Freebuff CLI to npm. * * Usage: - * bun freebuff/cli/release.ts [patch|minor|major] + * bun freebuff/cli/release.ts [patch|minor|major] [--ref ] * * Requires: * CODEBUFF_GITHUB_TOKEN environment variable @@ -16,7 +16,18 @@ import { execSync } from 'child_process' const args = process.argv.slice(2) -const versionType = args[0] || 'patch' + +let versionType = 'patch' +let checkoutRef = '' + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--ref' && args[i + 1]) { + checkoutRef = args[i + 1] + i++ + } else if (!args[i].startsWith('--')) { + versionType = args[i] + } +} function log(message: string) { console.log(`${message}`) @@ -53,18 +64,24 @@ function checkGitHubToken() { return token } -async function triggerWorkflow(versionType: string) { +async function triggerWorkflow(versionType: string, checkoutRef: string) { if (!process.env.GITHUB_TOKEN) { error('GITHUB_TOKEN environment variable is required but not set') } try { + const inputs: Record = { version_type: versionType } + if (checkoutRef) { + inputs.checkout_ref = checkoutRef + } + const payload = JSON.stringify({ ref: 'main', inputs }) + const triggerCmd = `curl -s -w "HTTP Status: %{http_code}" -X POST \ -H "Accept: application/vnd.github.v3+json" \ -H "Authorization: token ${process.env.GITHUB_TOKEN}" \ -H "Content-Type: application/json" \ https://api.github.com/repos/CodebuffAI/codebuff/actions/workflows/freebuff-release.yml/dispatches \ - -d '{"ref":"main","inputs":{"version_type":"${versionType}"}}'` + -d '${payload}'` const response = execSync(triggerCmd, { encoding: 'utf8' }) @@ -93,8 +110,11 @@ async function main() { log('✅ Using local CODEBUFF_GITHUB_TOKEN') log(`Version bump type: ${versionType}`) + if (checkoutRef) { + log(`Building from ref: ${checkoutRef}`) + } - await triggerWorkflow(versionType) + await triggerWorkflow(versionType, checkoutRef) log('') log( diff --git a/freebuff/cli/release/package.json b/freebuff/cli/release/package.json index 0a771f22d3..f75540e4d0 100644 --- a/freebuff/cli/release/package.json +++ b/freebuff/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "freebuff", - "version": "0.0.16", + "version": "0.0.18", "description": "The world's strongest free coding agent", "license": "MIT", "bin": { diff --git a/opencode b/opencode new file mode 160000 index 0000000000..73ee493265 --- /dev/null +++ b/opencode @@ -0,0 +1 @@ +Subproject commit 73ee493265acf15fcd8caab2bc8cd3bd375b63cb