diff --git a/.agents/claude-code-cli.ts b/.agents/claude-code-cli.ts index 72342110fd..075d9f23e4 100644 --- a/.agents/claude-code-cli.ts +++ b/.agents/claude-code-cli.ts @@ -1,450 +1,109 @@ +import { createCliAgent } from './lib/create-cli-agent' + import type { AgentDefinition } from './types/agent-definition' -const definition: AgentDefinition = { +const baseDefinition = createCliAgent({ id: 'claude-code-cli', displayName: 'Claude Code CLI', - model: 'anthropic/claude-opus-4.5', - - spawnerPrompt: `Expert at testing Claude Code CLI functionality using tmux, or performing code reviews via Claude Code. - -**Modes:** -- \`test\` (default): Spawns tmux sessions, sends input to Claude Code CLI, captures terminal output, and validates behavior. -- \`review\`: Uses Claude Code CLI to perform code reviews on specified files or directories. - -**Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. + cliName: 'Claude Code', + shortName: 'claude-code', + startCommand: 'claude --dangerously-skip-permissions', + permissionNote: + 'Always use `--dangerously-skip-permissions` when testing to avoid permission prompts that would block automated tests.', + model: 'anthropic/claude-opus-4.7', +}) + +// Constants must be inside handleSteps since it gets serialized via .toString() +const definition: AgentDefinition = { + ...baseDefinition, + handleSteps: function* ({ prompt, params, logger }) { + const START_COMMAND = 'claude --dangerously-skip-permissions' + const CLI_NAME = 'Claude Code' + + yield { + toolName: 'add_message', + input: { + role: 'user', + content: 'Before starting the ' + CLI_NAME + ' CLI session, gather context by reading relevant files and understanding the task to provide better guidance to the CLI.', + }, + includeToolCall: false, + } -**Your responsibilities as the parent agent:** -1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes -2. Use \`read_files\` on the capture paths to see what the CLI displayed -3. Re-run the test after fixing any script issues`, + yield 'STEP' - inputSchema: { - prompt: { - type: 'string', - description: - 'Description of what to do. For test mode: what CLI functionality to test. For review mode: what code to review and any specific concerns.', - }, - params: { - type: 'object', - properties: { - mode: { - type: 'string', - enum: ['test', 'review'], - description: - 'Operation mode - "test" for CLI testing (default), "review" for code review via Claude Code', - }, - }, - }, - }, + logger.info('Starting ' + CLI_NAME + ' tmux session...') - outputMode: 'structured_output', - outputSchema: { - type: 'object', - properties: { - overallStatus: { - type: 'string', - enum: ['success', 'failure', 'partial'], - description: 'Overall test outcome', - }, - summary: { - type: 'string', - description: 'Brief summary of what was tested and the outcome', + const { toolResult } = yield { + toolName: 'run_terminal_command', + input: { + command: './scripts/tmux/tmux-cli.sh start --command "' + START_COMMAND + '"', + timeout_seconds: 30, }, - testResults: { - type: 'array', - items: { - type: 'object', - properties: { - testName: { - type: 'string', - description: 'Name/description of the test', + } + + // Parse response from tmux-cli.sh (outputs plain session name on success, error to stderr on failure) + let sessionName = '' + let parseError = '' + + const result = toolResult?.[0] + if (result && result.type === 'json') { + const value = result.value as Record + const stdout = typeof value?.stdout === 'string' ? value.stdout.trim() : '' + const stderr = typeof value?.stderr === 'string' ? value.stderr.trim() : '' + const exitCode = typeof value?.exitCode === 'number' ? value.exitCode : undefined + + if (!stdout && !stderr) { + parseError = 'tmux-cli.sh returned empty output' + } else if (exitCode !== 0 || !stdout) { + parseError = stderr || 'tmux-cli.sh failed with no error message' + } else { + sessionName = stdout + } + } else { + parseError = 'Unexpected result type from run_terminal_command' + } + + if (!sessionName) { + const errorMsg = parseError || 'Session name was empty' + logger.error({ parseError: errorMsg }, 'Failed to start tmux session') + yield { + toolName: 'set_output', + input: { + overallStatus: 'failure', + summary: 'Failed to start ' + CLI_NAME + ' tmux session. ' + errorMsg, + sessionName: '', + scriptIssues: [ + { + script: 'tmux-cli.sh', + issue: errorMsg, + errorOutput: JSON.stringify(toolResult), + suggestedFix: 'Ensure tmux-cli.sh outputs the session name to stdout and exits with code 0. Check that tmux is installed.', }, - passed: { type: 'boolean', description: 'Whether the test passed' }, - details: { - type: 'string', - description: 'Details about what happened', - }, - capturedOutput: { - type: 'string', - description: 'Relevant output captured from the CLI', - }, - }, - required: ['testName', 'passed'], + ], + captures: [], }, - description: 'Array of individual test results', + } + return + } + + logger.info('Successfully started tmux session: ' + sessionName) + + yield { + toolName: 'add_message', + input: { + role: 'user', + content: 'A ' + CLI_NAME + ' tmux session has been started: `' + sessionName + '`\n\n' + + 'Use this session for all CLI interactions. The session name must be included in your final output.\n\n' + + 'Proceed with the task using the helper scripts:\n' + + '- Send commands: `./scripts/tmux/tmux-cli.sh send "' + sessionName + '" "..."`\n' + + '- Capture output: `./scripts/tmux/tmux-cli.sh capture "' + sessionName + '" --label "..."`\n' + + '- Stop when done: `./scripts/tmux/tmux-cli.sh stop "' + sessionName + '"`', }, - scriptIssues: { - type: 'array', - items: { - type: 'object', - properties: { - script: { - type: 'string', - description: - 'Which script had the issue (e.g., "tmux-start.sh", "tmux-send.sh")', - }, - issue: { - type: 'string', - description: 'What went wrong when using the script', - }, - errorOutput: { - type: 'string', - description: 'The actual error message or unexpected output', - }, - suggestedFix: { - type: 'string', - description: - 'Suggested fix or improvement for the parent agent to implement', - }, - }, - required: ['script', 'issue', 'suggestedFix'], - }, - description: - 'Issues encountered with the helper scripts that the parent agent should fix', - }, - captures: { - type: 'array', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Path to the capture file (relative to project root)', - }, - label: { - type: 'string', - description: - 'What this capture shows (e.g., "initial-cli-state", "after-help-command")', - }, - timestamp: { - type: 'string', - description: 'When the capture was taken', - }, - }, - required: ['path', 'label'], - }, - description: - 'Paths to saved terminal captures for debugging - check debug/tmux-sessions/{session}/', - }, - reviewFindings: { - type: 'array', - items: { - type: 'object', - properties: { - file: { - type: 'string', - description: 'File path where the issue was found', - }, - severity: { - type: 'string', - enum: ['critical', 'warning', 'suggestion', 'info'], - description: 'Severity level of the finding', - }, - line: { - type: 'number', - description: 'Line number (if applicable)', - }, - finding: { - type: 'string', - description: 'Description of the issue or suggestion', - }, - suggestion: { - type: 'string', - description: 'Suggested fix or improvement', - }, - }, - required: ['file', 'severity', 'finding'], - }, - description: - 'Code review findings (only populated in review mode)', - }, - }, - required: [ - 'overallStatus', - 'summary', - 'testResults', - 'scriptIssues', - 'captures', - ], - }, - includeMessageHistory: false, - - toolNames: [ - 'run_terminal_command', - 'read_files', - 'code_search', - 'set_output', - ], - - systemPrompt: `You are an expert at testing Claude Code CLI using tmux. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. - -## Claude Code Startup - -For testing Claude Code, use the \`--command\` flag with permission bypass: - -\`\`\`bash -# Start Claude Code CLI (with permission bypass for testing) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions") - -# Or with specific options -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions --help") -\`\`\` - -**Important:** Always use \`--dangerously-skip-permissions\` when testing to avoid permission prompts that would block automated tests. - -## Helper Scripts - -Use these scripts in \`scripts/tmux/\` for reliable CLI testing: - -### Unified Script (Recommended) - -\`\`\`bash -# Start a Claude Code test session (with permission bypass) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions") - -# Send input to the CLI -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" - -# Capture output (optionally wait first) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - -# Stop the session when done -./scripts/tmux/tmux-cli.sh stop "$SESSION" - -# Stop all test sessions -./scripts/tmux/tmux-cli.sh stop --all -\`\`\` - -### Individual Scripts (More Options) - -\`\`\`bash -# Start with custom settings -./scripts/tmux/tmux-start.sh --command "claude" --name claude-test --width 160 --height 40 - -# Send text (auto-presses Enter) -./scripts/tmux/tmux-send.sh claude-test "your prompt here" - -# Send without pressing Enter -./scripts/tmux/tmux-send.sh claude-test "partial" --no-enter - -# Send special keys -./scripts/tmux/tmux-send.sh claude-test --key Escape -./scripts/tmux/tmux-send.sh claude-test --key C-c - -# Capture with colors -./scripts/tmux/tmux-capture.sh claude-test --colors + includeToolCall: false, + } -# Save capture to file -./scripts/tmux/tmux-capture.sh claude-test -o output.txt -\`\`\` - -## Why These Scripts? - -The scripts handle **bracketed paste mode** automatically. Standard \`tmux send-keys\` drops characters with TUI apps like Claude Code due to how the CLI processes keyboard input. The helper scripts wrap input in escape sequences (\`\\e[200~...\\e[201~\`) so you don't have to. - -## Typical Test Workflow - -\`\`\`bash -# 1. Start a Claude Code session (with permission bypass) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions") -echo "Testing in session: $SESSION" - -# 2. Verify CLI started -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 3. Run your test -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" -sleep 2 -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 4. Clean up -./scripts/tmux/tmux-cli.sh stop "$SESSION" -\`\`\` - -## Session Logs (Paper Trail) - -All session data is stored in **YAML format** in \`debug/tmux-sessions/{session-name}/\`: - -- \`session-info.yaml\` - Session metadata (start time, dimensions, status) -- \`commands.yaml\` - YAML array of all commands sent with timestamps -- \`capture-{sequence}-{label}.txt\` - Captures with YAML front-matter - -\`\`\`bash -# Capture with a descriptive label (recommended) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - -# Capture saved to: debug/tmux-sessions/{session}/capture-001-after-help-command.txt -\`\`\` - -Each capture file has YAML front-matter with metadata: -\`\`\`yaml ---- -sequence: 1 -label: after-help-command -timestamp: 2025-01-01T12:00:30Z -after_command: "/help" -dimensions: - width: 120 - height: 30 ---- -[terminal content] -\`\`\` - -The capture path is printed to stderr. Both you and the parent agent can read these files to see exactly what the CLI displayed. - -## Debugging Tips - -- **Attach interactively**: \`tmux attach -t SESSION_NAME\` -- **List sessions**: \`./scripts/tmux/tmux-cli.sh list\` -- **View session logs**: \`ls debug/tmux-sessions/{session-name}/\` -- **Get help**: \`./scripts/tmux/tmux-cli.sh help\` or \`./scripts/tmux/tmux-start.sh --help\``, - - instructionsPrompt: `Instructions: - -Check the \`mode\` parameter to determine your operation: -- If \`mode\` is "review" or the prompt mentions reviewing/analyzing code: follow **Review Mode** instructions -- Otherwise: follow **Test Mode** instructions (default) - ---- - -## Test Mode Instructions - -1. **Use the helper scripts** in \`scripts/tmux/\` - they handle bracketed paste mode automatically - -2. **Start a Claude Code test session** with permission bypass: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions") - \`\`\` - -3. **Verify the CLI started** by capturing initial output: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" - \`\`\` - -4. **Send commands** and capture responses: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "your command here" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - \`\`\` - -5. **Always clean up** when done: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - -6. **Use labels when capturing** to create a clear paper trail: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - \`\`\` - ---- - -## Review Mode Instructions - -In review mode, you send a detailed review prompt to Claude Code. The prompt MUST start with the word "review" and include specific areas of concern. - -### What We're Looking For - -The review should focus on these key areas: - -1. **Code Organization Issues** - - Poor file/module structure - - Unclear separation of concerns - - Functions/classes that do too many things - - Missing or inconsistent abstractions - -2. **Over-Engineering & Complexity** - - Unnecessarily abstract or generic code - - Premature optimization - - Complex patterns where simple solutions would suffice - - "Enterprise" patterns in small codebases - -3. **AI-Generated Code Patterns ("AI Slop")** - - Verbose, flowery language in comments ("It's important to note...", "Worth mentioning...") - - Excessive disclaimers and hedging in documentation - - Inconsistent coding style within the same file - - Overly generic variable/function names - - Redundant explanatory comments that just restate the code - - Sudden shifts between formal and casual tone - - Filler phrases that add no value - -4. **Lack of Systems-Level Thinking** - - Missing error handling strategy - - No consideration for scaling or performance - - Ignoring edge cases and failure modes - - Lack of observability (logging, metrics, tracing) - - Missing or incomplete type definitions - -### Workflow - -1. **Start Claude Code** with permission bypass: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions") - \`\`\` - -2. **Wait for CLI to initialize**, then capture: - \`\`\`bash - sleep 3 - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - \`\`\` - -3. **Send a detailed review prompt** (MUST start with "review"): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "Review [files/directories from prompt]. Look for: - - 1. CODE ORGANIZATION: Poor structure, unclear separation of concerns, functions doing too much - 2. OVER-ENGINEERING: Unnecessary abstractions, premature optimization, complex patterns where simple would work - 3. AI SLOP: Verbose comments ('it\\'s important to note'), excessive disclaimers, inconsistent style, generic names, redundant explanations - 4. SYSTEMS THINKING: Missing error handling strategy, no scaling consideration, ignored edge cases, lack of observability - - For each issue found, specify the file, line number, what\\'s wrong, and how to fix it. Be direct and specific." - \`\`\` - -4. **Wait for and capture the review output** (reviews take longer): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 - \`\`\` - - If the review is still in progress, wait and capture again: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output-continued" --wait 30 - \`\`\` - -5. **Parse the review output** and populate \`reviewFindings\` with: - - \`file\`: Path to the file with the issue - - \`severity\`: "critical", "warning", "suggestion", or "info" - - \`line\`: Line number if mentioned - - \`finding\`: Description of the issue - - \`suggestion\`: How to fix it - -6. **Clean up**: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - ---- - -## Output (Both Modes) - -**Report results using set_output** - You MUST call set_output with structured results: -- \`overallStatus\`: "success", "failure", or "partial" -- \`summary\`: Brief description of what was tested/reviewed -- \`testResults\`: Array of test outcomes (for test mode) -- \`scriptIssues\`: Array of any problems with the helper scripts -- \`captures\`: Array of capture paths with labels -- \`reviewFindings\`: Array of code review findings (for review mode) - -**If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: -- \`script\`: Which script failed -- \`issue\`: What went wrong -- \`errorOutput\`: The actual error message -- \`suggestedFix\`: How the parent agent should fix the script - -**Always include captures** in your output so the parent agent can see what you saw. - -For advanced options, run \`./scripts/tmux/tmux-cli.sh help\` or check individual scripts with \`--help\`.`, + yield 'STEP_ALL' + }, } export default definition diff --git a/.agents/codebuff-local-cli.ts b/.agents/codebuff-local-cli.ts index 57d21ecaa0..8cb367a08a 100644 --- a/.agents/codebuff-local-cli.ts +++ b/.agents/codebuff-local-cli.ts @@ -1,455 +1,123 @@ +import { createCliAgent } from './lib/create-cli-agent' + import type { AgentDefinition } from './types/agent-definition' -const definition: AgentDefinition = { +const baseDefinition = createCliAgent({ id: 'codebuff-local-cli', displayName: 'Codebuff Local CLI', - model: 'anthropic/claude-opus-4.5', - - spawnerPrompt: `Expert at testing Codebuff CLI functionality using tmux, or performing code reviews via Codebuff. - -**Modes:** -- \`test\` (default): Spawns tmux sessions, sends input to the Codebuff CLI, captures terminal output, and validates behavior. -- \`review\`: Uses Codebuff CLI to perform code reviews on specified files or directories. - -**Use this agent after modifying:** -- \`cli/src/components/\` - UI components, layouts, rendering -- \`cli/src/hooks/\` - hooks that affect what users see -- Any CLI visual elements: borders, colors, spacing, text formatting - -**When to use:** After implementing CLI UI changes, use this to verify the visual output actually renders correctly. Unit tests and typechecks cannot catch layout bugs, rendering issues, or visual regressions. This agent captures real terminal output including colors and layout. - -**Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. - -**Your responsibilities as the parent agent:** -1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes -2. Use \`read_files\` on the capture paths to see what the CLI displayed -3. Re-run the test after fixing any script issues`, - - inputSchema: { - prompt: { - type: 'string', - description: - 'Description of what to do. For test mode: what CLI functionality to test. For review mode: what code to review and any specific concerns.', - }, - params: { - type: 'object', - properties: { - mode: { - type: 'string', - enum: ['test', 'review'], - description: - 'Operation mode - "test" for CLI testing (default), "review" for code review via Codebuff', - }, - }, - }, - }, - - outputMode: 'structured_output', - outputSchema: { - type: 'object', - properties: { - overallStatus: { - type: 'string', - enum: ['success', 'failure', 'partial'], - description: 'Overall test outcome', - }, - summary: { - type: 'string', - description: 'Brief summary of what was tested and the outcome', - }, - testResults: { - type: 'array', - items: { - type: 'object', - properties: { - testName: { - type: 'string', - description: 'Name/description of the test', - }, - passed: { type: 'boolean', description: 'Whether the test passed' }, - details: { - type: 'string', - description: 'Details about what happened', - }, - capturedOutput: { - type: 'string', - description: 'Relevant output captured from the CLI', - }, - }, - required: ['testName', 'passed'], - }, - description: 'Array of individual test results', - }, - scriptIssues: { - type: 'array', - items: { - type: 'object', - properties: { - script: { - type: 'string', - description: - 'Which script had the issue (e.g., "tmux-start.sh", "tmux-send.sh")', - }, - issue: { - type: 'string', - description: 'What went wrong when using the script', - }, - errorOutput: { - type: 'string', - description: 'The actual error message or unexpected output', - }, - suggestedFix: { - type: 'string', - description: - 'Suggested fix or improvement for the parent agent to implement', - }, - }, - required: ['script', 'issue', 'suggestedFix'], - }, - description: - 'Issues encountered with the helper scripts that the parent agent should fix', - }, - captures: { - type: 'array', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Path to the capture file (relative to project root)', - }, - label: { - type: 'string', - description: - 'What this capture shows (e.g., "initial-cli-state", "after-help-command")', - }, - timestamp: { - type: 'string', - description: 'When the capture was taken', - }, - }, - required: ['path', 'label'], - }, - description: - 'Paths to saved terminal captures for debugging - check debug/tmux-sessions/{session}/', + cliName: 'Codebuff', + shortName: 'codebuff-local', + startCommand: 'bun --cwd=cli run dev', + permissionNote: + 'No permission flags needed for Codebuff local dev server.', + model: 'anthropic/claude-opus-4.7', + skipPrepPhase: true, + cliSpecificDocs: `## Codebuff CLI Specific Guidance + +- The ready state is the Codebuff banner, working directory, and bordered input box with the agent selector. +- For smoke tests, \`/help\` is useful because it validates the overlay, shortcuts, features, and credits copy in one step. +- For implementation-oriented tests, prefer asking the CLI to inspect or reason about a specific file rather than making edits unless the parent prompt explicitly asks for edits. +- Long Codebuff responses live in a scrollable viewport. If the bottom of the answer already shows the core recommendation, do not spend many extra steps trying to reconstruct every hidden line. +- Avoid key combinations like Shift+Arrow or repeated history/navigation probing unless you have a clear reason; they can open overlays or mutate the input state unexpectedly. +- A good implementation-test flow is usually: initial ready capture → task sent/in-progress capture → response-complete capture → optional follow-up-ready or follow-up-complete capture. +- If you need a follow-up, keep it narrow and specific rather than re-asking the whole task. +- If the current session becomes clearly unusable, report that failure; do not silently start a replacement session and continue as though nothing happened.`, + spawnerPromptExtras: `**Purpose:** E2E visual testing of the Codebuff CLI itself. This agent starts a local dev Codebuff CLI instance and interacts with it to verify UI behavior. + +**When to use:** +- After modifying \`cli/src/components/\` - UI components, layouts, rendering +- After modifying \`cli/src/hooks/\` - hooks that affect what users see +- To test CLI visual elements: borders, colors, spacing, text formatting +- To verify the CLI responds correctly to user input + +**NOT for:** +- Code review or analysis tasks +- Reading files and verifying code logic +- Running unit tests or typechecks + +**How it works:** Starts \`bun --cwd=cli run dev\` in tmux, then you send prompts/commands to the CLI and capture the visual output. Unit tests and typechecks cannot catch layout bugs, rendering issues, or visual regressions - this agent captures real terminal output including colors and layout.`, +}) + +// Constants must be inside handleSteps since it gets serialized via .toString() +const definition: AgentDefinition = { + ...baseDefinition, + handleSteps: function* ({ prompt, params, logger }) { + const START_COMMAND = 'bun --cwd=cli run dev' + const CLI_NAME = 'Codebuff' + + logger.info('Starting ' + CLI_NAME + ' tmux session...') + + const { toolResult } = yield { + toolName: 'run_terminal_command', + input: { + command: './scripts/tmux/tmux-cli.sh start --command "' + START_COMMAND + '"', + timeout_seconds: 30, }, - reviewFindings: { - type: 'array', - items: { - type: 'object', - properties: { - file: { - type: 'string', - description: 'File path where the issue was found', - }, - severity: { - type: 'string', - enum: ['critical', 'warning', 'suggestion', 'info'], - description: 'Severity level of the finding', - }, - line: { - type: 'number', - description: 'Line number (if applicable)', - }, - finding: { - type: 'string', - description: 'Description of the issue or suggestion', + } + + // Parse response from tmux-cli.sh (outputs plain session name on success, error to stderr on failure) + let sessionName = '' + let parseError = '' + + const result = toolResult?.[0] + if (result && result.type === 'json') { + const value = result.value as Record + const stdout = typeof value?.stdout === 'string' ? value.stdout.trim() : '' + const stderr = typeof value?.stderr === 'string' ? value.stderr.trim() : '' + const exitCode = typeof value?.exitCode === 'number' ? value.exitCode : undefined + + if (!stdout && !stderr) { + parseError = 'tmux-cli.sh returned empty output' + } else if (exitCode !== 0 || !stdout) { + parseError = stderr || 'tmux-cli.sh failed with no error message' + } else { + sessionName = stdout + } + } else { + parseError = 'Unexpected result type from run_terminal_command' + } + + if (!sessionName) { + const errorMsg = parseError || 'Session name was empty' + logger.error({ parseError: errorMsg }, 'Failed to start tmux session') + yield { + toolName: 'set_output', + input: { + overallStatus: 'failure', + summary: 'Failed to start ' + CLI_NAME + ' tmux session. ' + errorMsg, + sessionName: '', + scriptIssues: [ + { + script: 'tmux-cli.sh', + issue: errorMsg, + errorOutput: JSON.stringify(toolResult), + suggestedFix: 'Ensure tmux-cli.sh outputs the session name to stdout and exits with code 0. Check that tmux is installed.', }, - suggestion: { - type: 'string', - description: 'Suggested fix or improvement', - }, - }, - required: ['file', 'severity', 'finding'], + ], + captures: [], }, - description: - 'Code review findings (only populated in review mode)', + } + return + } + + logger.info('Successfully started tmux session: ' + sessionName) + + yield { + toolName: 'add_message', + input: { + role: 'user', + content: 'A ' + CLI_NAME + ' tmux session has been started: `' + sessionName + '`\n\n' + + 'Use this session for all CLI interactions. Treat it as the canonical session for this run. If it fails, report that explicitly instead of silently starting another session. The session name must be included in your final output.\n\n' + + 'Proceed with the task using the helper scripts:\n' + + '- Send commands: `./scripts/tmux/tmux-cli.sh send "' + sessionName + '" "..."`\n' + + '- Capture output: `./scripts/tmux/tmux-cli.sh capture "' + sessionName + '" --label "..."`\n' + + '- Stop when done: `./scripts/tmux/tmux-cli.sh stop "' + sessionName + '"`', }, - }, - required: [ - 'overallStatus', - 'summary', - 'testResults', - 'scriptIssues', - 'captures', - ], - }, - includeMessageHistory: false, - - toolNames: [ - 'run_terminal_command', - 'read_files', - 'code_search', - 'set_output', - ], - - systemPrompt: `You are an expert at testing the Codebuff CLI using tmux. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. - -## Codebuff-Specific Startup - -For testing Codebuff, use the \`--command\` flag with the Codebuff dev server: - -\`\`\`bash -# Start Codebuff CLI dev server -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "bun --cwd=cli run dev") - -# Or test a compiled binary -SESSION=$(./scripts/tmux/tmux-cli.sh start --binary) -\`\`\` - -## Helper Scripts - -Use these scripts in \`scripts/tmux/\` for reliable CLI testing: - -### Unified Script (Recommended) - -\`\`\`bash -# Start a Codebuff test session -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "bun --cwd=cli run dev") - -# Send input to the CLI -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" - -# Capture output (optionally wait first) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - -# Stop the session when done -./scripts/tmux/tmux-cli.sh stop "$SESSION" - -# Stop all test sessions -./scripts/tmux/tmux-cli.sh stop --all -\`\`\` - -### Individual Scripts (More Options) - -\`\`\`bash -# Start with custom settings -./scripts/tmux/tmux-start.sh --command "bun --cwd=cli run dev" --name my-test --width 160 --height 40 - -# Send text (auto-presses Enter) -./scripts/tmux/tmux-send.sh my-test "your prompt here" - -# Send without pressing Enter -./scripts/tmux/tmux-send.sh my-test "partial" --no-enter + includeToolCall: false, + } -# Send special keys -./scripts/tmux/tmux-send.sh my-test --key Escape -./scripts/tmux/tmux-send.sh my-test --key C-c - -# Capture with colors -./scripts/tmux/tmux-capture.sh my-test --colors - -# Save capture to file -./scripts/tmux/tmux-capture.sh my-test -o output.txt -\`\`\` - -## Why These Scripts? - -The scripts handle **bracketed paste mode** automatically. Standard \`tmux send-keys\` drops characters with TUI apps like Codebuff due to how OpenTUI processes keyboard input. The helper scripts wrap input in escape sequences (\`\\e[200~...\\e[201~\`) so you don't have to. - -## Typical Test Workflow - -\`\`\`bash -# 1. Start a Codebuff session -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "bun --cwd=cli run dev") -echo "Testing in session: $SESSION" - -# 2. Verify CLI started -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 3. Run your test -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" -sleep 2 -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 4. Clean up -./scripts/tmux/tmux-cli.sh stop "$SESSION" -\`\`\` - -## Session Logs (Paper Trail) - -All session data is stored in **YAML format** in \`debug/tmux-sessions/{session-name}/\`: - -- \`session-info.yaml\` - Session metadata (start time, dimensions, status) -- \`commands.yaml\` - YAML array of all commands sent with timestamps -- \`capture-{sequence}-{label}.txt\` - Captures with YAML front-matter - -\`\`\`bash -# Capture with a descriptive label (recommended) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - -# Capture saved to: debug/tmux-sessions/{session}/capture-001-after-help-command.txt -\`\`\` - -Each capture file has YAML front-matter with metadata: -\`\`\`yaml ---- -sequence: 1 -label: after-help-command -timestamp: 2025-01-01T12:00:30Z -after_command: "/help" -dimensions: - width: 120 - height: 30 ---- -[terminal content] -\`\`\` - -The capture path is printed to stderr. Both you and the parent agent can read these files to see exactly what the CLI displayed. - -## Debugging Tips - -- **Attach interactively**: \`tmux attach -t SESSION_NAME\` -- **List sessions**: \`./scripts/tmux/tmux-cli.sh list\` -- **View session logs**: \`ls debug/tmux-sessions/{session-name}/\` -- **Get help**: \`./scripts/tmux/tmux-cli.sh help\` or \`./scripts/tmux/tmux-start.sh --help\``, - - instructionsPrompt: `Instructions: - -Check the \`mode\` parameter to determine your operation: -- If \`mode\` is "review" or the prompt mentions reviewing/analyzing code: follow **Review Mode** instructions -- Otherwise: follow **Test Mode** instructions (default) - ---- - -## Test Mode Instructions - -1. **Use the helper scripts** in \`scripts/tmux/\` - they handle bracketed paste mode automatically - -2. **Start a Codebuff test session** with the explicit command: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "bun --cwd=cli run dev") - \`\`\` - -3. **Verify the CLI started** by capturing initial output: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" - \`\`\` - -4. **Send commands** and capture responses: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "your command here" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - \`\`\` - -5. **Always clean up** when done: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - -6. **Use labels when capturing** to create a clear paper trail: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - \`\`\` - ---- - -## Review Mode Instructions - -In review mode, you send a detailed review prompt to Codebuff. The prompt MUST start with the word "review" and include specific areas of concern. - -### What We're Looking For - -The review should focus on these key areas: - -1. **Code Organization Issues** - - Poor file/module structure - - Unclear separation of concerns - - Functions/classes that do too many things - - Missing or inconsistent abstractions - -2. **Over-Engineering & Complexity** - - Unnecessarily abstract or generic code - - Premature optimization - - Complex patterns where simple solutions would suffice - - "Enterprise" patterns in small codebases - -3. **AI-Generated Code Patterns ("AI Slop")** - - Verbose, flowery language in comments ("It's important to note...", "Worth mentioning...") - - Excessive disclaimers and hedging in documentation - - Inconsistent coding style within the same file - - Overly generic variable/function names - - Redundant explanatory comments that just restate the code - - Sudden shifts between formal and casual tone - - Filler phrases that add no value - -4. **Lack of Systems-Level Thinking** - - Missing error handling strategy - - No consideration for scaling or performance - - Ignoring edge cases and failure modes - - Lack of observability (logging, metrics, tracing) - - Missing or incomplete type definitions - -### Workflow - -1. **Start Codebuff**: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "bun --cwd=cli run dev") - \`\`\` - -2. **Wait for CLI to initialize**, then capture: - \`\`\`bash - sleep 3 - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - \`\`\` - -3. **Send a detailed review prompt** (MUST start with "review"): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "Review [files/directories from prompt]. Look for: - - 1. CODE ORGANIZATION: Poor structure, unclear separation of concerns, functions doing too much - 2. OVER-ENGINEERING: Unnecessary abstractions, premature optimization, complex patterns where simple would work - 3. AI SLOP: Verbose comments ('it\\'s important to note'), excessive disclaimers, inconsistent style, generic names, redundant explanations - 4. SYSTEMS THINKING: Missing error handling strategy, no scaling consideration, ignored edge cases, lack of observability - - For each issue found, specify the file, line number, what\\'s wrong, and how to fix it. Be direct and specific." - \`\`\` - -4. **Wait for and capture the review output** (reviews take longer): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 - \`\`\` - - If the review is still in progress, wait and capture again: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output-continued" --wait 30 - \`\`\` - -5. **Parse the review output** and populate \`reviewFindings\` with: - - \`file\`: Path to the file with the issue - - \`severity\`: "critical", "warning", "suggestion", or "info" - - \`line\`: Line number if mentioned - - \`finding\`: Description of the issue - - \`suggestion\`: How to fix it - -6. **Clean up**: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - ---- - -## Output (Both Modes) - -**Report results using set_output** - You MUST call set_output with structured results: -- \`overallStatus\`: "success", "failure", or "partial" -- \`summary\`: Brief description of what was tested/reviewed -- \`testResults\`: Array of test outcomes (for test mode) -- \`scriptIssues\`: Array of any problems with the helper scripts -- \`captures\`: Array of capture paths with labels -- \`reviewFindings\`: Array of code review findings (for review mode) - -**If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: -- \`script\`: Which script failed -- \`issue\`: What went wrong -- \`errorOutput\`: The actual error message -- \`suggestedFix\`: How the parent agent should fix the script - -**Always include captures** in your output so the parent agent can see what you saw. - -For advanced options, run \`./scripts/tmux/tmux-cli.sh help\` or check individual scripts with \`--help\`.`, + yield 'STEP_ALL' + }, } export default definition diff --git a/.agents/codex-cli.ts b/.agents/codex-cli.ts index 95efbff7dd..e7b18473a8 100644 --- a/.agents/codex-cli.ts +++ b/.agents/codex-cli.ts @@ -1,356 +1,17 @@ -import type { AgentDefinition } from './types/agent-definition' - -const definition: AgentDefinition = { - id: 'codex-cli', - displayName: 'Codex CLI', - model: 'anthropic/claude-opus-4.5', - - spawnerPrompt: `Expert at testing OpenAI Codex CLI functionality using tmux, or performing code reviews via Codex. - -**Modes:** -- \`test\` (default): Spawns tmux sessions, sends input to Codex CLI, captures terminal output, and validates behavior. -- \`review\`: Uses Codex CLI to perform code reviews on specified files or directories. - -**Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. - -**Your responsibilities as the parent agent:** -1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes -2. Use \`read_files\` on the capture paths to see what the CLI displayed -3. Re-run the test after fixing any script issues`, - - inputSchema: { - prompt: { - type: 'string', - description: - 'Description of what to do. For test mode: what CLI functionality to test. For review mode: what code to review and any specific concerns.', - }, - params: { - type: 'object', - properties: { - mode: { - type: 'string', - enum: ['test', 'review'], - description: - 'Operation mode - "test" for CLI testing (default), "review" for code review via Codex', - }, - reviewType: { - type: 'string', - enum: ['pr', 'uncommitted', 'commit', 'custom'], - description: - 'For review mode: "pr" = Review against base branch (PR style), "uncommitted" = Review uncommitted changes, "commit" = Review a specific commit, "custom" = Custom review instructions. Defaults to "uncommitted".', - }, - }, - }, - }, - - outputMode: 'structured_output', - outputSchema: { - type: 'object', - properties: { - overallStatus: { - type: 'string', - enum: ['success', 'failure', 'partial'], - description: 'Overall test outcome', - }, - summary: { - type: 'string', - description: 'Brief summary of what was tested and the outcome', - }, - testResults: { - type: 'array', - items: { - type: 'object', - properties: { - testName: { - type: 'string', - description: 'Name/description of the test', - }, - passed: { type: 'boolean', description: 'Whether the test passed' }, - details: { - type: 'string', - description: 'Details about what happened', - }, - capturedOutput: { - type: 'string', - description: 'Relevant output captured from the CLI', - }, - }, - required: ['testName', 'passed'], - }, - description: 'Array of individual test results', - }, - scriptIssues: { - type: 'array', - items: { - type: 'object', - properties: { - script: { - type: 'string', - description: - 'Which script had the issue (e.g., "tmux-start.sh", "tmux-send.sh")', - }, - issue: { - type: 'string', - description: 'What went wrong when using the script', - }, - errorOutput: { - type: 'string', - description: 'The actual error message or unexpected output', - }, - suggestedFix: { - type: 'string', - description: - 'Suggested fix or improvement for the parent agent to implement', - }, - }, - required: ['script', 'issue', 'suggestedFix'], - }, - description: - 'Issues encountered with the helper scripts that the parent agent should fix', - }, - captures: { - type: 'array', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Path to the capture file (relative to project root)', - }, - label: { - type: 'string', - description: - 'What this capture shows (e.g., "initial-cli-state", "after-help-command")', - }, - timestamp: { - type: 'string', - description: 'When the capture was taken', - }, - }, - required: ['path', 'label'], - }, - description: - 'Paths to saved terminal captures for debugging - check debug/tmux-sessions/{session}/', - }, - reviewFindings: { - type: 'array', - items: { - type: 'object', - properties: { - file: { - type: 'string', - description: 'File path where the issue was found', - }, - severity: { - type: 'string', - enum: ['critical', 'warning', 'suggestion', 'info'], - description: 'Severity level of the finding', - }, - line: { - type: 'number', - description: 'Line number (if applicable)', - }, - finding: { - type: 'string', - description: 'Description of the issue or suggestion', - }, - suggestion: { - type: 'string', - description: 'Suggested fix or improvement', - }, - }, - required: ['file', 'severity', 'finding'], - }, - description: - 'Code review findings (only populated in review mode)', - }, - }, - required: [ - 'overallStatus', - 'summary', - 'testResults', - 'scriptIssues', - 'captures', - ], - }, - includeMessageHistory: false, - - toolNames: [ - 'run_terminal_command', - 'read_files', - 'code_search', - 'set_output', - ], - - systemPrompt: `You are an expert at testing OpenAI Codex CLI using tmux. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. - -## Codex Startup - -For testing Codex, use the \`--command\` flag with permission bypass: - -\`\`\`bash -# Start Codex CLI (with full access and no approval prompts) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access") - -# Or with specific options -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access --help") -\`\`\` - -**Important:** Always use \`-a never -s danger-full-access\` when testing to avoid approval prompts that would block automated tests. - -## Helper Scripts - -Use these scripts in \`scripts/tmux/\` for reliable CLI testing: - -### Unified Script (Recommended) - -\`\`\`bash -# Start a Codex test session (with permission bypass) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access") - -# Send input to the CLI -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" - -# Capture output (optionally wait first) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - -# Stop the session when done -./scripts/tmux/tmux-cli.sh stop "$SESSION" - -# Stop all test sessions -./scripts/tmux/tmux-cli.sh stop --all -\`\`\` - -### Individual Scripts (More Options) - -\`\`\`bash -# Start with custom settings -./scripts/tmux/tmux-start.sh --command "codex" --name codex-test --width 160 --height 40 +import { createCliAgent } from './lib/create-cli-agent' -# Send text (auto-presses Enter) -./scripts/tmux/tmux-send.sh codex-test "your prompt here" - -# Send without pressing Enter -./scripts/tmux/tmux-send.sh codex-test "partial" --no-enter - -# Send special keys -./scripts/tmux/tmux-send.sh codex-test --key Escape -./scripts/tmux/tmux-send.sh codex-test --key C-c - -# Capture with colors -./scripts/tmux/tmux-capture.sh codex-test --colors - -# Save capture to file -./scripts/tmux/tmux-capture.sh codex-test -o output.txt -\`\`\` - -## Why These Scripts? - -The scripts handle **bracketed paste mode** automatically. Standard \`tmux send-keys\` drops characters with TUI apps like Codex due to how the CLI processes keyboard input. The helper scripts wrap input in escape sequences (\`\\e[200~...\\e[201~\`) so you don't have to. - -## Typical Test Workflow - -\`\`\`bash -# 1. Start a Codex session (with permission bypass) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access") -echo "Testing in session: $SESSION" - -# 2. Verify CLI started -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 3. Run your test -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" -sleep 2 -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 4. Clean up -./scripts/tmux/tmux-cli.sh stop "$SESSION" -\`\`\` - -## Session Logs (Paper Trail) - -All session data is stored in **YAML format** in \`debug/tmux-sessions/{session-name}/\`: - -- \`session-info.yaml\` - Session metadata (start time, dimensions, status) -- \`commands.yaml\` - YAML array of all commands sent with timestamps -- \`capture-{sequence}-{label}.txt\` - Captures with YAML front-matter - -\`\`\`bash -# Capture with a descriptive label (recommended) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - -# Capture saved to: debug/tmux-sessions/{session}/capture-001-after-help-command.txt -\`\`\` - -Each capture file has YAML front-matter with metadata: -\`\`\`yaml ---- -sequence: 1 -label: after-help-command -timestamp: 2025-01-01T12:00:30Z -after_command: "/help" -dimensions: - width: 120 - height: 30 ---- -[terminal content] -\`\`\` - -The capture path is printed to stderr. Both you and the parent agent can read these files to see exactly what the CLI displayed. - -## Debugging Tips - -- **Attach interactively**: \`tmux attach -t SESSION_NAME\` -- **List sessions**: \`./scripts/tmux/tmux-cli.sh list\` -- **View session logs**: \`ls debug/tmux-sessions/{session-name}/\` -- **Get help**: \`./scripts/tmux/tmux-cli.sh help\` or \`./scripts/tmux/tmux-start.sh --help\``, - - instructionsPrompt: `Instructions: - -Check the \`mode\` parameter to determine your operation: -- If \`mode\` is "review" or the prompt mentions reviewing/analyzing code: follow **Review Mode** instructions -- Otherwise: follow **Test Mode** instructions (default) - ---- - -## Test Mode Instructions - -1. **Use the helper scripts** in \`scripts/tmux/\` - they handle bracketed paste mode automatically - -2. **Start a Codex test session** with permission bypass: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access") - \`\`\` - -3. **Verify the CLI started** by capturing initial output: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" - \`\`\` - -4. **Send commands** and capture responses: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "your command here" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - \`\`\` - -5. **Always clean up** when done: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - -6. **Use labels when capturing** to create a clear paper trail: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - \`\`\` - ---- +import type { AgentDefinition } from './types/agent-definition' -## Review Mode Instructions +/** + * Codex-specific review mode instructions. + * Codex CLI has a built-in /review command with an interactive questionnaire. + */ +const CODEX_REVIEW_MODE_INSTRUCTIONS = `## Review Mode Instructions Codex CLI has a built-in \`/review\` command that presents an interactive questionnaire. You must navigate it using arrow keys and Enter. +**Note:** A tmux session will be started for you automatically after your preparation phase. Use the session name from the assistant message that announces it. + ### Review Type Mapping The \`reviewType\` param maps to menu options (1-indexed from top): @@ -361,25 +22,20 @@ The \`reviewType\` param maps to menu options (1-indexed from top): ### Workflow -1. **Start Codex** with permission bypass: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access") - \`\`\` - -2. **Wait for CLI to initialize**, then capture: +1. **Wait for CLI to initialize**, then capture: \`\`\`bash sleep 3 ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" \`\`\` -3. **Send the /review command**: +2. **Send the /review command**: \`\`\`bash ./scripts/tmux/tmux-cli.sh send "$SESSION" "/review" sleep 2 ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-menu" \`\`\` -4. **Navigate to the correct option** using arrow keys: +3. **Navigate to the correct option** using arrow keys: - The menu starts with Option 1 selected (PR Style) - Use Down arrow to move to the desired option: - \`reviewType="pr"\`: No navigation needed, just press Enter @@ -394,50 +50,140 @@ The \`reviewType\` param maps to menu options (1-indexed from top): ./scripts/tmux/tmux-send.sh "$SESSION" --key Enter \`\`\` -5. **For "custom" reviewType**, after selecting option 4, you'll need to send the custom instructions from the prompt: +4. **For "custom" reviewType**, after selecting option 4, you'll need to send the custom instructions from the prompt: \`\`\`bash sleep 1 ./scripts/tmux/tmux-cli.sh send "$SESSION" "[custom instructions from the prompt]" \`\`\` -6. **Wait for and capture the review output** (reviews take longer): +5. **Wait for and capture the review output** (reviews take longer): \`\`\`bash ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 \`\`\` -7. **Parse the review output** and populate \`reviewFindings\` with: +6. **Parse the review output** and populate \`reviewFindings\` with: - \`file\`: Path to the file with the issue - \`severity\`: "critical", "warning", "suggestion", or "info" - \`line\`: Line number if mentioned - \`finding\`: Description of the issue - \`suggestion\`: How to fix it -8. **Clean up**: +7. **Clean up**: \`\`\`bash ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` + \`\`\`` ---- +const baseDefinition = createCliAgent({ + id: 'codex-cli', + displayName: 'Codex CLI', + cliName: 'Codex', + shortName: 'codex', + startCommand: 'codex -a never -s danger-full-access', + permissionNote: + 'Always use `-a never -s danger-full-access` when testing to avoid approval prompts that would block automated tests.', + model: 'anthropic/claude-opus-4.7', + extraInputParams: { + reviewType: { + type: 'string', + enum: ['pr', 'uncommitted', 'commit', 'custom'], + description: + 'For review mode: "pr" = Review against base branch (PR style), "uncommitted" = Review uncommitted changes, "commit" = Review a specific commit, "custom" = Custom review instructions. Defaults to "uncommitted".', + }, + }, + reviewModeInstructions: CODEX_REVIEW_MODE_INSTRUCTIONS, +}) -## Output (Both Modes) +// Constants must be inside handleSteps since it gets serialized via .toString() +const definition: AgentDefinition = { + ...baseDefinition, + handleSteps: function* ({ prompt, params, logger }) { + const START_COMMAND = 'codex -a never -s danger-full-access' + const CLI_NAME = 'Codex' + + yield { + toolName: 'add_message', + input: { + role: 'user', + content: 'Before starting the ' + CLI_NAME + ' CLI session, gather context by reading relevant files and understanding the task to provide better guidance to the CLI.', + }, + includeToolCall: false, + } -**Report results using set_output** - You MUST call set_output with structured results: -- \`overallStatus\`: "success", "failure", or "partial" -- \`summary\`: Brief description of what was tested/reviewed -- \`testResults\`: Array of test outcomes (for test mode) -- \`scriptIssues\`: Array of any problems with the helper scripts -- \`captures\`: Array of capture paths with labels -- \`reviewFindings\`: Array of code review findings (for review mode) + yield 'STEP' -**If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: -- \`script\`: Which script failed -- \`issue\`: What went wrong -- \`errorOutput\`: The actual error message -- \`suggestedFix\`: How the parent agent should fix the script + logger.info('Starting ' + CLI_NAME + ' tmux session...') -**Always include captures** in your output so the parent agent can see what you saw. + const { toolResult } = yield { + toolName: 'run_terminal_command', + input: { + command: './scripts/tmux/tmux-cli.sh start --command "' + START_COMMAND + '"', + timeout_seconds: 30, + }, + } + + // Parse response from tmux-cli.sh (outputs plain session name on success, error to stderr on failure) + let sessionName = '' + let parseError = '' + + const result = toolResult?.[0] + if (result && result.type === 'json') { + const value = result.value as Record + const stdout = typeof value?.stdout === 'string' ? value.stdout.trim() : '' + const stderr = typeof value?.stderr === 'string' ? value.stderr.trim() : '' + const exitCode = typeof value?.exitCode === 'number' ? value.exitCode : undefined + + if (!stdout && !stderr) { + parseError = 'tmux-cli.sh returned empty output' + } else if (exitCode !== 0 || !stdout) { + parseError = stderr || 'tmux-cli.sh failed with no error message' + } else { + sessionName = stdout + } + } else { + parseError = 'Unexpected result type from run_terminal_command' + } + + if (!sessionName) { + const errorMsg = parseError || 'Session name was empty' + logger.error({ parseError: errorMsg }, 'Failed to start tmux session') + yield { + toolName: 'set_output', + input: { + overallStatus: 'failure', + summary: 'Failed to start ' + CLI_NAME + ' tmux session. ' + errorMsg, + sessionName: '', + scriptIssues: [ + { + script: 'tmux-cli.sh', + issue: errorMsg, + errorOutput: JSON.stringify(toolResult), + suggestedFix: 'Ensure tmux-cli.sh outputs the session name to stdout and exits with code 0. Check that tmux is installed.', + }, + ], + captures: [], + }, + } + return + } + + logger.info('Successfully started tmux session: ' + sessionName) + + yield { + toolName: 'add_message', + input: { + role: 'user', + content: 'A ' + CLI_NAME + ' tmux session has been started: `' + sessionName + '`\n\n' + + 'Use this session for all CLI interactions. The session name must be included in your final output.\n\n' + + 'Proceed with the task using the helper scripts:\n' + + '- Send commands: `./scripts/tmux/tmux-cli.sh send "' + sessionName + '" "..."`\n' + + '- Capture output: `./scripts/tmux/tmux-cli.sh capture "' + sessionName + '" --label "..."`\n' + + '- Stop when done: `./scripts/tmux/tmux-cli.sh stop "' + sessionName + '"`', + }, + includeToolCall: false, + } -For advanced options, run \`./scripts/tmux/tmux-cli.sh help\` or check individual scripts with \`--help\`.`, + yield 'STEP_ALL' + }, } export default definition diff --git a/.agents/gemini-cli.ts b/.agents/gemini-cli.ts index 43ecaf7d27..d5eb7f45e2 100644 --- a/.agents/gemini-cli.ts +++ b/.agents/gemini-cli.ts @@ -1,457 +1,115 @@ +import { createCliAgent } from './lib/create-cli-agent' + import type { AgentDefinition } from './types/agent-definition' -const definition: AgentDefinition = { +const baseDefinition = createCliAgent({ id: 'gemini-cli', displayName: 'Gemini CLI', - model: 'anthropic/claude-opus-4.5', + cliName: 'Gemini', + shortName: 'gemini', + startCommand: 'gemini --yolo', + permissionNote: + 'Always use `--yolo` (or `--approval-mode yolo`) when testing to auto-approve all tool actions and avoid prompts that would block automated tests.', + model: 'anthropic/claude-opus-4.7', + cliSpecificDocs: `## Gemini CLI Commands - spawnerPrompt: `Expert at testing Google Gemini CLI functionality using tmux, or performing code reviews via Gemini. - -**Modes:** -- \`test\` (default): Spawns tmux sessions, sends input to Gemini CLI, captures terminal output, and validates behavior. -- \`review\`: Uses Gemini CLI to perform code reviews on specified files or directories. +Gemini CLI uses slash commands for navigation: +- \`/help\` - Show help information +- \`/tools\` - List available tools +- \`/quit\` - Exit the CLI (or Ctrl-C twice)`, +}) -**Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. +// Constants must be inside handleSteps since it gets serialized via .toString() +const definition: AgentDefinition = { + ...baseDefinition, + handleSteps: function* ({ prompt, params, logger }) { + const START_COMMAND = 'gemini --yolo' + const CLI_NAME = 'Gemini' + + yield { + toolName: 'add_message', + input: { + role: 'user', + content: 'Before starting the ' + CLI_NAME + ' CLI session, gather context by reading relevant files and understanding the task to provide better guidance to the CLI.', + }, + includeToolCall: false, + } -**Your responsibilities as the parent agent:** -1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes -2. Use \`read_files\` on the capture paths to see what the CLI displayed -3. Re-run the test after fixing any script issues`, + yield 'STEP' - inputSchema: { - prompt: { - type: 'string', - description: - 'Description of what to do. For test mode: what CLI functionality to test. For review mode: what code to review and any specific concerns.', - }, - params: { - type: 'object', - properties: { - mode: { - type: 'string', - enum: ['test', 'review'], - description: - 'Operation mode - "test" for CLI testing (default), "review" for code review via Gemini', - }, - }, - }, - }, + logger.info('Starting ' + CLI_NAME + ' tmux session...') - outputMode: 'structured_output', - outputSchema: { - type: 'object', - properties: { - overallStatus: { - type: 'string', - enum: ['success', 'failure', 'partial'], - description: 'Overall test outcome', - }, - summary: { - type: 'string', - description: 'Brief summary of what was tested and the outcome', - }, - testResults: { - type: 'array', - items: { - type: 'object', - properties: { - testName: { - type: 'string', - description: 'Name/description of the test', - }, - passed: { type: 'boolean', description: 'Whether the test passed' }, - details: { - type: 'string', - description: 'Details about what happened', - }, - capturedOutput: { - type: 'string', - description: 'Relevant output captured from the CLI', - }, - }, - required: ['testName', 'passed'], - }, - description: 'Array of individual test results', - }, - scriptIssues: { - type: 'array', - items: { - type: 'object', - properties: { - script: { - type: 'string', - description: - 'Which script had the issue (e.g., "tmux-start.sh", "tmux-send.sh")', - }, - issue: { - type: 'string', - description: 'What went wrong when using the script', - }, - errorOutput: { - type: 'string', - description: 'The actual error message or unexpected output', - }, - suggestedFix: { - type: 'string', - description: - 'Suggested fix or improvement for the parent agent to implement', - }, - }, - required: ['script', 'issue', 'suggestedFix'], - }, - description: - 'Issues encountered with the helper scripts that the parent agent should fix', - }, - captures: { - type: 'array', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Path to the capture file (relative to project root)', - }, - label: { - type: 'string', - description: - 'What this capture shows (e.g., "initial-cli-state", "after-help-command")', - }, - timestamp: { - type: 'string', - description: 'When the capture was taken', - }, - }, - required: ['path', 'label'], - }, - description: - 'Paths to saved terminal captures for debugging - check debug/tmux-sessions/{session}/', + const { toolResult } = yield { + toolName: 'run_terminal_command', + input: { + command: './scripts/tmux/tmux-cli.sh start --command "' + START_COMMAND + '"', + timeout_seconds: 30, }, - reviewFindings: { - type: 'array', - items: { - type: 'object', - properties: { - file: { - type: 'string', - description: 'File path where the issue was found', + } + + // Parse response from tmux-cli.sh (outputs plain session name on success, error to stderr on failure) + let sessionName = '' + let parseError = '' + + const result = toolResult?.[0] + if (result && result.type === 'json') { + const value = result.value as Record + const stdout = typeof value?.stdout === 'string' ? value.stdout.trim() : '' + const stderr = typeof value?.stderr === 'string' ? value.stderr.trim() : '' + const exitCode = typeof value?.exitCode === 'number' ? value.exitCode : undefined + + if (!stdout && !stderr) { + parseError = 'tmux-cli.sh returned empty output' + } else if (exitCode !== 0 || !stdout) { + parseError = stderr || 'tmux-cli.sh failed with no error message' + } else { + sessionName = stdout + } + } else { + parseError = 'Unexpected result type from run_terminal_command' + } + + if (!sessionName) { + const errorMsg = parseError || 'Session name was empty' + logger.error({ parseError: errorMsg }, 'Failed to start tmux session') + yield { + toolName: 'set_output', + input: { + overallStatus: 'failure', + summary: 'Failed to start ' + CLI_NAME + ' tmux session. ' + errorMsg, + sessionName: '', + scriptIssues: [ + { + script: 'tmux-cli.sh', + issue: errorMsg, + errorOutput: JSON.stringify(toolResult), + suggestedFix: 'Ensure tmux-cli.sh outputs the session name to stdout and exits with code 0. Check that tmux is installed.', }, - severity: { - type: 'string', - enum: ['critical', 'warning', 'suggestion', 'info'], - description: 'Severity level of the finding', - }, - line: { - type: 'number', - description: 'Line number (if applicable)', - }, - finding: { - type: 'string', - description: 'Description of the issue or suggestion', - }, - suggestion: { - type: 'string', - description: 'Suggested fix or improvement', - }, - }, - required: ['file', 'severity', 'finding'], + ], + captures: [], }, - description: - 'Code review findings (only populated in review mode)', + } + return + } + + logger.info('Successfully started tmux session: ' + sessionName) + + yield { + toolName: 'add_message', + input: { + role: 'user', + content: 'A ' + CLI_NAME + ' tmux session has been started: `' + sessionName + '`\n\n' + + 'Use this session for all CLI interactions. The session name must be included in your final output.\n\n' + + 'Proceed with the task using the helper scripts:\n' + + '- Send commands: `./scripts/tmux/tmux-cli.sh send "' + sessionName + '" "..."`\n' + + '- Capture output: `./scripts/tmux/tmux-cli.sh capture "' + sessionName + '" --label "..."`\n' + + '- Stop when done: `./scripts/tmux/tmux-cli.sh stop "' + sessionName + '"`', }, - }, - required: [ - 'overallStatus', - 'summary', - 'testResults', - 'scriptIssues', - 'captures', - ], - }, - includeMessageHistory: false, - - toolNames: [ - 'run_terminal_command', - 'read_files', - 'code_search', - 'set_output', - ], - - systemPrompt: `You are an expert at testing Google Gemini CLI using tmux. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. - -## Gemini CLI Startup - -For testing Gemini, use the \`--command\` flag with YOLO mode (auto-approve all actions): - -\`\`\`bash -# Start Gemini CLI (with YOLO mode - auto-approves all actions) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo") - -# Or with specific options -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo --help") -\`\`\` - -**Important:** Always use \`--yolo\` (or \`--approval-mode yolo\`) when testing to auto-approve all tool actions and avoid prompts that would block automated tests. - -## Helper Scripts - -Use these scripts in \`scripts/tmux/\` for reliable CLI testing: - -### Unified Script (Recommended) - -\`\`\`bash -# Start a Gemini test session (with YOLO mode) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo") - -# Send input to the CLI -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" - -# Capture output (optionally wait first) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - -# Stop the session when done -./scripts/tmux/tmux-cli.sh stop "$SESSION" - -# Stop all test sessions -./scripts/tmux/tmux-cli.sh stop --all -\`\`\` - -### Individual Scripts (More Options) - -\`\`\`bash -# Start with custom settings -./scripts/tmux/tmux-start.sh --command "gemini --yolo" --name gemini-test --width 160 --height 40 - -# Send text (auto-presses Enter) -./scripts/tmux/tmux-send.sh gemini-test "your prompt here" - -# Send without pressing Enter -./scripts/tmux/tmux-send.sh gemini-test "partial" --no-enter - -# Send special keys -./scripts/tmux/tmux-send.sh gemini-test --key Escape -./scripts/tmux/tmux-send.sh gemini-test --key C-c - -# Capture with colors -./scripts/tmux/tmux-capture.sh gemini-test --colors - -# Save capture to file -./scripts/tmux/tmux-capture.sh gemini-test -o output.txt -\`\`\` - -## Gemini CLI Commands + includeToolCall: false, + } -Gemini CLI uses slash commands for navigation: -- \`/help\` - Show help information -- \`/tools\` - List available tools -- \`/quit\` - Exit the CLI (or Ctrl-C twice) - -## Why These Scripts? - -The scripts handle **bracketed paste mode** automatically. Standard \`tmux send-keys\` drops characters with TUI apps like Gemini CLI due to how the CLI processes keyboard input. The helper scripts wrap input in escape sequences (\`\\e[200~...\\e[201~\`) so you don't have to. - -## Typical Test Workflow - -\`\`\`bash -# 1. Start a Gemini session (with YOLO mode) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo") -echo "Testing in session: $SESSION" - -# 2. Verify CLI started -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 3. Run your test -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" -sleep 2 -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 4. Clean up -./scripts/tmux/tmux-cli.sh stop "$SESSION" -\`\`\` - -## Session Logs (Paper Trail) - -All session data is stored in **YAML format** in \`debug/tmux-sessions/{session-name}/\`: - -- \`session-info.yaml\` - Session metadata (start time, dimensions, status) -- \`commands.yaml\` - YAML array of all commands sent with timestamps -- \`capture-{sequence}-{label}.txt\` - Captures with YAML front-matter - -\`\`\`bash -# Capture with a descriptive label (recommended) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - -# Capture saved to: debug/tmux-sessions/{session}/capture-001-after-help-command.txt -\`\`\` - -Each capture file has YAML front-matter with metadata: -\`\`\`yaml ---- -sequence: 1 -label: after-help-command -timestamp: 2025-01-01T12:00:30Z -after_command: "/help" -dimensions: - width: 120 - height: 30 ---- -[terminal content] -\`\`\` - -The capture path is printed to stderr. Both you and the parent agent can read these files to see exactly what the CLI displayed. - -## Debugging Tips - -- **Attach interactively**: \`tmux attach -t SESSION_NAME\` -- **List sessions**: \`./scripts/tmux/tmux-cli.sh list\` -- **View session logs**: \`ls debug/tmux-sessions/{session-name}/\` -- **Get help**: \`./scripts/tmux/tmux-cli.sh help\` or \`./scripts/tmux/tmux-start.sh --help\``, - - instructionsPrompt: `Instructions: - -Check the \`mode\` parameter to determine your operation: -- If \`mode\` is "review" or the prompt mentions reviewing/analyzing code: follow **Review Mode** instructions -- Otherwise: follow **Test Mode** instructions (default) - ---- - -## Test Mode Instructions - -1. **Use the helper scripts** in \`scripts/tmux/\` - they handle bracketed paste mode automatically - -2. **Start a Gemini test session** with YOLO mode: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo") - \`\`\` - -3. **Verify the CLI started** by capturing initial output: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" - \`\`\` - -4. **Send commands** and capture responses: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "your command here" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - \`\`\` - -5. **Always clean up** when done: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - -6. **Use labels when capturing** to create a clear paper trail: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - \`\`\` - ---- - -## Review Mode Instructions - -In review mode, you send a detailed review prompt to Gemini. The prompt MUST start with the word "review" and include specific areas of concern. - -### What We're Looking For - -The review should focus on these key areas: - -1. **Code Organization Issues** - - Poor file/module structure - - Unclear separation of concerns - - Functions/classes that do too many things - - Missing or inconsistent abstractions - -2. **Over-Engineering & Complexity** - - Unnecessarily abstract or generic code - - Premature optimization - - Complex patterns where simple solutions would suffice - - "Enterprise" patterns in small codebases - -3. **AI-Generated Code Patterns ("AI Slop")** - - Verbose, flowery language in comments ("It's important to note...", "Worth mentioning...") - - Excessive disclaimers and hedging in documentation - - Inconsistent coding style within the same file - - Overly generic variable/function names - - Redundant explanatory comments that just restate the code - - Sudden shifts between formal and casual tone - - Filler phrases that add no value - -4. **Lack of Systems-Level Thinking** - - Missing error handling strategy - - No consideration for scaling or performance - - Ignoring edge cases and failure modes - - Lack of observability (logging, metrics, tracing) - - Missing or incomplete type definitions - -### Workflow - -1. **Start Gemini** with YOLO mode: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo") - \`\`\` - -2. **Wait for CLI to initialize**, then capture: - \`\`\`bash - sleep 3 - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - \`\`\` - -3. **Send a detailed review prompt** (MUST start with "review"): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "Review [files/directories from prompt]. Look for: - - 1. CODE ORGANIZATION: Poor structure, unclear separation of concerns, functions doing too much - 2. OVER-ENGINEERING: Unnecessary abstractions, premature optimization, complex patterns where simple would work - 3. AI SLOP: Verbose comments ('it\\'s important to note'), excessive disclaimers, inconsistent style, generic names, redundant explanations - 4. SYSTEMS THINKING: Missing error handling strategy, no scaling consideration, ignored edge cases, lack of observability - - For each issue found, specify the file, line number, what\\'s wrong, and how to fix it. Be direct and specific." - \`\`\` - -4. **Wait for and capture the review output** (reviews take longer): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 - \`\`\` - - If the review is still in progress, wait and capture again: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output-continued" --wait 30 - \`\`\` - -5. **Parse the review output** and populate \`reviewFindings\` with: - - \`file\`: Path to the file with the issue - - \`severity\`: "critical", "warning", "suggestion", or "info" - - \`line\`: Line number if mentioned - - \`finding\`: Description of the issue - - \`suggestion\`: How to fix it - -6. **Clean up**: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - ---- - -## Output (Both Modes) - -**Report results using set_output** - You MUST call set_output with structured results: -- \`overallStatus\`: "success", "failure", or "partial" -- \`summary\`: Brief description of what was tested/reviewed -- \`testResults\`: Array of test outcomes (for test mode) -- \`scriptIssues\`: Array of any problems with the helper scripts -- \`captures\`: Array of capture paths with labels -- \`reviewFindings\`: Array of code review findings (for review mode) - -**If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: -- \`script\`: Which script failed -- \`issue\`: What went wrong -- \`errorOutput\`: The actual error message -- \`suggestedFix\`: How the parent agent should fix the script - -**Always include captures** in your output so the parent agent can see what you saw. - -For advanced options, run \`./scripts/tmux/tmux-cli.sh help\` or check individual scripts with \`--help\`.`, + yield 'STEP_ALL' + }, } export default definition diff --git a/.agents/lib/cli-agent-prompts.ts b/.agents/lib/cli-agent-prompts.ts new file mode 100644 index 0000000000..ff206345dc --- /dev/null +++ b/.agents/lib/cli-agent-prompts.ts @@ -0,0 +1,345 @@ +import { CLI_AGENT_MODES } from './cli-agent-types' + +import type { CliAgentConfig } from './cli-agent-types' + +const TMUX_SESSION_DOCS = `## Session Logs (Paper Trail) + +All session data is stored in **YAML format** in \`debug/tmux-sessions/{session-name}/\`: + +- \`session-info.yaml\` - Session metadata (start time, dimensions, status) +- \`commands.yaml\` - YAML array of all commands sent with timestamps +- \`capture-{sequence}-{label}.txt\` - Captures with YAML front-matter + +\`\`\`bash +# Capture with a descriptive label (recommended) +./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 + +# Capture saved to: debug/tmux-sessions/{session}/capture-001-after-help-command.txt +\`\`\` + +Each capture file has YAML front-matter with metadata: +\`\`\`yaml +--- +sequence: 1 +label: after-help-command +timestamp: 2025-01-01T12:00:30Z +after_command: "/help" +dimensions: + width: 120 + height: 30 +--- +[terminal content] +\`\`\` + +The capture path is printed to stderr. Both you and the parent agent can read these files to see exactly what the CLI displayed.` + +const TMUX_DEBUG_TIPS = `## Debugging Tips + +- **Attach interactively**: \`tmux attach -t SESSION_NAME\` +- **List sessions**: \`./scripts/tmux/tmux-cli.sh list\` +- **View session logs**: \`ls debug/tmux-sessions/{session-name}/\` +- **Get help**: \`./scripts/tmux/tmux-cli.sh help\` or \`./scripts/tmux/tmux-start.sh --help\`` + +const REVIEW_CRITERIA = `### What We're Looking For + +The review should focus on these key areas: + +1. **Code Organization Issues** + - Poor file/module structure + - Unclear separation of concerns + - Functions/classes that do too many things + - Missing or inconsistent abstractions + +2. **Over-Engineering & Complexity** + - Unnecessarily abstract or generic code + - Premature optimization + - Complex patterns where simple solutions would suffice + - "Enterprise" patterns in small codebases + +3. **AI-Generated Code Patterns ("AI Slop")** + - Verbose, flowery language in comments ("It's important to note...", "Worth mentioning...") + - Excessive disclaimers and hedging in documentation + - Inconsistent coding style within the same file + - Overly generic variable/function names + - Redundant explanatory comments that just restate the code + - Sudden shifts between formal and casual tone + - Filler phrases that add no value + +4. **Lack of Systems-Level Thinking** + - Missing error handling strategy + - No consideration for scaling or performance + - Ignoring edge cases and failure modes + - Lack of observability (logging, metrics, tracing) + - Missing or incomplete type definitions` + +export function getSpawnerPrompt(config: CliAgentConfig): string { + const defaultMode = config.defaultMode ?? 'work' + const modeDescriptions = { + work: `Use ${config.cliName} to implement features, fix bugs, refactor code, or complete other coding tasks.`, + review: `Uses ${config.cliName} CLI to perform code reviews on specified files or directories.`, + } + const modeLines = CLI_AGENT_MODES.map(mode => { + const isDefault = mode === defaultMode + return `- \`${mode}\`${isDefault ? ' (default)' : ''}: ${modeDescriptions[mode]}` + }).join('\n') + + const base = `Expert at using ${config.cliName} CLI via tmux for implementation work or code reviews. + +**Modes:** +${modeLines} + +**Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. + +**Your responsibilities as the parent agent:** +1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes +2. Use \`read_files\` on the capture paths to see what the CLI displayed +3. Re-run the agent after fixing any script issues` + + return config.spawnerPromptExtras ? `${base}\n\n${config.spawnerPromptExtras}` : base +} + +export function getSystemPrompt(config: CliAgentConfig): string { + const cliSpecificSection = config.cliSpecificDocs ? `\n${config.cliSpecificDocs}\n` : '\n' + + return `You are an expert at using ${config.cliName} CLI via tmux for implementation work and code reviews. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. + +## Session Management + +**A tmux session is started for you automatically.** The session name will be announced in an assistant message. Use that session name (stored in \`$SESSION\`) for all subsequent commands. + +**Do NOT start a new session** - use the one that was started for you. + +**Important:** ${config.permissionNote} +${cliSpecificSection} +## Operating Heuristics + +- Treat the provided tmux session as the single source of truth. Do not start a second session unless the current one has clearly failed and you are explicitly recovering from that failure. +- Prefer fewer, higher-value captures over many overlapping captures. +- A capture is worth taking when the UI meaningfully changes: startup ready state, help overlay open, task in progress, task complete, clean follow-up-ready state, or an error state. +- Avoid exploratory key presses that can mutate the UI state unless they are necessary for the task. +- If the CLI already shows enough evidence in the current viewport, do not keep scrolling or recapturing just to get a more perfect screenshot. +- If a long response is partially off-screen, prefer summarizing from the visible evidence instead of repeatedly trying viewport-recovery tricks unless the missing content is essential. +- Do not use \`read_files\` on tmux capture artifacts from inside the CLI tester run; rely on the terminal capture output you already obtained and let the parent agent inspect saved capture files later if needed. + +## Helper Scripts + +Use these scripts in \`scripts/tmux/\` to interact with the CLI session: + +\`\`\`bash +# Send input to the CLI +./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" + +# Capture output (optionally wait first) +./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 + +# Capture with a descriptive label +./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-task" --wait 5 + +# Stop the session when done +./scripts/tmux/tmux-cli.sh stop "$SESSION" +\`\`\` + +### Additional Options + +\`\`\`bash +# Send without pressing Enter +./scripts/tmux/tmux-send.sh "$SESSION" "partial" --no-enter + +# Send special keys +./scripts/tmux/tmux-send.sh "$SESSION" --key Escape +./scripts/tmux/tmux-send.sh "$SESSION" --key C-c + +# Capture with colors +./scripts/tmux/tmux-capture.sh "$SESSION" --colors +\`\`\` + +## Why These Scripts? + +The scripts handle **bracketed paste mode** automatically. Standard \`tmux send-keys\` drops characters with TUI apps like ${config.cliName} due to how the CLI processes keyboard input. The helper scripts wrap input in escape sequences (\`\\e[200~...\\e[201~\`) so you don't have to. + +${TMUX_SESSION_DOCS} + +${TMUX_DEBUG_TIPS}` +} + +export function getDefaultReviewModeInstructions(config: CliAgentConfig): string { + const isDefault = config.defaultMode === 'review' + return `## Review Mode Instructions${isDefault ? ' (Default)' : ''} + +In review mode, you send a detailed review prompt to ${config.cliName}. The prompt MUST start with the word "review" and include specific areas of concern. + +${REVIEW_CRITERIA} + +### Workflow + +**Note:** A tmux session will be started for you automatically after your preparation phase. Use the session name from the assistant message that announces it. + +1. **Wait for CLI to initialize**, then capture: + \`\`\`bash + sleep 3 + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" + \`\`\` + +2. **Send a detailed review prompt** (MUST start with "review"): + \`\`\`bash + ./scripts/tmux/tmux-cli.sh send "$SESSION" "Review [files/directories from prompt]. Look for: + + 1. CODE ORGANIZATION: Poor structure, unclear separation of concerns, functions doing too much + 2. OVER-ENGINEERING: Unnecessary abstractions, premature optimization, complex patterns where simple would work + 3. AI SLOP: Verbose comments ('it\\'s important to note'), excessive disclaimers, inconsistent style, generic names, redundant explanations + 4. SYSTEMS THINKING: Missing error handling strategy, no scaling consideration, ignored edge cases, lack of observability + + For each issue found, specify the file, line number, what's wrong, and how to fix it. Be direct and specific." + \`\`\` + +3. **Wait for and capture the review output** (reviews take longer): + \`\`\`bash + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 + \`\`\` + + If the review is still in progress, wait and capture again: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output-continued" --wait 30 + \`\`\` + +4. **Parse the review output** and populate \`reviewFindings\` with: + - \`file\`: Path to the file with the issue + - \`severity\`: "critical", "warning", "suggestion", or "info" + - \`line\`: Line number if mentioned + - \`finding\`: Description of the issue + - \`suggestion\`: How to fix it + +5. **Clean up**: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh stop "$SESSION" + \`\`\`` +} + +export function getWorkModeInstructions(config: CliAgentConfig): string { + const isDefault = (config.defaultMode ?? 'work') === 'work' + return `## Work Mode Instructions${isDefault ? ' (Default)' : ''} + +Use ${config.cliName} to complete implementation tasks like building features, fixing bugs, or refactoring code. + +### Workflow + +**Note:** A tmux session will be started for you automatically after your preparation phase. Use the session name from the assistant message that announces it. + +1. **Wait for CLI to initialize**, then capture: + \`\`\`bash + sleep 3 + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" + \`\`\` + +2. **Send your task** (from the prompt you received) to the CLI: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh send "$SESSION" "" + \`\`\` + + Use the exact task description from the prompt the parent agent gave you. + +3. **Wait for completion and capture output** (implementation tasks may take a while): + \`\`\`bash + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "work-in-progress" --wait 30 + \`\`\` + + If the work is still in progress, wait and capture again: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "work-continued" --wait 30 + \`\`\` + + Prefer at most 1-2 progress captures before deciding whether you already have enough evidence. + +4. **Send follow-up prompts** if needed to refine or continue the work: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh send "$SESSION" "" + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "follow-up" --wait 30 + \`\`\` + +5. **Verify the changes** by checking files or running commands: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh send "$SESSION" "run the tests to verify the changes" + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "verification" --wait 60 + \`\`\` + +6. **Clean up** when done: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh stop "$SESSION" + \`\`\` + +### Tips + +- Break complex tasks into smaller prompts +- Prefer high-value captures tied to meaningful UI changes rather than frequent overlapping captures +- Use descriptive labels for captures +- Check intermediate results before moving on` +} + +export function getInstructionsPrompt(config: CliAgentConfig): string { + const defaultMode = config.defaultMode ?? 'work' + const workModeInstructions = config.workModeInstructions ?? getWorkModeInstructions(config) + const reviewModeInstructions = config.reviewModeInstructions ?? getDefaultReviewModeInstructions(config) + + const modeNames = { work: 'Work Mode', review: 'Review Mode' } + const nonDefaultModes = CLI_AGENT_MODES.filter(m => m !== defaultMode) + const modeChecks = nonDefaultModes.map(m => `- If \`mode\` is "${m}": follow **${modeNames[m]}** instructions`).join('\n') + + const workflowSection = config.skipPrepPhase + ? `## Workflow + +**A tmux session is started for you immediately.** An assistant message will announce the session name. **Do NOT start a new session** - use the one provided.` + : `## Two-Phase Workflow + +This agent operates in two phases: + +### Phase 1: Preparation (Current Phase) +You have an opportunity to prepare before the CLI session starts. Use this time to: +- Read relevant files to understand the codebase +- Search for code patterns or implementations +- Understand the task requirements +- Gather context that will help you guide the CLI effectively + +After your preparation turn, a tmux session will be started automatically. + +### Phase 2: CLI Execution +Once the session starts, an assistant message will announce the session name. **Do NOT start a new session** - use the one provided.` + + return `Instructions: + +${workflowSection} + +Check the \`mode\` parameter to determine your operation: +${modeChecks} +- Otherwise: follow **${modeNames[defaultMode]}** instructions (default) + +--- + +${workModeInstructions} + +--- + +${reviewModeInstructions} + +--- + +## Output (All Modes) + +**Report results using set_output** - You MUST call set_output with structured results: +- \`overallStatus\`: "success", "failure", or "partial" +- \`summary\`: Brief description of what was done +- \`sessionName\`: The tmux session name (REQUIRED - from the session started for you) +- \`results\`: Array of task outcomes (for work mode) +- \`scriptIssues\`: Array of any problems with the helper scripts +- \`captures\`: Array of capture paths with labels (MUST have at least one capture) +- \`reviewFindings\`: Array of code review findings (for review mode) + +**If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: +- \`script\`: Which script failed +- \`issue\`: What went wrong +- \`errorOutput\`: The actual error message +- \`suggestedFix\`: How to fix the script + +**Always include captures** in your output so the parent agent can see what you saw. + +For advanced options, run \`./scripts/tmux/tmux-cli.sh help\` or check individual scripts with \`--help\`.` +} diff --git a/.agents/lib/cli-agent-schemas.ts b/.agents/lib/cli-agent-schemas.ts new file mode 100644 index 0000000000..6c063a9902 --- /dev/null +++ b/.agents/lib/cli-agent-schemas.ts @@ -0,0 +1,76 @@ +// Shared output schema for CLI agents. results for work mode, reviewFindings for review mode. +export const outputSchema = { + type: 'object' as const, + properties: { + overallStatus: { + type: 'string' as const, + enum: ['success', 'failure', 'partial'], + description: 'Overall outcome', + }, + summary: { + type: 'string' as const, + description: 'Brief summary of what was done and the outcome', + }, + sessionName: { + type: 'string' as const, + description: 'The tmux session name that was used for CLI interactions', + }, + results: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + name: { type: 'string' as const, description: 'Name/description of the task' }, + passed: { type: 'boolean' as const, description: 'Whether the task succeeded' }, + details: { type: 'string' as const, description: 'Details about what happened' }, + capturedOutput: { type: 'string' as const, description: 'Relevant output captured from the CLI' }, + }, + required: ['name', 'passed'], + }, + description: 'Array of individual task results', + }, + scriptIssues: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + script: { type: 'string' as const, description: 'Which script had the issue (e.g., "tmux-start.sh", "tmux-send.sh")' }, + issue: { type: 'string' as const, description: 'What went wrong when using the script' }, + errorOutput: { type: 'string' as const, description: 'The actual error message or unexpected output' }, + suggestedFix: { type: 'string' as const, description: 'Suggested fix or improvement for the parent agent to implement' }, + }, + required: ['script', 'issue', 'suggestedFix'], + }, + description: 'Issues encountered with the helper scripts that should be fixed', + }, + captures: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + path: { type: 'string' as const, description: 'Path to the capture file (relative to project root)' }, + label: { type: 'string' as const, description: 'What this capture shows (e.g., "initial-cli-state", "after-help-command")' }, + timestamp: { type: 'string' as const, description: 'When the capture was taken' }, + }, + required: ['path', 'label'], + }, + description: 'Paths to saved terminal captures for debugging - check debug/tmux-sessions/{session}/', + }, + reviewFindings: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + file: { type: 'string' as const, description: 'File path where the issue was found' }, + severity: { type: 'string' as const, enum: ['critical', 'warning', 'suggestion', 'info'], description: 'Severity level of the finding' }, + line: { type: 'number' as const, description: 'Line number (if applicable)' }, + finding: { type: 'string' as const, description: 'Description of the issue or suggestion' }, + suggestion: { type: 'string' as const, description: 'Suggested fix or improvement' }, + }, + required: ['file', 'severity', 'finding'], + }, + description: 'Code review findings (only populated in review mode)', + }, + }, + required: ['overallStatus', 'summary', 'sessionName', 'scriptIssues', 'captures'], +} diff --git a/.agents/lib/cli-agent-types.ts b/.agents/lib/cli-agent-types.ts new file mode 100644 index 0000000000..0d8f9771a0 --- /dev/null +++ b/.agents/lib/cli-agent-types.ts @@ -0,0 +1,52 @@ +export type CliAgentMode = 'work' | 'review' + +/** + * Result type for tmux-start.sh JSON output. + * The shell script outputs this JSON format to stdout. + * See: scripts/tmux/tmux-start.sh + */ +export type TmuxStartResult = + | { status: 'success'; sessionName: string } + | { status: 'failure'; error: string } + +export const CLI_AGENT_MODES: readonly CliAgentMode[] = ['work', 'review'] as const + +export interface InputParamDefinition { + type: 'string' | 'number' | 'boolean' | 'array' | 'object' + description?: string + enum?: string[] +} + +/** + * Extra input params that can be added to CLI agent configs. + * Uses key remapping to exclude 'mode' at compile time (Omit on Record is a no-op). + */ +export type ExtraInputParams = { + [K in string as K extends 'mode' ? never : K]?: InputParamDefinition +} + +export interface CliAgentConfig { + id: string + displayName: string + cliName: string + /** Used for session naming, e.g., 'claude-code' -> sessions named 'claude-code-test' */ + shortName: string + startCommand: string + permissionNote: string + model: string + /** Default mode when mode param is not specified. Defaults to 'work' */ + defaultMode?: CliAgentMode + spawnerPromptExtras?: string + extraInputParams?: ExtraInputParams + /** Custom instructions for work mode. If not provided, uses getWorkModeInstructions() */ + workModeInstructions?: string + /** Custom instructions for review mode. If not provided, uses getDefaultReviewModeInstructions() */ + reviewModeInstructions?: string + cliSpecificDocs?: string + /** + * If true, skips the preparation phase before starting the tmux session. + * Use this for agents that test the CLI itself (like codebuff-local-cli) + * rather than external tools that need context gathering. + */ + skipPrepPhase?: boolean +} diff --git a/.agents/lib/create-cli-agent.ts b/.agents/lib/create-cli-agent.ts new file mode 100644 index 0000000000..43159ae02e --- /dev/null +++ b/.agents/lib/create-cli-agent.ts @@ -0,0 +1,77 @@ +import { + getSpawnerPrompt, + getSystemPrompt, + getInstructionsPrompt, +} from './cli-agent-prompts' +import { outputSchema } from './cli-agent-schemas' +import { CLI_AGENT_MODES } from './cli-agent-types' + +import type { CliAgentConfig } from './cli-agent-types' +import type { AgentDefinition } from '../types/agent-definition' + +export function createCliAgent(config: CliAgentConfig): AgentDefinition { + // Simple validation for shortName since it's used in file paths + if (!/^[a-z0-9-]+$/.test(config.shortName)) { + throw new Error( + `CliAgentConfig '${config.id}': shortName must be lowercase alphanumeric with hyphens, got '${config.shortName}'` + ) + } + + const defaultMode = config.defaultMode ?? 'work' + const modeDescriptions = { + work: 'implementation tasks', + review: `code review via ${config.cliName}`, + } + const modeDescParts = CLI_AGENT_MODES.map(mode => { + const isDefault = mode === defaultMode + return `"${mode}" for ${modeDescriptions[mode]}${isDefault ? ' (default)' : ''}` + }) + + const baseInputParams = { + mode: { + type: 'string' as const, + enum: [...CLI_AGENT_MODES], + description: `Operation mode - ${modeDescParts.join(', ')}`, + }, + } + + const inputParams = config.extraInputParams + ? { ...baseInputParams, ...config.extraInputParams } + : baseInputParams + + return { + id: config.id, + displayName: config.displayName, + model: config.model, + providerOptions: { + ignore: ['Amazon Bedrock'], + }, + + spawnerPrompt: getSpawnerPrompt(config), + + inputSchema: { + prompt: { + type: 'string' as const, + description: + 'Description of what to do. For work mode: implementation task to complete. For review mode: code to review.', + }, + params: { + type: 'object' as const, + properties: inputParams, + }, + }, + + outputMode: 'structured_output', + outputSchema, + includeMessageHistory: false, + + toolNames: ['run_terminal_command', 'read_files', 'code_search', 'set_output', 'add_message'], + + // NOTE: handleSteps is NOT defined here - each CLI agent file defines its own + // handleSteps with hardcoded config values following the context-pruner pattern. + // See claude-code-cli.ts, codex-cli.ts, etc. + + systemPrompt: getSystemPrompt(config), + instructionsPrompt: getInstructionsPrompt(config), + } +} diff --git a/.agents/notion-agent.ts b/.agents/notion-agent.ts index 8bdfefc56c..37bfb88e9f 100644 --- a/.agents/notion-agent.ts +++ b/.agents/notion-agent.ts @@ -3,7 +3,7 @@ import type { AgentDefinition } from './types/agent-definition' const definition: AgentDefinition = { id: 'notion-query-agent', displayName: 'Notion Query Agent', - model: 'x-ai/grok-4-fast', + model: 'google/gemini-3.1-flash-lite-preview', spawnerPrompt: 'Expert at querying Notion databases and pages to find information and answer questions about content stored in Notion workspaces.', diff --git a/.agents/notion-researcher.ts b/.agents/notion-researcher.ts index 38db0917d1..341e7d30b3 100644 --- a/.agents/notion-researcher.ts +++ b/.agents/notion-researcher.ts @@ -1,11 +1,12 @@ -import type { AgentDefinition } from './types/agent-definition' import { publisher } from './constants' +import type { AgentDefinition } from './types/agent-definition' + const definition: AgentDefinition = { id: 'notion-researcher', publisher, displayName: 'Notion Researcher', - model: 'x-ai/grok-4-fast', + model: 'google/gemini-3.1-flash-lite-preview', spawnerPrompt: 'Expert at conducting comprehensive research across Notion workspaces by spawning multiple notion agents in parallel waves to gather information from different angles and sources.', diff --git a/.agents/package.json b/.agents/package.json index e6dd6fc4e7..053d1e6c66 100644 --- a/.agents/package.json +++ b/.agents/package.json @@ -5,7 +5,6 @@ "type": "module", "scripts": { "typecheck": "bun x tsc --noEmit -p tsconfig.json", - "test": "bun test __tests__", - "test:e2e": "bun test e2e" + "test": "bun test __tests__" } } diff --git a/.agents/sessions/03-02-1407-chatgpt-oauth-direct/LESSONS.md b/.agents/sessions/03-02-1407-chatgpt-oauth-direct/LESSONS.md new file mode 100644 index 0000000000..0dbb6fd5b9 --- /dev/null +++ b/.agents/sessions/03-02-1407-chatgpt-oauth-direct/LESSONS.md @@ -0,0 +1,42 @@ +# LESSONS — ChatGPT OAuth Direct Routing + +Session: `.agents/sessions/03-02-14:07-chatgpt-oauth-direct/` + +## What went well +- Building this feature behind a strict feature flag (`CHATGPT_OAUTH_ENABLED=false`) reduced rollout risk while allowing full end-to-end wiring. +- Reusing the Claude OAuth architectural pattern (credentials helpers, refresh mutex, routing split) accelerated implementation without coupling the two providers. +- Splitting policy logic into `classifyChatGptOAuthStreamError` made fallback/auth/fail-fast behavior easier to test and reason about. +- Adding focused CLI tests for `/connect:chatgpt` gating and utility sanitization caught regression risk early. + +## Current confidence / known gaps +- Runtime ChatGPT stream policy is **partially tested**: `classifyChatGptOAuthStreamError` is covered, but we do not yet have full behavioral tests for `promptAiSdkStream` recursion branches (actual fallback recursion and post-partial-output behavior). +- CLI routing coverage is strongest for **feature-flag OFF** paths; flag-ON auth-code routing should get explicit dedicated tests in a future pass. + +## What was tricky +- The repo had unrelated local drift during implementation; explicit scope cleanup (`git checkout -- `) was necessary to avoid accidental cross-feature commits. +- CLI module mocking is path-sensitive. Test modules under `cli/src/commands/__tests__` must mock sibling modules with correct relative paths (e.g. `../../state/chat-store`), or mocks silently fail. +- Over-mocking analytics can break transitive imports (`setAnalyticsErrorLogger` export expectations). A safe pattern is spreading real analytics exports and overriding only `trackEvent`. + +## Unexpected behaviors / gotchas +- A staged unrelated file can survive despite working-tree revert; both staged and worktree states must be checked before final handoff. +- “Looks correct” tests can still miss runtime branches if they only validate helper classification, not route wiring; reviewer loops were useful to force coverage on practical paths. +- For OAuth tooling/scripts, sanitize error text aggressively. Returning status-only errors avoids accidental token payload leakage. + +## Useful patterns discovered +- Keep direct-provider routing stream-only initially; explicitly forcing non-streaming/structured calls to backend avoided broad compatibility risk. +- Use deterministic model allowlist + normalization mapping in constants to avoid relying on provider-side parsing/errors for unsupported models. +- Treat temporary protocol validation scripts as first-class validation artifacts: they are valuable for real-account smoke checks without coupling to full CLI runtime. + +## Temporary script disposition +- `scripts/chatgpt-oauth-validate.ts` is currently kept as a **dev utility** for manual protocol revalidation while the feature remains experimental/off by default. +- Removal criteria: if protocol endpoints are either officially documented or the CLI flow gets stable automated integration coverage, this script can be retired. + +## Repeatable security verification +- For redaction checks, run targeted searches against changed code/log handling paths for sensitive markers before handoff, e.g. `access_token`, `refresh_token`, and `Authorization: Bearer`. +- Keep surfaced token exchange errors status-only and avoid echoing raw provider response bodies. + +## Follow-up improvements worth considering +- Add deeper runtime-behavior tests for `promptAiSdkStream` recursive fallback branches (not just policy classifier). +- Add explicit CLI test for flag-ON connect flow path once flag toggling is test-harness friendly. +- If feature graduates from experimental, add richer direct-path observability while preserving strict token redaction. +- Add periodic protocol drift checks (authorize/token/callback PKCE assumptions) before enabling the feature flag in production defaults. diff --git a/.agents/sessions/03-02-1407-chatgpt-oauth-direct/PLAN.md b/.agents/sessions/03-02-1407-chatgpt-oauth-direct/PLAN.md new file mode 100644 index 0000000000..9684c95329 --- /dev/null +++ b/.agents/sessions/03-02-1407-chatgpt-oauth-direct/PLAN.md @@ -0,0 +1,104 @@ +# PLAN — ChatGPT Subscription OAuth Direct Routing + +## Implementation Steps +1. **Add shared ChatGPT OAuth constants** + - Create `common/src/constants/chatgpt-oauth.ts` with: + - feature flag (`CHATGPT_OAUTH_ENABLED=false`) + - endpoints/client id/redirect URI/env var + - model allowlist + normalization helpers + - Export through `common/src/constants/index.ts`. + +2. **Build core OAuth utility + temporary protocol validation script (early gate)** + - Create `cli/src/utils/chatgpt-oauth.ts` with PKCE URL generation, browser-open helper, pasted code/URL parsing, token exchange helper. + - Create `scripts/chatgpt-oauth-validate.ts` to test OAuth URL generation + paste parsing + token exchange interaction. + - **Run this script before full integration** as go/no-go checkpoint for endpoint assumptions. + +3. **Add SDK env + credential support** + - Extend `sdk/src/env.ts` with `getChatGptOAuthTokenFromEnv()`. + - Extend `sdk/src/credentials.ts` with `chatgptOAuth` schema and helpers: + - get/save/clear + - valid-check + refresh mutex + - get-valid-with-refresh + - Preserve all non-target credentials in read/write operations. + +4. **Add CLI connect flow UI and command routing** + - Create `cli/src/components/chatgpt-connect-banner.tsx` with state machine + `handleChatGptAuthCode`. + - Update input modes (`connect:chatgpt`) and banner registry. + - Add `/connect:chatgpt` command + alias handling and slash command entry (feature-gated). + - Extend router to process pasted auth code in `connect:chatgpt` mode. + - Verify command visibility: hidden when flag OFF, present when flag ON. + +5. **Implement direct routing primitives in model-provider (decomposed)** + - 5.1 Add ChatGPT direct eligibility checks (feature flag + creds + model scope + skip flag + rate-limit cache state). + - 5.2 Add model normalization + prevalidation helpers (OpenRouter-style -> provider-native). + - 5.3 Add strict payload sanitization helper for direct requests. + - 5.4 Add ChatGPT OAuth direct model construction using OpenAI-compatible transport. + - 5.5 Add ChatGPT rate-limit cache helpers (parallel to Claude cache pattern). + - Keep Claude OAuth path unchanged. + +6. **Update stream execution + fallback/error policy** + - Extend `sdk/src/impl/llm.ts` to: + - recognize ChatGPT direct route usage + - emit ChatGPT OAuth analytics + - fallback only on rate-limit errors + - fail with reconnect guidance on auth errors + - fail fast for all other direct errors + - skip cost accounting for successful ChatGPT direct requests + - avoid fallback once output has already streamed + +7. **Wire startup refresh + CLI status surfacing** + - Update `cli/src/init/init-app.ts` for background ChatGPT OAuth credential refresh when enabled. + - Update `cli/src/chat.tsx`, `cli/src/components/bottom-status-line.tsx`, and `cli/src/components/usage-banner.tsx` to surface ChatGPT connection/active status. + +8. **Add analytics constants + SDK exports** + - Extend `common/src/constants/analytics-events.ts` with ChatGPT OAuth request/rate-limit/auth-error events. + - Ensure SDK exports newly needed helper(s) in `sdk/src/index.ts`. + +9. **Add/adjust tests (explicit matrix)** + - SDK credentials tests: + - env precedence + - persisted read/write/clear + - refresh success/failure + mutex + - Model-provider tests: + - rate-limit cache lifecycle + - allowlist prevalidation + unsupported-model error + - normalization behavior for mapped/unknown variants + - LLM routing/fallback tests (targeted): + - 429 fallback + - 401/403 no-fallback + reconnect path + - timeout/5xx fail-fast + - no fallback after content emitted + - CLI tests/wiring checks: + - command/mode visibility by feature flag + - connect mode routing and handler call. + - Non-streaming/structured guard check: + - confirm backend-only behavior unchanged. + +10. **Validation and cleanup decision for temporary script** + - Run targeted tests/typechecks for touched packages. + - Run OAuth validation script in manual mode (with your account interaction if needed). + - Decide and apply final disposition of temporary script: + - keep as dev utility, or + - remove before finalization. + +11. **Security/redaction verification** + - Validate no token values are logged in direct feature code paths. + - Grep/check for accidental logging of authorization headers, token payload fields, or raw callback query params. + +## Dependencies / Ordering +- Step 1 must be first. +- Step 2 must run before deep integration (early protocol validation gate). +- Step 3 precedes Steps 5–7. +- Step 4 can run in parallel with Step 3 after constants/util setup. +- Step 5 must precede Step 6. +- Step 8 can be implemented alongside Steps 5–6 but must complete before final validation. +- Step 9 follows core implementation completion. +- Steps 10–11 are final validation/cleanup/security passes. + +## Risk Areas +1. **Unofficial OAuth contract drift** — endpoint/field incompatibility can break token exchange. +2. **Direct payload compatibility** — strict sanitization must retain required OpenAI fields. +3. **Error classification correctness** — misclassification can violate requested fallback policy. +4. **Model normalization accuracy** — wrong mapping yields avoidable provider failures. +5. **Token redaction** — avoid leakage in logs, errors, or analytics payloads. +6. **Streaming boundary behavior** — fallback must not happen after partial output is emitted. diff --git a/.agents/sessions/03-02-1407-chatgpt-oauth-direct/SPEC.md b/.agents/sessions/03-02-1407-chatgpt-oauth-direct/SPEC.md new file mode 100644 index 0000000000..d56a415caf --- /dev/null +++ b/.agents/sessions/03-02-1407-chatgpt-oauth-direct/SPEC.md @@ -0,0 +1,155 @@ +# SPEC — ChatGPT Subscription OAuth Direct Routing + +## Overview +Implement an **experimental, default-disabled** ChatGPT subscription OAuth feature that allows the local CLI to route eligible OpenAI-model **streaming** requests directly to OpenAI instead of Codebuff backend routing, mirroring the prior Claude OAuth architecture pattern. + +## Protocol Assumptions (Explicit) +Because this is unofficial/experimental, this implementation proceeds under the following explicit assumptions: + +1. OAuth authorize endpoint: `https://auth.openai.com/oauth/authorize` +2. OAuth token endpoint: `https://auth.openai.com/oauth/token` +3. Public client id is configurable constant, defaulting to Codex-compatible value from ecosystem references. +4. PKCE (`S256`) is required. +5. Redirect URI is pinned to: `http://localhost:1455/auth/callback` +6. User can paste either: + - raw authorization code, or + - full callback URL containing code/state query params. +7. Token response includes at least `access_token`, optional `refresh_token`, and expiry info (`expires_in` or equivalent). +8. Refresh uses standard `grant_type=refresh_token`. + +If any assumption fails at runtime, the feature fails with explicit guidance and remains safely fallbackable only where policy allows. + +## Requirements +1. Add ChatGPT OAuth feature set, default disabled behind `CHATGPT_OAUTH_ENABLED = false`. +2. Add a new CLI command and mode: `/connect:chatgpt` with dedicated banner flow. +3. Implement browser-based PKCE code-paste flow (no device-code flow in this iteration). +4. Keep user-facing warning minimal (per user preference), while leaving code comments clearly marking experimental nature. +5. Store ChatGPT OAuth credentials in local credentials JSON alongside existing credentials. +6. Support env-var token override (power-user/automation use), but env var **must not bypass feature flag**. +7. Add refresh-token support with concurrency guard (mutex) for persisted credentials. +8. Direct routing scope is **streaming only** (`promptAiSdkStream` path); non-streaming and structured stay backend-routed. +9. Add model allowlist for direct routing; include optimistic aliases: + - `openai/gpt-5.3` + - `openai/gpt-5.3-codex` + - `openai/gpt-5.2` + - `openai/gpt-5.2-codex` + - plus selected nearby GPT/Codex IDs already present in repo config. +10. Provide deterministic model normalization for direct requests (OpenRouter-style -> provider-native): + - Example: `openai/gpt-5.3-codex` -> `gpt-5.3-codex` + - Mapping table lives in constants and is used for prevalidation. +11. Unsupported model handling must be deterministic and prevalidated: + - if model is not in allowlist/mapping for direct route, fail with explicit unsupported-model error (no fallback). +12. Fallback policy: + - Rate-limit/overload classification: auto-fallback to Codebuff backend. + - Auth errors (401/403): fail explicitly with reconnect guidance (no fallback). + - All other direct errors: fail fast (no fallback), per user decision. +13. Successful direct ChatGPT OAuth requests do **not** consume Codebuff credits. +14. Add lightweight ChatGPT connection status surfacing in CLI (usage banner and/or bottom status line), without quota API dependency. +15. Preserve existing Claude OAuth behavior unchanged. +16. Add temporary OAuth validation script that tests auth URL generation + token exchange manually before/alongside full wiring. +17. Add/update tests for credential parsing/storage/refresh, model gating, routing/fallback classification, and CLI command/mode wiring. +18. Never log OAuth tokens in analytics or error logs. + +## Direct Request Transformation Rules +Before sending direct streaming requests to OpenAI, enforce strict sanitization: + +1. Rewrite `model` from `openai/*` format to provider-native mapped id. +2. Remove provider-specific/non-OpenAI fields (e.g., codebuff metadata/provider routing payloads). +3. Preserve fields known to be valid for OpenAI-compatible chat completions. +4. Do not inject Codex-specific required prefix by default in v1 (user preference), but structure code so optional future injection is easy. + +## Error Classification Table +| Class | Detection | Behavior | +|---|---|---| +| Rate limit | HTTP 429 or message/body contains rate-limit indicators | Fallback to backend (if no output emitted yet) | +| Auth | HTTP 401/403 or auth-token-invalid indicators | Fail with reconnect guidance; no fallback | +| Unsupported model | Local allowlist/mapping precheck failure | Fail explicit unsupported-model error; no fallback | +| Other | Network timeout, 5xx, malformed payload, unknown 4xx | Fail fast; no fallback | + +## Routing Scope +1. Direct routing applies only to `promptAiSdkStream` eligible requests. +2. `promptAiSdk` and `promptAiSdkStructured` remain backend-only for this iteration. +3. Backend routing remains unchanged for all non-eligible models and when feature disabled/disconnected. + +## Credentials & Precedence Rules +1. Credentials file schema extends with `chatgptOAuth` object. +2. Precedence: env token override > persisted OAuth credentials > none. +3. Env token produces synthetic non-refreshing credentials object. +4. Persisted credentials refresh when expired/near-expiry (5-minute buffer). +5. On refresh failure for persisted credentials, clear only `chatgptOAuth` entry (preserve other credentials). + +## Feature Gating Matrix +1. `CHATGPT_OAUTH_ENABLED = false` + - hide `/connect:chatgpt` command and banner UX + - disable direct routing even if env token exists +2. `CHATGPT_OAUTH_ENABLED = true` and credentials available + - enable command/UI + - enable direct routing for eligible models + +## Logging/Redaction Requirements +1. Never log raw access tokens, refresh tokens, authorization headers, or token response payloads. +2. If callback URL is logged for debugging, redact query values for `code`, `access_token`, `refresh_token`, and similar sensitive keys. +3. Analytics properties must not include token-bearing strings. + +## Technical Approach +1. Create `common/src/constants/chatgpt-oauth.ts`: + - feature flag, endpoints, client id, redirect URI, env var name, model allowlist/mapping helpers. +2. Export new constants via `common/src/constants/index.ts` so legacy `old-constants` re-export path includes them. +3. Extend `sdk/src/env.ts` with ChatGPT OAuth env-token helper. +4. Extend `sdk/src/credentials.ts` with ChatGPT OAuth schema+helpers mirroring Claude pattern. +5. Create `cli/src/utils/chatgpt-oauth.ts` for PKCE start/open/exchange/disconnect/status. +6. Create `cli/src/components/chatgpt-connect-banner.tsx` and auth-code handler. +7. Wire CLI command/input mode/slash menu/router/banner registry for `connect:chatgpt`. +8. Extend model provider (`sdk/src/impl/model-provider.ts`): + - add ChatGPT direct route decision path for `openai/*` allowlisted models + - add rate-limit cache helpers for ChatGPT path + - build direct OpenAI-compatible language model with OAuth bearer auth + - enforce strict body sanitization + model normalization in the direct path. +9. Extend stream error handling (`sdk/src/impl/llm.ts`) for ChatGPT direct path with required fallback/fail rules and analytics. +10. Extend app init (`cli/src/init/init-app.ts`) for background ChatGPT credential refresh when enabled. +11. Add analytics events for ChatGPT OAuth request/rate-limit/auth-error. +12. Update usage/status UI text to include ChatGPT connection state. +13. Add temporary validation script (e.g., `scripts/chatgpt-oauth-validate.ts`) to exercise OAuth setup interactively. + +## Acceptance Criteria +1. With feature disabled, `/connect:chatgpt` is unavailable and no direct routing occurs. +2. With feature enabled, user can run `/connect:chatgpt`, complete browser flow, paste code/URL, and connect. +3. Eligible streaming requests on allowlisted `openai/*` models use direct OAuth path. +4. Direct request payloads are sanitized and model ids normalized before transmission. +5. Rate-limited direct requests fallback to backend automatically. +6. Auth failures produce reconnect guidance and do not fallback. +7. Unsupported models fail immediately with explicit unsupported-model message. +8. Successful direct requests skip Codebuff credit accounting path. +9. Existing Claude OAuth flow remains behaviorally unchanged. +10. New/updated tests pass for touched behavior. +11. Temporary validation script can run and guide manual OAuth exchange checks. + +## Files to Create/Modify +- Create: `common/src/constants/chatgpt-oauth.ts` +- Create: `cli/src/utils/chatgpt-oauth.ts` +- Create: `cli/src/components/chatgpt-connect-banner.tsx` +- Create: `scripts/chatgpt-oauth-validate.ts` (temporary validation utility) +- Modify: `common/src/constants/index.ts` +- Modify: `common/src/constants/analytics-events.ts` +- Modify: `sdk/src/env.ts` +- Modify: `sdk/src/credentials.ts` +- Modify: `sdk/src/impl/model-provider.ts` +- Modify: `sdk/src/impl/llm.ts` +- Modify: `sdk/src/index.ts` +- Modify: `cli/src/utils/input-modes.ts` +- Modify: `cli/src/components/input-mode-banner.tsx` +- Modify: `cli/src/data/slash-commands.ts` +- Modify: `cli/src/commands/command-registry.ts` +- Modify: `cli/src/commands/router.ts` +- Modify: `cli/src/chat.tsx` +- Modify: `cli/src/components/usage-banner.tsx` +- Modify: `cli/src/components/bottom-status-line.tsx` +- Modify: `cli/src/init/init-app.ts` +- Modify tests in SDK/CLI for new behavior. + +## Out of Scope +1. Device-code auth flow. +2. Legal/policy guarantees around undocumented endpoints. +3. Full quota/usage API integration for ChatGPT subscription plans. +4. Local callback server daemon beyond paste-based flow. +5. Enabling feature by default. diff --git a/.agents/sessions/03-03-0909-add-console-log/LESSONS.md b/.agents/sessions/03-03-0909-add-console-log/LESSONS.md new file mode 100644 index 0000000000..271cfead5b --- /dev/null +++ b/.agents/sessions/03-03-0909-add-console-log/LESSONS.md @@ -0,0 +1,15 @@ +# LESSONS + +## What went well +- `git diff -- cli/src/index.tsx` immediately after editing made it easy to enforce exact scope for a one-line change. +- Validating with `bun run cli/src/index.tsx --help` gave a quick, non-effectful end-to-end check that startup output works. + +## What was tricky +- Bun script invocation shape from repo root was easy to misremember: `bun --cwd cli run typecheck` failed, while `bun run --cwd cli typecheck` succeeded. + +## Useful patterns +- Entrypoint logs placed at the top of `main()` apply to all command paths that enter `main()`; verify with a non-interactive path first. +- For tiny requests, combine: (1) minimal code edit, (2) scoped diff check, (3) one runtime smoke check, (4) one typecheck. + +## Future efficiency notes +- Put exact validation commands directly in `PLAN.md` to avoid command-syntax backtracking during validation. diff --git a/.agents/sessions/03-03-0909-add-console-log/PLAN.md b/.agents/sessions/03-03-0909-add-console-log/PLAN.md new file mode 100644 index 0000000000..5b27b95678 --- /dev/null +++ b/.agents/sessions/03-03-0909-add-console-log/PLAN.md @@ -0,0 +1,16 @@ +# PLAN + +## Implementation Steps +1. Update `cli/src/index.tsx` by adding `console.log('Codebuff CLI starting')` as the first statement in `main()`. +2. Inspect the diff to confirm scope: exactly one new `console.log` line in `cli/src/index.tsx` and no unintended edits. +3. Run lightweight validation for CLI startup behavior: + - Run a non-interactive path (`--help`) and confirm the line appears once. + - Confirm the log sits before command branching in `main()` so it applies to all `main()` paths. + +## Dependencies / Ordering +- Step 1 must happen before Step 2 and Step 3. +- Step 2 should complete before Step 3 to ensure we validate the intended change only. + +## Risk Areas +- Low risk overall. +- Minor UX risk: the new stdout line appears for all command paths entering `main()` (including `--help`, `login`, and `publish`). This is intentional per spec. diff --git a/.agents/sessions/03-03-0909-add-console-log/SPEC.md b/.agents/sessions/03-03-0909-add-console-log/SPEC.md new file mode 100644 index 0000000000..4b69f71768 --- /dev/null +++ b/.agents/sessions/03-03-0909-add-console-log/SPEC.md @@ -0,0 +1,25 @@ +# SPEC + +## Overview +Add a single startup `console.log` to the CLI entrypoint so there is explicit stdout output when the CLI boots. + +## Requirements +1. Modify `cli/src/index.tsx` only for functional code changes. +2. Add exactly one `console.log(...)` statement. +3. Place the log at the start of `main()`. +4. Use a static message string (no timestamp or dynamic args). Chosen message: `Codebuff CLI starting`. +5. The log should print for any execution path that enters `main()` (including normal startup and command modes like `login`/`publish`). +6. Keep all existing behavior unchanged aside from the added stdout line. + +## Technical Approach +Insert one `console.log('Codebuff CLI starting')` call as the first statement inside `main()` so it prints once per process run before the rest of startup flow proceeds. + +## Files to Create/Modify +- `cli/src/index.tsx` (modify) +- `.agents/sessions/03-03-0909-add-console-log/SPEC.md` (this spec) + +## Out of Scope +- Replacing existing logger usage with `console.log` +- Adding additional logs +- Refactoring startup flow or command handling +- Any server/web/API changes diff --git a/.agents/sessions/03-06-0850-cli-tester-efficiency/LESSONS.md b/.agents/sessions/03-06-0850-cli-tester-efficiency/LESSONS.md new file mode 100644 index 0000000000..b2eacf94dd --- /dev/null +++ b/.agents/sessions/03-06-0850-cli-tester-efficiency/LESSONS.md @@ -0,0 +1,73 @@ +# Lessons: CLI tester efficiency and CLI knowledge improvements + +## What went well + +- The SDK-driven harness made it straightforward to collect full event streams, stream chunks, structured outputs, and tmux capture paths for repeated `codebuff-local-cli` runs. +- The baseline runs clearly exposed behavior patterns instead of relying on intuition. +- The Codebuff CLI itself was capable and informative during implementation-oriented runs; most inefficiency came from the tester agent’s workflow rather than the CLI under test. + +## What was tricky + +- The `codebuff-local-cli` agent uses only `run_terminal_command`, `add_message`, and `set_output`, so all tester intelligence has to come from prompt/instruction quality rather than richer tooling. +- Long Codebuff CLI responses live in a scrollable viewport. The tester spent many extra steps trying to recover hidden content even when the visible portion already contained enough evidence. +- One smoke run silently started a second tmux session mid-run, showing that the current guidance was too weak about preserving session continuity and treating failure recovery explicitly. +- Reading tmux capture artifacts from inside the tester run is ineffective because the agent does not have `read_files`; attempts to recover more evidence should therefore be avoided unless the current viewport is truly insufficient. + +## Quantified before/after findings + +### Smoke scenario + +- Baseline smoke runs: `27` and `38` total events, with one run silently starting a replacement tmux session mid-run. +- Post-change smoke run: `27` total events, `10` tool calls, `3` captures, no replacement session, and clearer capture labels (`initial-state`, `after-help`, `after-2plus2`). + +### Implementation scenario + +- Baseline implementation runs: + - tool calls: `19` and `21` + - captures: `8` and `7` + - total cost: `30` and `40` + - strong evidence of wasted viewport-recovery actions (page up/down, history keys, extra captures, direct tmux scrollback commands) +- Post-change implementation run: + - tool calls: `10` + - captures: `4` + - total cost: `14` + - no viewport-recovery thrashing; the tester captured the ready state, in-progress state, response, and follow-up response and then stopped. + +## Baseline findings + +- Smoke runs were mostly efficient, but their capture labels were generic and the agent did not explicitly reason about why each capture was worth taking. +- One smoke run restarted the session instead of treating the original session as canonical, inflating event/tool counts. +- Implementation runs showed the biggest inefficiency: excessive viewport recovery actions (page up/down, arrow keys, extra captures, direct tmux scrollback commands) after the key recommendation was already visible. +- The tester lacked Codebuff-specific guidance about: + - what the ready state looks like, + - when `/help` is especially valuable, + - how to structure a good implementation-oriented test, + - and when to stop chasing perfect captures of long responses. + +## What changed behavior most + +- Adding a canonical-session instruction prevented silent session replacement behavior and made failure handling expectations explicit. +- Adding the shared “high-value capture” heuristic reduced redundant captures and discouraged overlapping progress snapshots. +- Adding explicit guidance to stop chasing hidden viewport text eliminated the biggest source of waste in implementation-oriented runs. +- Adding Codebuff-specific flow guidance improved follow-up quality and reduced exploratory key usage. + +## Changes made from baseline evidence + +- Added shared operating heuristics to bias CLI testers toward fewer, higher-value captures and away from unnecessary UI mutation. +- Added explicit guidance to avoid `read_files` on tmux artifacts from inside the tester run. +- Added Codebuff-specific testing guidance covering ready state, smoke-test flow, implementation-test flow, long-response behavior, and session continuity expectations. +- Added best-effort harness cleanup when a run throws after a tmux session has already been created. + +## Cautionary note + +- Different runs may disagree about whether adjacent edge cases are worth fixing. For example, one post-change implementation run argued that the original-case `isEnvFile` call path was acceptable because `.env` files are conventionally lowercase, while earlier baseline runs framed nearby case handling as security-sensitive. Future work should settle those questions with source-of-truth tests or project policy, not by trusting a single run’s opinion. + +## Known limitation + +- The analysis harness now does best-effort tmux cleanup when a run throws after a session has already been created, but it still does not implement a hard per-run abort/timeout with guaranteed teardown if `client.run()` stalls indefinitely. Future iterations should add explicit run cancellation once the preferred timeout mechanism is settled. + +## What we intentionally did not change + +- We did not change the tmux helper scripts because the baseline problems were primarily agent-behavior issues, not script failures. +- We did not broaden the tester’s tool access; this pass focuses on making the current workflow smarter rather than increasing power. +- We did not change the shared output schema because the existing `set_output` contract was sufficient for analysis once the agent behavior improved. diff --git a/.agents/sessions/03-06-0850-cli-tester-efficiency/PLAN.md b/.agents/sessions/03-06-0850-cli-tester-efficiency/PLAN.md new file mode 100644 index 0000000000..13c4cb61e5 --- /dev/null +++ b/.agents/sessions/03-06-0850-cli-tester-efficiency/PLAN.md @@ -0,0 +1,57 @@ +# Plan: CLI tester efficiency and CLI knowledge improvements + +## Implementation Steps + +1. Build an SDK-driven analysis harness for the CLI tester runs. + - Add a reproducible script or test helper that runs `codebuff-local-cli` through the SDK with `handleEvent` and `handleStreamChunk` collection. + - Standardize artifact naming for comparison (for example `baseline-smoke-run1`, `baseline-implementation-run2`, `post-smoke-run1`). + - Define and persist a consistent metrics schema per run, including event counts by type, tool-call counts, unique tool names, spawned-agent counts, capture counts, and notable wait/capture observations. + - Build in explicit failure-path handling for missing API key, auth failure, tmux startup failure, and hung runs, including cleanup where possible. + +2. Execute baseline mixed-scenario runs and document findings. + - Run the smoke scenario twice and the implementation scenario twice. + - Keep the comparison controlled by using the same prompts, logging granularity, and timeout policy across baseline runs. + - Inspect each run’s SDK trace and tmux session logs. + - Record concrete inefficiencies, wasted actions, and missing Codebuff-CLI knowledge to drive the prompt/template changes. + +3. Improve the shared CLI tester prompt layer. + - Update `.agents/lib/cli-agent-prompts.ts` so CLI testers have sharper workflow guidance. + - Add targeted guidance on when to gather prep context, when to capture, how to detect progress/completion, and how to avoid low-value repeated actions. + - Keep knowledge additions evidence-based and avoid prompt bloat. + +4. Improve shared CLI tester orchestration and the concrete `codebuff-local-cli` agent. + - Update `.agents/lib/create-cli-agent.ts` if shared orchestration behavior needs refinement. + - Update `.agents/codebuff-local-cli.ts` with Codebuff-CLI-specific knowledge and workflow refinements informed by baseline evidence. + - Ensure the agent remains focused on CLI UI testing and uses the tmux helper scripts efficiently. + - Keep output contract compatibility intact. + +5. Add or update validation coverage. + - Add tests for shared CLI-agent prompt/template behavior and/or the analysis harness. + - Include compatibility-oriented checks for the shared CLI-agent layer. + - At minimum, verify the `.agents` layer still typechecks and that `claude-code-cli`, `codex-cli`, `gemini-cli`, and `codebuff-local-cli` still satisfy shared construction/schema expectations. + +6. Re-run post-change verification scenarios. + - Run at least one smoke and one implementation scenario after changes using the same prompts and comparison controls. + - Compare outputs/artifacts against the baseline. + - Treat the step as successful if the post-change runs show at least two improvement signals such as fewer duplicate captures, fewer redundant waits/follow-ups, clearer evidence in captures/output, or better scenario-specific verification behavior. + +7. Write session documentation and capture durable lessons. + - Record before/after findings in `LESSONS.md`. + - Document what was intentionally not changed and why. + - Update relevant skill files only with broadly reusable insights. + +## Dependencies / Ordering + +- Step 1 must happen before baseline analysis in Step 2. +- Step 2 should happen before Steps 3–4 so improvements are evidence-based. +- Step 3 should happen before or alongside Step 4 because shared prompt guidance informs the concrete agent behavior. +- Step 5 should follow implementation so tests validate the actual behavior. +- Step 6 depends on Steps 3–5 being complete. +- Step 7 should happen after validation so lessons reflect the final state. + +## Risk Areas + +- The requested `cli-ui-tester` name does not exist directly in the repo, so the harness must target the correct concrete agent (`codebuff-local-cli`) and shared template layer consistently. +- SDK-driven CLI runs may fail due to auth, tmux availability, or local CLI startup issues; the harness should make failures inspectable rather than opaque. +- Richer CLI knowledge can easily become prompt bloat, so additions must stay targeted to observed failures. +- Shared-layer changes can affect multiple CLI tester agents, so compatibility checks are important. diff --git a/.agents/sessions/03-06-0850-cli-tester-efficiency/SPEC.md b/.agents/sessions/03-06-0850-cli-tester-efficiency/SPEC.md new file mode 100644 index 0000000000..15c2f383c0 --- /dev/null +++ b/.agents/sessions/03-06-0850-cli-tester-efficiency/SPEC.md @@ -0,0 +1,76 @@ +# Spec: CLI tester efficiency and CLI knowledge improvements + +## Overview + +Evaluate the shared tmux-based CLI tester agent framework and the concrete `codebuff-local-cli` agent as the implementation of the requested CLI UI tester. Do this by running the tester through the Codebuff SDK multiple times with full event logging, inspecting the resulting SDK event traces and tmux session logs after each run, and then improving the agent(s) so they use fewer wasted steps, capture more useful evidence, and have stronger built-in knowledge of the Codebuff CLI under test. + +## Requirements + +1. Treat `codebuff-local-cli` plus the shared CLI-agent template/prompt layer as the concrete implementation of the requested CLI UI tester for this pass. +2. Run the relevant tester via the Codebuff SDK multiple times with per-event logging enabled. +3. Use a fixed mixed scenario set for analysis: + 1. a visual smoke-test flow for startup/help/basic prompt rendering, + 2. a realistic implementation-oriented flow. +4. Collect a minimum of: + 1. 2 baseline runs of the smoke scenario, + 2. 2 baseline runs of the implementation scenario, + 3. 1 post-change verification run for each scenario. +5. Persist analysis artifacts for each run, including: + 1. full SDK event stream, + 2. stream chunks where available, + 3. run summary metrics, + 4. tmux session capture paths / session logs. +6. Inspect logs after each run and compare baseline behavior across runs before making changes. +7. Identify inefficiencies in the current tester workflow, especially repeated or low-value captures, vague prompting, unnecessary setup, weak completion criteria, and poor completion detection. +8. For this task, treat the following as examples of “wasted actions” unless the logs justify them: + 1. duplicate captures with no meaningful UI state change, + 2. redundant waits that do not produce new evidence, + 3. follow-up prompts that restate the original task without adding precision, + 4. generic verification steps that are not well matched to the scenario, + 5. broad repo-reading instructions that do not improve the test outcome. +9. Identify missing Codebuff-CLI-specific knowledge that would help the tester drive the CLI more effectively, such as startup expectations, useful commands, verification behaviors, and signs that the CLI is done or needs follow-up. +10. Improve the shared CLI tester framework where doing so benefits multiple CLI testers. +11. Improve the `codebuff-local-cli` agent as the concrete primary target. +12. Preserve the tmux-session-based testing model and the existing structured `set_output` contract; any schema changes should be backward-compatible or additive only. +13. Keep changes focused on agent behavior, prompt quality, logging usefulness, and related validation/test coverage rather than unrelated CLI product changes. +14. Add richer CLI knowledge in a targeted way: new prompt or workflow guidance must be tied to observed baseline failures, confusion, or inefficiencies rather than generic prompt expansion. +15. Add or update validation coverage for the new behavior where practical. +16. Handle key failure modes cleanly in either the agent behavior or the analysis harness, including: + 1. missing API key / auth failure, + 2. tmux startup failure, + 3. CLI hang / no-progress situations, + 4. cleanup of temporary artifacts or tmux sessions where applicable. +17. Summarize findings, rationale, and before/after evidence in session documentation. + +## Acceptance Criteria + +1. There is a reproducible SDK-driven way to run and inspect the CLI tester with full event logging. +2. The session documentation includes concrete before/after findings from the mixed scenario runs rather than only anecdotal recommendations. +3. The shared prompt/template layer or concrete tester agent is updated to add materially better Codebuff-CLI-specific guidance. +4. The updated tester behavior reduces obvious wasted actions or improves evidence quality in a way that is visible in prompts, logs, outputs, or tests. +5. Validation demonstrates the changes did not break the CLI tester contract or nearby shared behavior, including at least one compatibility-oriented check on the shared CLI-agent layer. + +## Technical Approach + +- Use the SDK directly to run the relevant tester agent with `handleEvent` and `handleStreamChunk` collectors so every emitted event can be persisted and analyzed. +- Use the tester’s existing tmux scripts and session logs as the main source of truth for what the tested CLI actually displayed. +- Compare current shared instructions in `.agents/lib/cli-agent-prompts.ts` and agent-construction logic in `.agents/lib/create-cli-agent.ts` against the Codebuff-local tester’s concrete behavior in `.agents/codebuff-local-cli.ts` to find mismatches and missing guidance. +- Tighten prompts and workflow instructions so the tester gathers relevant repo/CLI context up front when appropriate, uses more targeted capture/verification behavior, and returns richer but backward-compatible structured output. +- Capture lightweight comparative metrics such as event counts by type, tool-call counts, spawned-agent counts, and notable capture usefulness observations. +- Add or update tests around the agent prompt/template layer and, if useful, add a reproducible SDK-driven analysis harness. + +## Files to Create/Modify + +- `.agents/codebuff-local-cli.ts` +- `.agents/lib/create-cli-agent.ts` +- `.agents/lib/cli-agent-prompts.ts` +- `.agents/lib/cli-agent-schemas.ts` (only if additive schema changes are needed) +- Possible new SDK/e2e or helper script under `sdk/e2e/` or `scripts/` +- Session docs under `.agents/sessions/03-06-0850-cli-tester-efficiency/` + +## Out of Scope + +- Reworking the underlying tmux helper scripts unless logs show a concrete blocker there. +- Broad changes to the main Codebuff CLI product unrelated to tester quality. +- Replacing the tmux-based approach with a different testing framework. +- Optimizing non-CLI-testing agents unless directly affected by shared CLI tester changes. diff --git a/.agents/skills/cleanup/SKILL.md b/.agents/skills/cleanup/SKILL.md new file mode 100644 index 0000000000..dd41e2a10f --- /dev/null +++ b/.agents/skills/cleanup/SKILL.md @@ -0,0 +1,8 @@ +--- +name: cleanup +description: Simplify and clean code +--- + +# Cleanup + +Please review the uncommitted changes (staged and unstaged) and find ways to simplify the code. Clean up logic. Find a simpler design. Reuse existing functions. Move utilities to utility files. Lower the cyclomatic complexity. Remove try/catch statements when not completely necessary. \ No newline at end of file diff --git a/.agents/skills/meta/SKILL.md b/.agents/skills/meta/SKILL.md new file mode 100644 index 0000000000..8b05efdddf --- /dev/null +++ b/.agents/skills/meta/SKILL.md @@ -0,0 +1,18 @@ +--- +name: meta +description: Broad project-level implementation and validation heuristics +--- + +# Meta + +- When validating CLI changes, run a non-effectful command path first (for example `--help`) before any command that could trigger external side effects. (from .agents/sessions/03-03-0909-add-console-log) +- For tightly scoped edits, pair runtime smoke-checks with `git diff -- ` to verify no unintended spillover. (from .agents/sessions/03-03-0909-add-console-log) +- From monorepo root, run workspace scripts as `bun run --cwd '), + createTestItem('special-2', 'Item & More'), + ] + + const markup = renderToStaticMarkup( + , + ) + + // React escapes HTML entities + expect(markup).toContain('<script>') + expect(markup).toContain('&') + }) + + test('handles undefined footer gracefully', () => { + const items = [createTestItem('item-1', 'Item')] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Item') + }) + }) + + describe('memoization', () => { + test('component is memoized', () => { + // MasonryGrid is wrapped in memo(), verify it renders consistently + const items = [createTestItem('memo-test', 'Memoized')] + + const markup1 = renderToStaticMarkup( + , + ) + + const markup2 = renderToStaticMarkup( + , + ) + + expect(markup1).toBe(markup2) + }) + }) +}) diff --git a/cli/src/components/__tests__/message-block.completion.test.tsx b/cli/src/components/__tests__/message-block.completion.test.tsx index 18d8a10797..d255fe7065 100644 --- a/cli/src/components/__tests__/message-block.completion.test.tsx +++ b/cli/src/components/__tests__/message-block.completion.test.tsx @@ -46,6 +46,7 @@ const baseProps = { onToggleCollapsed: () => {}, onBuildFast: () => {}, onBuildMax: () => {}, + onBuildLite: () => {}, setCollapsedAgents: () => {}, addAutoCollapsedAgent: () => {}, } diff --git a/cli/src/components/__tests__/message-block.streaming.test.tsx b/cli/src/components/__tests__/message-block.streaming.test.tsx index 1f054fc8b5..86bcb540e1 100644 --- a/cli/src/components/__tests__/message-block.streaming.test.tsx +++ b/cli/src/components/__tests__/message-block.streaming.test.tsx @@ -42,6 +42,7 @@ const baseProps = { onToggleCollapsed: () => {}, onBuildFast: () => {}, onBuildMax: () => {}, + onBuildLite: () => {}, setCollapsedAgents: () => {}, addAutoCollapsedAgent: () => {}, } diff --git a/cli/src/components/__tests__/message-with-agents.test.tsx b/cli/src/components/__tests__/message-with-agents.test.tsx new file mode 100644 index 0000000000..ba7a67cb04 --- /dev/null +++ b/cli/src/components/__tests__/message-with-agents.test.tsx @@ -0,0 +1,569 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { useChatStore } from '../../state/chat-store' +import { useMessageBlockStore } from '../../state/message-block-store' +import { chatThemes, createMarkdownPalette } from '../../utils/theme-system' +import { MessageWithAgents } from '../message-with-agents' + +import type { ChatMessage } from '../../types/chat' +import type { MarkdownPalette } from '../../utils/markdown-renderer' + +initializeThemeStore() + +const theme = chatThemes.light +const basePalette: MarkdownPalette = createMarkdownPalette(theme) + +// ----------------------------------------------------------------------------- +// Helper factory functions for creating test messages +// ----------------------------------------------------------------------------- + +const createUserMessage = (id: string, content: string): ChatMessage => ({ + id, + variant: 'user', + content, + timestamp: new Date().toISOString(), +}) + +const createAiMessage = (id: string, content: string): ChatMessage => ({ + id, + variant: 'ai', + content, + timestamp: new Date().toISOString(), +}) + +const createAgentMessage = ( + id: string, + content: string, + agentName: string, + options: Partial = {}, +): ChatMessage => ({ + id, + variant: 'agent', + content, + timestamp: new Date().toISOString(), + agent: { + agentName, + agentType: 'test-agent', + responseCount: 1, + }, + ...options, +}) + +const createErrorMessage = (id: string, content: string): ChatMessage => ({ + id, + variant: 'error', + content, + timestamp: new Date().toISOString(), +}) + +// Creates an agent message without the required agent info (for error testing) +const createMalformedAgentMessage = (id: string, content: string): ChatMessage => ({ + id, + variant: 'agent', + content, + timestamp: new Date().toISOString(), + // Intentionally missing agent property +} as ChatMessage) + +const createModeDividerMessage = (id: string, mode: string): ChatMessage => ({ + id, + variant: 'ai', + content: 'this content should be ignored', + timestamp: new Date().toISOString(), + blocks: [ + { + type: 'mode-divider', + mode, + }, + ], +}) + +const defaultCallbacks = { + onToggleCollapsed: () => {}, + onBuildFast: () => {}, + onBuildMax: () => {}, + onBuildLite: () => {}, + onFeedback: () => {}, + onCloseFeedback: () => {}, +} + +const initializeStore = (overrides: { + messageTree?: Map + isWaitingForResponse?: boolean + timerStartTime?: number | null + availableWidth?: number +} = {}) => { + useMessageBlockStore.setState({ + context: { + theme, + markdownPalette: basePalette, + messageTree: overrides.messageTree ?? new Map(), + isWaitingForResponse: overrides.isWaitingForResponse ?? false, + timerStartTime: overrides.timerStartTime ?? null, + availableWidth: overrides.availableWidth ?? 80, + }, + callbacks: defaultCallbacks, + }) +} + +beforeEach(() => { + initializeStore() + useChatStore.setState({ streamingAgents: new Set() }) +}) + +afterEach(() => { + useMessageBlockStore.getState().reset() + useChatStore.setState({ streamingAgents: new Set() }) +}) + +const baseMessageWithAgentsProps = { + depth: 0, + isLastMessage: false, + availableWidth: 80, +} + +// ============================================================================= +// MessageBlockStore Tests - store behavior, not JS built-ins +// ============================================================================= + +describe('MessageBlockStore', () => { + describe('setContext', () => { + test('performs partial merge, preserving unspecified values', () => { + // Set initial state with specific values + initializeStore({ + isWaitingForResponse: true, + timerStartTime: 12345, + availableWidth: 100, + }) + + // Update only one value + useMessageBlockStore.getState().setContext({ + isWaitingForResponse: false, + }) + + const state = useMessageBlockStore.getState() + // Updated value should change + expect(state.context.isWaitingForResponse).toBe(false) + // Other values should be preserved + expect(state.context.timerStartTime).toBe(12345) + expect(state.context.availableWidth).toBe(100) + expect(state.context.theme).toBe(theme) + }) + + test('updates messageTree without affecting other context values', () => { + const child1 = createAgentMessage('child-1', 'Content 1', 'Agent One') + const child2 = createAgentMessage('child-2', 'Content 2', 'Agent Two') + const newTree = new Map([ + ['parent-1', [child1, child2]], + ]) + + useMessageBlockStore.getState().setContext({ + messageTree: newTree, + }) + + const state = useMessageBlockStore.getState() + expect(state.context.messageTree).toBe(newTree) + expect(state.context.messageTree?.get('parent-1')).toHaveLength(2) + // Theme should be unchanged + expect(state.context.theme).toBe(theme) + }) + + test('can update multiple context values at once', () => { + useMessageBlockStore.getState().setContext({ + isWaitingForResponse: true, + timerStartTime: 99999, + availableWidth: 200, + }) + + const state = useMessageBlockStore.getState() + expect(state.context.isWaitingForResponse).toBe(true) + expect(state.context.timerStartTime).toBe(99999) + expect(state.context.availableWidth).toBe(200) + }) + }) + + describe('setCallbacks', () => { + test('replaces entire callbacks object', () => { + const mockToggle = () => {} + const mockBuildFast = () => {} + const mockBuildMax = () => {} + const mockBuildFree = () => {} + const mockFeedback = () => {} + const mockCloseFeedback = () => {} + + useMessageBlockStore.getState().setCallbacks({ + onToggleCollapsed: mockToggle, + onBuildFast: mockBuildFast, + onBuildMax: mockBuildMax, + onBuildLite: mockBuildFree, + onFeedback: mockFeedback, + onCloseFeedback: mockCloseFeedback, + }) + + const state = useMessageBlockStore.getState() + expect(state.callbacks.onToggleCollapsed).toBe(mockToggle) + expect(state.callbacks.onBuildFast).toBe(mockBuildFast) + expect(state.callbacks.onBuildMax).toBe(mockBuildMax) + expect(state.callbacks.onBuildLite).toBe(mockBuildFree) + expect(state.callbacks.onFeedback).toBe(mockFeedback) + expect(state.callbacks.onCloseFeedback).toBe(mockCloseFeedback) + }) + + test('callbacks are independent from context', () => { + const originalTheme = useMessageBlockStore.getState().context.theme + + useMessageBlockStore.getState().setCallbacks({ + ...defaultCallbacks, + onToggleCollapsed: () => console.log('new toggle'), + }) + + // Context should be unchanged + expect(useMessageBlockStore.getState().context.theme).toBe(originalTheme) + }) + }) + + describe('reset', () => { + test('restores context to initial state', () => { + // Modify state significantly + useMessageBlockStore.getState().setContext({ + isWaitingForResponse: true, + timerStartTime: 12345, + availableWidth: 200, + messageTree: new Map([['key', [createAgentMessage('a', 'b', 'c')]]]), + }) + + useMessageBlockStore.getState().reset() + + const state = useMessageBlockStore.getState() + expect(state.context.theme).toBeNull() + expect(state.context.isWaitingForResponse).toBe(false) + expect(state.context.timerStartTime).toBeNull() + expect(state.context.availableWidth).toBe(80) + }) + + test('restores callbacks to noop functions', () => { + const mockFn = () => console.log('test') + useMessageBlockStore.getState().setCallbacks({ + onToggleCollapsed: mockFn, + onBuildFast: mockFn, + onBuildMax: mockFn, + onBuildLite: mockFn, + onFeedback: mockFn, + onCloseFeedback: mockFn, + }) + + useMessageBlockStore.getState().reset() + + const state = useMessageBlockStore.getState() + // Callbacks should be noop functions (not undefined) + expect(typeof state.callbacks.onToggleCollapsed).toBe('function') + expect(typeof state.callbacks.onBuildFast).toBe('function') + expect(typeof state.callbacks.onBuildLite).toBe('function') + // They should not throw when called + expect(() => state.callbacks.onToggleCollapsed('test-id')).not.toThrow() + }) + }) +}) + +// ============================================================================= +// MessageWithAgents Component Tests - behavior across variants +// ============================================================================= + +describe('MessageWithAgents', () => { + describe('message variant rendering', () => { + test('renders user message content', () => { + const message = createUserMessage('user-1', 'Hello from user') + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Hello from user') + }) + + test('renders AI message content', () => { + const message = createAiMessage('ai-1', 'Hello from AI') + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Hello from AI') + }) + + test('renders error message content', () => { + const message = createErrorMessage('error-1', 'An error occurred') + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('An error occurred') + }) + + test('renders agent message with agent name displayed', () => { + const message = createAgentMessage('agent-1', 'Agent response', 'Code Searcher') + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Code Searcher') + expect(markup).toContain('Agent response') + }) + + test('handles message with markdown content', () => { + const message = createAiMessage('ai-md', '**Bold** and *italic*') + + const markup = renderToStaticMarkup( + , + ) + + // Content should be present (markdown rendering may transform it) + expect(markup).toContain('Bold') + expect(markup).toContain('italic') + }) + + test('handles empty content without crashing', () => { + const message = createAiMessage('ai-empty', '') + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toBeDefined() + }) + }) + + describe('mode divider block rendering', () => { + test('renders ModeDivider when message contains only a mode-divider block and ignores content', () => { + const message = createModeDividerMessage('mode-1', 'Edit Mode') + + const markup = renderToStaticMarkup( + , + ) + + // Mode text should appear + expect(markup).toContain('Edit Mode') + // Original message content should not be rendered + expect(markup).not.toContain('this content should be ignored') + }) + }) + + describe('error handling', () => { + test('shows error message when agent message is missing agent info', () => { + const malformedMessage = createMalformedAgentMessage( + 'bad-agent', + 'This should fail', + ) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain('Missing agent info') + }) + }) + + describe('collapsed vs expanded agent state', () => { + test('renders collapsed agent with preview and collapsed indicator', () => { + const collapsedMessage = createAgentMessage( + 'collapsed-agent', + 'This is the full content\nwith multiple lines\nand the last line is shown', + 'Collapsed Agent', + { + metadata: { isCollapsed: true }, + }, + ) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Collapsed Agent') + // When collapsed, should show the collapsed indicator + expect(markup).toContain('▸') + // Preview should be the last line + expect(markup).toContain('and the last line is shown') + // First line of full content should not be present as a full block + expect(markup).not.toContain('This is the full content') + }) + + test('renders expanded agent with full content and expanded indicator', () => { + const expandedMessage = createAgentMessage( + 'expanded-agent', + 'Full expanded content here', + 'Expanded Agent', + { + metadata: { isCollapsed: false }, + }, + ) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Expanded Agent') + expect(markup).toContain('Full expanded content here') + // When expanded, should show the expanded indicator + expect(markup).toContain('▾') + }) + }) +}) + +// ============================================================================= +// Callback Integration Tests +// ============================================================================= + +describe('callback invocation', () => { + test('callbacks are retrievable from store and callable', () => { + let toggleCalledWith: string | undefined + const mockToggle = (id: string) => { + toggleCalledWith = id + } + + useMessageBlockStore.getState().setCallbacks({ + ...defaultCallbacks, + onToggleCollapsed: mockToggle, + }) + + // Verify callback is stored and retrievable + const storedCallback = useMessageBlockStore.getState().callbacks + .onToggleCollapsed + storedCallback('test-message-id') + + expect(toggleCalledWith).toBe('test-message-id') + }) + + test('onFeedback callback receives messageId and options', () => { + let feedbackMessageId: string | undefined + let feedbackOptions: object | undefined + const mockFeedback = (messageId: string, options?: object) => { + feedbackMessageId = messageId + feedbackOptions = options + } + + useMessageBlockStore.getState().setCallbacks({ + ...defaultCallbacks, + onFeedback: mockFeedback, + }) + + const storedCallback = useMessageBlockStore.getState().callbacks.onFeedback + storedCallback('msg-123', { category: 'app_bug' }) + + expect(feedbackMessageId).toBe('msg-123') + expect(feedbackOptions).toEqual({ category: 'app_bug' }) + }) +}) + +// ============================================================================= +// Layout and visual structure tests +// ============================================================================= + +describe('layout handling', () => { + test('renders correctly across different terminal widths', () => { + const widths = [20, 80, 120, 300] + + for (const width of widths) { + const message = createAiMessage(`width-${width}`, `Content at width ${width}`) + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain(`Content at width ${width}`) + } + }) + + test('renders correctly with isLastMessage true and false', () => { + const message = createAiMessage('last-msg-test', 'Test content') + + const lastMarkup = renderToStaticMarkup( + , + ) + + const notLastMarkup = renderToStaticMarkup( + , + ) + + expect(lastMarkup).toContain('Test content') + expect(notLastMarkup).toContain('Test content') + }) +}) + +describe('vertical line for user messages', () => { + test('renders vertical line box for user messages only', () => { + const userMessage = createUserMessage('user-line', 'User content') + const aiMessage = createAiMessage('ai-no-line', 'AI content') + + const userMarkup = renderToStaticMarkup( + , + ) + + const aiMarkup = renderToStaticMarkup( + , + ) + + // Vertical line uses style={{ width: 1, backgroundColor: lineColor }} + // which becomes width:1px in the style string. + expect(userMarkup).toContain('width:1px') + expect(aiMarkup).not.toContain('width:1px') + }) +}) diff --git a/cli/src/components/__tests__/user-error-banner.test.tsx b/cli/src/components/__tests__/user-error-banner.test.tsx new file mode 100644 index 0000000000..87cf1f9b21 --- /dev/null +++ b/cli/src/components/__tests__/user-error-banner.test.tsx @@ -0,0 +1,102 @@ +import { describe, test, expect } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { UserErrorBanner } from '../user-error-banner' + +initializeThemeStore() + +describe('UserErrorBanner', () => { + test('renders error message', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain('Something went wrong') + }) + + test('renders with context length exceeded error', () => { + const errorMessage = + "This endpoint's maximum context length is 200000 tokens. However, you requested about 201209 tokens." + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain('200000 tokens') + expect(markup).toContain('201209 tokens') + }) + + test('renders with network error', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain('Network request failed') + expect(markup).toContain('Connection refused') + }) + + test('returns null for empty error message', () => { + const markup = renderToStaticMarkup() + + // Empty error should render nothing + expect(markup).toBe('') + }) + + test('returns null for whitespace-only error message', () => { + const markup = renderToStaticMarkup() + + // Whitespace-only error should render nothing + expect(markup).toBe('') + }) + + test('renders with multiline error message', () => { + const multilineError = 'First line of error\nSecond line of error' + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain('First line of error') + expect(markup).toContain('Second line of error') + }) + + test('renders with special characters in error message', () => { + const specialCharsError = 'Error with tags & "quotes"' + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + // HTML entities should be escaped in the markup + expect(markup).toContain('<html>') + expect(markup).toContain('&') + expect(markup).toContain('"quotes"') + }) + + test('renders with long error message', () => { + const longError = 'A'.repeat(500) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain(longError) + }) + + test('renders with custom title', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Network Error') + expect(markup).toContain('Something went wrong') + }) +}) diff --git a/cli/src/components/ad-banner.tsx b/cli/src/components/ad-banner.tsx deleted file mode 100644 index ba85faf2e8..0000000000 --- a/cli/src/components/ad-banner.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import open from 'open' -import React, { useCallback, useEffect, useState } from 'react' - -import { Button } from './button' -import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' -import { useTheme } from '../hooks/use-theme' -import { logger } from '../utils/logger' - -import type { AdResponse } from '../hooks/use-gravity-ad' - -interface AdBannerProps { - ad: AdResponse -} - -const extractDomain = (url: string): string => { - try { - const parsed = new URL(url) - return parsed.hostname.replace(/^www\./, '') - } catch { - return url - } -} - -export const AdBanner: React.FC = ({ ad }) => { - const theme = useTheme() - const { separatorWidth, terminalWidth } = useTerminalDimensions() - const [isLinkHovered, setIsLinkHovered] = useState(false) - - const handleClick = useCallback(() => { - if (ad.clickUrl) { - open(ad.clickUrl).catch((err) => { - logger.error(err, 'Failed to open ad link') - }) - } - }, [ad.clickUrl]) - - // Use 'url' field for display domain (the actual destination) - const domain = extractDomain(ad.url) - // Use cta field for button text, with title as fallback - const ctaText = ad.cta || ad.title || 'Learn more' - - // Calculate available width for ad text - // Account for: padding (2), "Ad" label with space (3) - const maxTextWidth = separatorWidth - 5 - - return ( - - {/* Horizontal divider line */} - {'─'.repeat(terminalWidth)} - {/* Top line: ad text + Ad label */} - - - {ad.adText} - - Ad - - {/* Bottom line: button, domain, credits */} - - {ctaText && ( - - )} - {domain && {domain}} - - {ad.credits != null && ad.credits > 0 && ( - +{ad.credits} credits - )} - - - ) -} diff --git a/cli/src/components/agent-checklist.tsx b/cli/src/components/agent-checklist.tsx index cff16e7534..4ecab8f270 100644 --- a/cli/src/components/agent-checklist.tsx +++ b/cli/src/components/agent-checklist.tsx @@ -1,7 +1,7 @@ +import { pluralize } from '@codebuff/common/util/string' import { TextAttributes } from '@opentui/core' import React, { useMemo, useRef, useEffect, useState } from 'react' -import { pluralize } from '@codebuff/common/util/string' import { Button } from './button' import { useTheme } from '../hooks/use-theme' diff --git a/cli/src/components/agent-mode-toggle.tsx b/cli/src/components/agent-mode-toggle.tsx index 6070a57f30..a75c4f56fd 100644 --- a/cli/src/components/agent-mode-toggle.tsx +++ b/cli/src/components/agent-mode-toggle.tsx @@ -4,7 +4,7 @@ import { Button } from './button' import { SegmentedControl } from './segmented-control' import { useTheme } from '../hooks/use-theme' import { useChatStore } from '../state/chat-store' -import { AGENT_MODES } from '../utils/constants' +import { AGENT_MODES, IS_FREEBUFF } from '../utils/constants' import { BORDER_CHARS } from '../utils/ui-constants' import type { Segment } from './segmented-control' @@ -156,6 +156,8 @@ export const AgentModeToggle = ({ onToggle: () => void onSelectMode?: (mode: AgentMode) => void }) => { + if (IS_FREEBUFF) return null + const theme = useTheme() const inputFocused = useChatStore((state) => state.inputFocused) const [isCollapsedHovered, setIsCollapsedHovered] = useState(false) diff --git a/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts b/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts index fced9c0cd7..f275c1ab44 100644 --- a/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts +++ b/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from 'bun:test' -import { getOptionLabel, OTHER_OPTION_INDEX } from '../constants' +import { getOptionLabel, CUSTOM_OPTION_INDEX } from '../constants' import type { AccordionAnswer } from '../components/accordion-question' import type { AskUserOption } from '../constants' @@ -40,8 +40,8 @@ function formatAnswer( : [] const customText = - answer.isOther && (answer.otherText?.trim().length ?? 0) > 0 - ? (answer.otherText ?? '').trim() + answer.isCustom && (answer.customText?.trim().length ?? 0) > 0 + ? (answer.customText ?? '').trim() : '' const parts = customText ? [...selectedOptions, customText] : selectedOptions @@ -132,10 +132,10 @@ describe('formatAnswer', () => { }) }) - it('returns custom text when isOther is true', () => { + it('returns custom text when isCustom is true', () => { const answer: AccordionAnswer = { - isOther: true, - otherText: 'Purple', + isCustom: true, + customText: 'Purple', } const result = formatAnswer(singleSelectQuestion, answer) expect(result).toEqual({ @@ -146,8 +146,8 @@ describe('formatAnswer', () => { it('trims whitespace from custom text', () => { const answer: AccordionAnswer = { - isOther: true, - otherText: ' Purple ', + isCustom: true, + customText: ' Purple ', } const result = formatAnswer(singleSelectQuestion, answer) expect(result).toEqual({ @@ -156,10 +156,10 @@ describe('formatAnswer', () => { }) }) - it('returns Skipped when isOther is true but text is empty', () => { + it('returns Skipped when isCustom is true but text is empty', () => { const answer: AccordionAnswer = { - isOther: true, - otherText: '', + isCustom: true, + customText: '', } const result = formatAnswer(singleSelectQuestion, answer) expect(result).toEqual({ @@ -168,10 +168,10 @@ describe('formatAnswer', () => { }) }) - it('returns Skipped when isOther is true but text is only whitespace', () => { + it('returns Skipped when isCustom is true but text is only whitespace', () => { const answer: AccordionAnswer = { - isOther: true, - otherText: ' ', + isCustom: true, + customText: ' ', } const result = formatAnswer(singleSelectQuestion, answer) expect(result).toEqual({ @@ -221,8 +221,8 @@ describe('formatAnswer', () => { it('includes custom text with selections', () => { const answer: AccordionAnswer = { selectedIndices: new Set([0]), - isOther: true, - otherText: 'Cooking', + isCustom: true, + customText: 'Cooking', } const result = formatAnswer(multiSelectQuestion, answer) expect(result).toEqual({ @@ -234,8 +234,8 @@ describe('formatAnswer', () => { it('returns only custom text when no other selections', () => { const answer: AccordionAnswer = { selectedIndices: new Set(), - isOther: true, - otherText: 'Cooking', + isCustom: true, + customText: 'Cooking', } const result = formatAnswer(multiSelectQuestion, answer) expect(result).toEqual({ @@ -266,67 +266,67 @@ describe('formatAnswer', () => { }) }) -describe('OTHER_OPTION_INDEX constant', () => { - it('is -1 for identifying custom/other option', () => { - expect(OTHER_OPTION_INDEX).toBe(-1) +describe('CUSTOM_OPTION_INDEX constant', () => { + it('is -1 for identifying custom option', () => { + expect(CUSTOM_OPTION_INDEX).toBe(-1) }) it('is distinct from valid option indices', () => { - expect(OTHER_OPTION_INDEX).toBeLessThan(0) + expect(CUSTOM_OPTION_INDEX).toBeLessThan(0) }) }) describe('answer state management patterns', () => { describe('single-select behavior', () => { - it('selecting an option clears isOther flag', () => { + it('selecting an option clears isCustom flag', () => { const previousAnswer: AccordionAnswer = { - isOther: true, - otherText: 'Custom text', + isCustom: true, + customText: 'Custom text', } const optionIndex: number = 1 - const isOtherOption = optionIndex === OTHER_OPTION_INDEX + const isCustomOption = optionIndex === CUSTOM_OPTION_INDEX - const newAnswer: AccordionAnswer = isOtherOption + const newAnswer: AccordionAnswer = isCustomOption ? { selectedIndex: undefined, selectedIndices: undefined, - isOther: true, - otherText: previousAnswer.otherText || '', + isCustom: true, + customText: previousAnswer.customText || '', } : { selectedIndex: optionIndex, selectedIndices: undefined, - isOther: false, + isCustom: false, } expect(newAnswer.selectedIndex).toBe(1) - expect(newAnswer.isOther).toBe(false) + expect(newAnswer.isCustom).toBe(false) }) - it('selecting OTHER clears selectedIndex and enables isOther', () => { + it('selecting CUSTOM clears selectedIndex and enables isCustom', () => { const previousAnswer: AccordionAnswer = { selectedIndex: 1, } - const optionIndex = OTHER_OPTION_INDEX - const isOtherOption = optionIndex === OTHER_OPTION_INDEX + const optionIndex = CUSTOM_OPTION_INDEX + const isCustomOption = optionIndex === CUSTOM_OPTION_INDEX - const newAnswer: AccordionAnswer = isOtherOption + const newAnswer: AccordionAnswer = isCustomOption ? { selectedIndex: undefined, selectedIndices: undefined, - isOther: true, - otherText: previousAnswer.otherText || '', + isCustom: true, + customText: previousAnswer.customText || '', } : { selectedIndex: optionIndex, selectedIndices: undefined, - isOther: false, + isCustom: false, } expect(newAnswer.selectedIndex).toBeUndefined() - expect(newAnswer.isOther).toBe(true) + expect(newAnswer.isCustom).toBe(true) }) }) @@ -368,17 +368,17 @@ describe('answer state management patterns', () => { expect(newIndices.size).toBe(2) }) - it('toggling OTHER toggles isOther flag', () => { + it('toggling CUSTOM toggles isCustom flag', () => { const currentAnswer: AccordionAnswer = { selectedIndices: new Set([0]), - isOther: false, + isCustom: false, } - const optionIndex = OTHER_OPTION_INDEX - const toggledOtherOn = - optionIndex === OTHER_OPTION_INDEX && !currentAnswer.isOther + const optionIndex = CUSTOM_OPTION_INDEX + const toggledCustomOn = + optionIndex === CUSTOM_OPTION_INDEX && !currentAnswer.isCustom - expect(toggledOtherOn).toBe(true) + expect(toggledCustomOn).toBe(true) }) }) }) diff --git a/cli/src/components/ask-user/components/accordion-question.tsx b/cli/src/components/ask-user/components/accordion-question.tsx index 6172f47cb9..132c594b7c 100644 --- a/cli/src/components/ask-user/components/accordion-question.tsx +++ b/cli/src/components/ask-user/components/accordion-question.tsx @@ -2,23 +2,21 @@ * Accordion-style question component that can expand/collapse */ -import { TextAttributes } from '@opentui/core' -import React from 'react' +import React, { useCallback } from 'react' -import { QuestionOption } from './question-option' -import { useTheme } from '../../../hooks/use-theme' -import { Button } from '../../button' -import { MultilineInput } from '../../multiline-input' -import { getOptionLabel, OTHER_OPTION_INDEX, SYMBOLS } from '../constants' +import { CustomAnswerInput } from './custom-answer-input' +import { OptionsList } from './options-list' +import { QuestionHeader } from './question-header' +import { getOptionLabel } from '../constants' -import type { AskUserQuestion } from '../../../state/chat-store' +import type { AskUserQuestion } from '../../../types/store' /** Answer state for a single question */ export interface AccordionAnswer { selectedIndex?: number selectedIndices?: Set - isOther?: boolean - otherText?: string + isCustom?: boolean + customText?: string } export interface AccordionQuestionProps { @@ -27,13 +25,13 @@ export interface AccordionQuestionProps { totalQuestions: number answer: AccordionAnswer | undefined isExpanded: boolean - isTypingOther: boolean + isTypingCustom: boolean onToggleExpand: () => void onSelectOption: (optionIndex: number) => void onToggleOption: (optionIndex: number) => void - onSetOtherText: (text: string, cursorPosition: number) => void - onOtherSubmit: () => void - otherCursorPosition: number + onSetCustomText: (text: string, cursorPosition: number) => void + onCustomSubmit: () => void + customCursorPosition: number focusedOptionIndex: number | null onFocusOption: (index: number | null) => void } @@ -44,17 +42,16 @@ export const AccordionQuestion: React.FC = ({ totalQuestions, answer, isExpanded, - isTypingOther, + isTypingCustom, onToggleExpand, onSelectOption, onToggleOption, - onSetOtherText, - onOtherSubmit, - otherCursorPosition, + onSetCustomText, + onCustomSubmit, + customCursorPosition, focusedOptionIndex, onFocusOption, }) => { - const theme = useTheme() const isMultiSelect = question.multiSelect const showQuestionNumber = totalQuestions > 1 const questionNumber = questionIndex + 1 @@ -64,16 +61,20 @@ export const AccordionQuestion: React.FC = ({ // Check if question has a valid answer const isAnswered = !!answer && - ((answer.isOther && !!answer.otherText?.trim()) || + ((answer.isCustom && !!answer.customText?.trim()) || (isMultiSelect && (answer.selectedIndices?.size ?? 0) > 0) || answer.selectedIndex !== undefined) - // Get display text for the current answer const getAnswerDisplay = (): string => { if (!answer) return '(click to answer)' - if (answer.isOther && answer.otherText) { - return `Custom: ${answer.otherText}` + if (answer.isCustom && answer.customText) { + const hadNewlines = /\r?\n/.test(answer.customText) + const flattenedText = answer.customText + .replace(/\r?\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + return `Custom: ${flattenedText}${hadNewlines ? '…' : ''}` } if (isMultiSelect && answer.selectedIndices) { @@ -93,149 +94,57 @@ export const AccordionQuestion: React.FC = ({ return '(click to answer)' } - const handleOptionSelect = (optionIndex: number) => { - if (isMultiSelect) { - onToggleOption(optionIndex) - } else { - onSelectOption(optionIndex) - } - } - - const isCustomSelected = answer?.isOther ?? false - const isCustomFocused = focusedOptionIndex === question.options.length || isTypingOther - const selectedFg = theme.name === 'dark' ? '#ffffff' : '#000000' - const customSymbol = isMultiSelect - ? isCustomSelected ? SYMBOLS.CHECKBOX_CHECKED : SYMBOLS.CHECKBOX_UNCHECKED - : isCustomSelected ? SYMBOLS.SELECTED : SYMBOLS.UNSELECTED - const customFg = isCustomFocused ? '#000000' : isCustomSelected ? selectedFg : theme.muted - const customAttributes = isCustomFocused || isCustomSelected ? TextAttributes.BOLD : undefined + const isCustomSelected = answer?.isCustom ?? false + + const handlePaste = useCallback( + (text: string) => { + const currentText = answer?.customText || '' + const newText = + currentText.slice(0, customCursorPosition) + + text + + currentText.slice(customCursorPosition) + onSetCustomText(newText, customCursorPosition + text.length) + }, + [answer?.customText, customCursorPosition, onSetCustomText], + ) return ( {/* Question header - always visible */} - + {/* Expanded content - options */} {isExpanded && ( - {/* Multi-select hint */} - {isMultiSelect && ( - - (Select multiple options) - - )} - - {/* Options */} - {question.options.map((option, optionIndex) => { - const isSelected = isMultiSelect - ? answer?.selectedIndices?.has(optionIndex) ?? false - : answer?.selectedIndex === optionIndex - - return ( - handleOptionSelect(optionIndex)} - onMouseOver={() => onFocusOption(optionIndex)} - /> - ) - })} - - {/* Custom option - uses checkbox style for multi-select questions */} - - - {/* Text input area when typing Custom */} - {isTypingOther && ( - - { - onSetOtherText(inputValue.text, inputValue.cursorPosition) - }} - onSubmit={onOtherSubmit} - onPaste={(text) => { - if (text) { - const currentText = answer?.otherText || '' - const newText = - currentText.slice(0, otherCursorPosition) + - text + - currentText.slice(otherCursorPosition) - onSetOtherText(newText, otherCursorPosition + text.length) - } - }} - focused={true} - maxHeight={3} - minHeight={1} - placeholder="Type your answer..." - /> - + + + {/* Text input area when Custom is selected */} + {isCustomSelected && ( + )} )} diff --git a/cli/src/components/ask-user/components/custom-answer-input.tsx b/cli/src/components/ask-user/components/custom-answer-input.tsx new file mode 100644 index 0000000000..40cbaba936 --- /dev/null +++ b/cli/src/components/ask-user/components/custom-answer-input.tsx @@ -0,0 +1,66 @@ +/** + * Custom answer input component - MultilineInput wrapper for custom text answers + */ + +import React, { memo } from 'react' + +import { useTheme } from '../../../hooks/use-theme' +import { MultilineInput } from '../../multiline-input' + +export interface CustomAnswerInputProps { + value: string + cursorPosition: number + focused: boolean + optionIndent: number + onChange: (text: string, cursorPosition: number) => void + onSubmit: () => void + onPaste: (text: string) => void +} + +export const CustomAnswerInput: React.FC = memo( + ({ + value, + cursorPosition, + focused, + optionIndent, + onChange, + onSubmit, + onPaste, + }) => { + const theme = useTheme() + + return ( + + + { + onChange(inputValue.text, inputValue.cursorPosition) + }} + onSubmit={onSubmit} + onPaste={(text) => { + if (text) { + onPaste(text) + } + }} + focused={focused} + maxHeight={5} + minHeight={1} + placeholder="Type your answer..." + showScrollbar={true} + /> + + + ) + }, +) + +CustomAnswerInput.displayName = 'CustomAnswerInput' diff --git a/cli/src/components/ask-user/components/options-list.tsx b/cli/src/components/ask-user/components/options-list.tsx new file mode 100644 index 0000000000..67b7b381c0 --- /dev/null +++ b/cli/src/components/ask-user/components/options-list.tsx @@ -0,0 +1,133 @@ +/** + * Options list component that renders all question options + * including the Custom option button + */ + +import { TextAttributes } from '@opentui/core' +import React, { memo } from 'react' + +import { QuestionOption } from './question-option' +import { useTheme } from '../../../hooks/use-theme' +import { Button } from '../../button' +import { CUSTOM_OPTION_INDEX, SYMBOLS } from '../constants' + +import type { AccordionAnswer } from './accordion-question' +import type { AskUserQuestion } from '../../../types/store' + +export interface OptionsListProps { + question: AskUserQuestion + answer: AccordionAnswer | undefined + optionIndent: number + focusedOptionIndex: number | null + isTypingCustom: boolean + onSelectOption: (optionIndex: number) => void + onToggleOption: (optionIndex: number) => void + onFocusOption: (index: number | null) => void +} + +export const OptionsList: React.FC = memo( + ({ + question, + answer, + optionIndent, + focusedOptionIndex, + isTypingCustom, + onSelectOption, + onToggleOption, + onFocusOption, + }) => { + const theme = useTheme() + const isMultiSelect = question.multiSelect + + const isCustomSelected = answer?.isCustom ?? false + const isCustomFocused = focusedOptionIndex === question.options.length || isTypingCustom + const selectedFg = theme.name === 'dark' ? '#ffffff' : '#000000' + const customSymbol = isMultiSelect + ? isCustomSelected + ? SYMBOLS.CHECKBOX_CHECKED + : SYMBOLS.CHECKBOX_UNCHECKED + : isCustomSelected + ? SYMBOLS.SELECTED + : SYMBOLS.UNSELECTED + const customFg = isCustomFocused ? '#000000' : isCustomSelected ? selectedFg : theme.muted + const customAttributes = isCustomFocused || isCustomSelected ? TextAttributes.BOLD : undefined + + const handleOptionSelect = (optionIndex: number) => { + if (isMultiSelect) { + onToggleOption(optionIndex) + } else { + onSelectOption(optionIndex) + } + } + + const handleCustomClick = () => { + if (isMultiSelect) { + onToggleOption(CUSTOM_OPTION_INDEX) + } else { + onSelectOption(CUSTOM_OPTION_INDEX) + } + } + + return ( + <> + {/* Multi-select hint */} + {isMultiSelect && ( + + (Select multiple options) + + )} + + {/* Options */} + {question.options.map((option, optionIndex) => { + const isSelected = isMultiSelect + ? answer?.selectedIndices?.has(optionIndex) ?? false + : answer?.selectedIndex === optionIndex + + return ( + handleOptionSelect(optionIndex)} + onMouseOver={() => onFocusOption(optionIndex)} + /> + ) + })} + + {/* Custom option - uses checkbox style for multi-select questions */} + + + ) + }, +) + +OptionsList.displayName = 'OptionsList' diff --git a/cli/src/components/ask-user/components/question-header.tsx b/cli/src/components/ask-user/components/question-header.tsx new file mode 100644 index 0000000000..8851d39222 --- /dev/null +++ b/cli/src/components/ask-user/components/question-header.tsx @@ -0,0 +1,80 @@ +/** + * Question header component with expand/collapse functionality + * and answer preview when collapsed + */ + +import { TextAttributes } from '@opentui/core' +import React, { memo } from 'react' + +import { useTerminalLayout } from '../../../hooks/use-terminal-layout' +import { useTheme } from '../../../hooks/use-theme' +import { Button } from '../../button' + +// Overhead for the answer line: modal borders (~6) + marginLeft (3) + "↳ " (3) + quotes (2) + buffer (6) +const ANSWER_LINE_OVERHEAD = 20 + +export interface QuestionHeaderProps { + questionText: string + questionPrefix: string + isExpanded: boolean + isAnswered: boolean + answerDisplay: string + onToggleExpand: () => void +} + +export const QuestionHeader: React.FC = memo( + ({ + questionText, + questionPrefix, + isExpanded, + isAnswered, + answerDisplay, + onToggleExpand, + }) => { + const theme = useTheme() + const { terminalWidth } = useTerminalLayout() + + // Calculate available width for the answer text and truncate with ellipsis at end + const availableWidth = Math.max(20, terminalWidth - ANSWER_LINE_OVERHEAD) + const truncatedAnswer = + answerDisplay.length > availableWidth + ? answerDisplay.slice(0, availableWidth - 1) + '…' + : answerDisplay + + return ( + + ) + }, +) + +QuestionHeader.displayName = 'QuestionHeader' diff --git a/cli/src/components/ask-user/constants.ts b/cli/src/components/ask-user/constants.ts index 4765df056a..9bd7ac351f 100644 --- a/cli/src/components/ask-user/constants.ts +++ b/cli/src/components/ask-user/constants.ts @@ -29,8 +29,8 @@ export const getOptionLabel = (option: AskUserOption): string => { return typeof option === 'string' ? option : option?.label ?? '' } -/** Constant for the "Other" option index */ -export const OTHER_OPTION_INDEX: number = -1 +/** Constant for the "Custom" option index */ +export const CUSTOM_OPTION_INDEX: number = -1 export const KEYBOARD_HINTS = [ '↑↓ navigate •', diff --git a/cli/src/components/ask-user/index.tsx b/cli/src/components/ask-user/index.tsx index f9826910d9..b56b5cccd2 100644 --- a/cli/src/components/ask-user/index.tsx +++ b/cli/src/components/ask-user/index.tsx @@ -8,19 +8,19 @@ import { TextAttributes } from '@opentui/core' import { useKeyboard } from '@opentui/react' import React, { useState, useCallback, useEffect, useRef } from 'react' -import type { KeyEvent } from '@opentui/core' import { AccordionQuestion, type AccordionAnswer, } from './components/accordion-question' -import { getOptionLabel, KEYBOARD_HINTS, OTHER_OPTION_INDEX } from './constants' +import { getOptionLabel, KEYBOARD_HINTS, CUSTOM_OPTION_INDEX } from './constants' import { useTheme } from '../../hooks/use-theme' import { useChatStore } from '../../state/chat-store' import { BORDER_CHARS } from '../../utils/ui-constants' import { Button } from '../button' -import type { AskUserQuestion } from '../../state/chat-store' +import type { AskUserQuestion } from '../../types/store' +import type { KeyEvent } from '@opentui/core' export interface MultipleChoiceFormProps { questions: AskUserQuestion[] @@ -67,11 +67,11 @@ export const MultipleChoiceForm: React.FC = ({ optionIndex: number } | null>(null) - // Track if user is typing in "Other" text input - const [isTypingOther, setIsTypingOther] = useState(false) + // Track if user is typing in "Custom" text input + const [isTypingCustom, setIsTypingCustom] = useState(false) - // Track cursor position for "Other" text input (per question) - const [otherCursorPositions, setOtherCursorPositions] = useState>( + // Track cursor position for "Custom" text input (per question) + const [customCursorPositions, setCustomCursorPositions] = useState>( new Map(), ) @@ -95,7 +95,7 @@ export const MultipleChoiceForm: React.FC = ({ setFocusedQuestionIndex(questionIndex) setFocusedOptionIndex(optionIndex) setSubmitFocused(false) - setIsTypingOther(false) + setIsTypingCustom(false) }, []) const focusSubmit = useCallback( @@ -104,20 +104,20 @@ export const MultipleChoiceForm: React.FC = ({ const questionIndex = from?.questionIndex ?? focusedQuestionIndex setLastFocusBeforeSubmit({ questionIndex, optionIndex }) setSubmitFocused(true) - setIsTypingOther(false) + setIsTypingCustom(false) }, [focusedOptionIndex, focusedQuestionIndex], ) - // Handle setting "Other" text (with cursor position) - const handleSetOtherText = useCallback( + // Handle setting "Custom" text (with cursor position) + const handleSetCustomText = useCallback( (questionIndex: number, text: string, cursorPosition: number) => { setAnswerForQuestion(questionIndex, (currentAnswer) => ({ ...currentAnswer, - isOther: true, - otherText: text, + isCustom: true, + customText: text, })) - setOtherCursorPositions((prev) => { + setCustomCursorPositions((prev) => { const newPositions = new Map(prev) newPositions.set(questionIndex, cursorPosition) return newPositions @@ -126,10 +126,10 @@ export const MultipleChoiceForm: React.FC = ({ [setAnswerForQuestion], ) - // Handle "Other" text submit (Enter key) - const handleOtherSubmit = useCallback( + // Handle "Custom" text submit (Enter key) + const handleCustomSubmit = useCallback( (questionIndex: number) => { - setIsTypingOther(false) + setIsTypingCustom(false) setSubmitFocused(false) if (questions[questionIndex]?.multiSelect) { @@ -157,34 +157,49 @@ export const MultipleChoiceForm: React.FC = ({ source: 'keyboard' | 'mouse' = 'keyboard', ) => { setSubmitFocused(false) - const isOtherOption = optionIndex === OTHER_OPTION_INDEX + const isCustomOption = optionIndex === CUSTOM_OPTION_INDEX + + // When clicking out of Custom typing mode, first click just exits and highlights + // the option without selecting it (requires a second click to actually select) + if (source === 'mouse' && isTypingCustom && !isCustomOption) { + setIsTypingCustom(false) + setFocusedOptionIndex(optionIndex) + setShowFocusHighlight(true) + // Deselect Custom option but preserve the typed text + setAnswerForQuestion(questionIndex, (currentAnswer) => ({ + ...currentAnswer, + isCustom: false, + })) + return + } - if (source === 'mouse' && !isOtherOption) { + if (source === 'mouse' && !isCustomOption) { setShowFocusHighlight(false) suppressNextHoverFocusRef.current = true } setAnswerForQuestion(questionIndex, (currentAnswer) => - isOtherOption + isCustomOption ? { // Selecting "Custom" should clear any single-select choice selectedIndex: undefined, selectedIndices: undefined, - isOther: true, - otherText: currentAnswer?.otherText || '', + isCustom: true, + customText: currentAnswer?.customText || '', } : { selectedIndex: optionIndex, selectedIndices: undefined, - isOther: false, + isCustom: false, + customText: currentAnswer?.customText, // Preserve custom text when switching away }, ) - // For "Other" option, enter typing mode - if (isOtherOption) { + // For "Custom" option, enter typing mode + if (isCustomOption) { setFocusedQuestionIndex(questionIndex) setFocusedOptionIndex(questions[questionIndex]?.options.length ?? 0) - setIsTypingOther(true) + setIsTypingCustom(true) return } @@ -197,26 +212,26 @@ export const MultipleChoiceForm: React.FC = ({ setExpandedIndex(null) focusSubmit({ questionIndex, optionIndex }) }, - [questions, openQuestion, focusSubmit, setAnswerForQuestion], + [questions, openQuestion, focusSubmit, setAnswerForQuestion, isTypingCustom], ) // Handle toggling an option (multi-select) const handleToggleOption = useCallback( (questionIndex: number, optionIndex: number) => { setSubmitFocused(false) - let toggledOtherOn = false + let toggledCustomOn = false setAnswers((prev) => { const newAnswers = new Map(prev) const currentAnswer: AccordionAnswer = prev.get(questionIndex) ?? {} - if (optionIndex === OTHER_OPTION_INDEX) { - toggledOtherOn = !(currentAnswer?.isOther ?? false) + if (optionIndex === CUSTOM_OPTION_INDEX) { + toggledCustomOn = !(currentAnswer?.isCustom ?? false) newAnswers.set(questionIndex, { ...currentAnswer, selectedIndices: new Set(currentAnswer?.selectedIndices ?? []), - isOther: !currentAnswer?.isOther, - otherText: currentAnswer?.otherText || '', + isCustom: !currentAnswer?.isCustom, + customText: currentAnswer?.customText || '', }) return newAnswers } @@ -230,14 +245,14 @@ export const MultipleChoiceForm: React.FC = ({ newAnswers.set(questionIndex, { ...currentAnswer, selectedIndices: newIndices, - isOther: currentAnswer?.isOther ?? false, + isCustom: currentAnswer?.isCustom ?? false, }) return newAnswers }) - // For "Other" option in multi-select, also enter typing mode - if (optionIndex === OTHER_OPTION_INDEX) { - setIsTypingOther(toggledOtherOn) + // For "Custom" option in multi-select, also enter typing mode + if (optionIndex === CUSTOM_OPTION_INDEX) { + setIsTypingCustom(toggledCustomOn) } }, [], @@ -261,8 +276,8 @@ export const MultipleChoiceForm: React.FC = ({ : [] const customText = - answer.isOther && (answer.otherText?.trim().length ?? 0) > 0 - ? (answer.otherText ?? '').trim() + answer.isCustom && (answer.customText?.trim().length ?? 0) > 0 + ? (answer.customText ?? '').trim() : '' const parts = customText ? [...selectedOptions, customText] : selectedOptions @@ -313,7 +328,7 @@ export const MultipleChoiceForm: React.FC = ({ if (submitFocused) { if (key.name === 'up' || (key.name === 'tab' && key.shift)) { preventDefault() - setIsTypingOther(false) + setIsTypingCustom(false) setSubmitFocused(false) if (questions.length === 0) return if (lastFocusBeforeSubmit) { @@ -337,8 +352,8 @@ export const MultipleChoiceForm: React.FC = ({ return } - // When typing in "Other" input, let MultilineInput handle all keyboard input - if (isTypingOther) { + // When typing in "Custom" input, let MultilineInput handle all keyboard input + if (isTypingCustom) { return } @@ -437,7 +452,7 @@ export const MultipleChoiceForm: React.FC = ({ const optionIdx = currentOptionIndex === lastOptionIndex - ? OTHER_OPTION_INDEX + ? CUSTOM_OPTION_INDEX : currentOptionIndex if (currentQuestion.multiSelect) { handleToggleOption(currentQuestionIndex, optionIdx) @@ -454,7 +469,7 @@ export const MultipleChoiceForm: React.FC = ({ focusedOptionIndex, submitFocused, lastFocusBeforeSubmit, - isTypingOther, + isTypingCustom, showFocusHighlight, handleSelectOption, handleToggleOption, @@ -502,13 +517,13 @@ export const MultipleChoiceForm: React.FC = ({ totalQuestions={questions.length} answer={answers.get(index)} isExpanded={expandedIndex === index} - isTypingOther={isTypingOther && expandedIndex === index} + isTypingCustom={isTypingCustom && expandedIndex === index} onToggleExpand={() => { const nextExpandedIndex = expandedIndex === index ? null : index setExpandedIndex(nextExpandedIndex) setFocusedQuestionIndex(index) setSubmitFocused(false) - setIsTypingOther(false) + setIsTypingCustom(false) setFocusedOptionIndex(nextExpandedIndex === null ? null : 0) }} onSelectOption={(optionIndex) => @@ -517,16 +532,16 @@ export const MultipleChoiceForm: React.FC = ({ onToggleOption={(optionIndex) => handleToggleOption(index, optionIndex) } - onSetOtherText={(text, cursorPos) => handleSetOtherText(index, text, cursorPos)} - onOtherSubmit={() => handleOtherSubmit(index)} - otherCursorPosition={otherCursorPositions.get(index) ?? 0} + onSetCustomText={(text, cursorPos) => handleSetCustomText(index, text, cursorPos)} + onCustomSubmit={() => handleCustomSubmit(index)} + customCursorPosition={customCursorPositions.get(index) ?? 0} focusedOptionIndex={ expandedIndex === index && !submitFocused && showFocusHighlight ? focusedOptionIndex : null } onFocusOption={(optionIndex) => { - if (!terminalFocused || isTypingOther) return + if (!terminalFocused || isTypingCustom) return if (suppressNextHoverFocusRef.current) { suppressNextHoverFocusRef.current = false return diff --git a/cli/src/components/blocks/agent-block-grid.tsx b/cli/src/components/blocks/agent-block-grid.tsx new file mode 100644 index 0000000000..9d93db501d --- /dev/null +++ b/cli/src/components/blocks/agent-block-grid.tsx @@ -0,0 +1,58 @@ +import React, { memo, useCallback, useMemo } from 'react' + +import { GridLayout } from '../grid-layout' +import { splitAgentsBySize } from '../../utils/block-processor' + +import type { AgentContentBlock } from '../../types/chat' + +export interface AgentBlockGridProps { + agentBlocks: AgentContentBlock[] + keyPrefix: string + availableWidth: number + renderAgentBranch: ( + agentBlock: AgentContentBlock, + keyPrefix: string, + availableWidth: number, + ) => React.ReactNode +} + +export const AgentBlockGrid = memo( + ({ + agentBlocks, + keyPrefix, + availableWidth, + renderAgentBranch, + }: AgentBlockGridProps) => { + const getItemKey = useCallback( + (agentBlock: AgentContentBlock) => agentBlock.agentId, + [], + ) + + const renderItem = useCallback( + (agentBlock: AgentContentBlock, idx: number, columnWidth: number) => + renderAgentBranch(agentBlock, `${keyPrefix}-agent-${idx}`, columnWidth), + [keyPrefix, renderAgentBranch], + ) + + const subGroups = useMemo( + () => splitAgentsBySize(agentBlocks), + [agentBlocks], + ) + + if (agentBlocks.length === 0) return null + + return ( + + {subGroups.map((group) => ( + + ))} + + ) + }, +) diff --git a/cli/src/components/agent-branch-item.tsx b/cli/src/components/blocks/agent-branch-item.tsx similarity index 84% rename from cli/src/components/agent-branch-item.tsx rename to cli/src/components/blocks/agent-branch-item.tsx index 59f35d1580..90573fe51c 100644 --- a/cli/src/components/agent-branch-item.tsx +++ b/cli/src/components/blocks/agent-branch-item.tsx @@ -1,12 +1,14 @@ import { TextAttributes } from '@opentui/core' import React, { memo, type ReactNode } from 'react' -import { Button } from './button' -import { CollapseButton } from './collapse-button' -import { useTheme } from '../hooks/use-theme' -import { useWhyDidYouUpdateById } from '../hooks/use-why-did-you-update' -import { getCliEnv } from '../utils/env' -import { BORDER_CHARS } from '../utils/ui-constants' +import { useTheme } from '../../hooks/use-theme' +import { useWhyDidYouUpdateById } from '../../hooks/use-why-did-you-update' +import { getCliEnv } from '../../utils/env' +import { MAX_COLLAPSED_LINES, truncateToLines } from '../../utils/strings' +import { BORDER_CHARS } from '../../utils/ui-constants' +import { Button } from '../button' +import { CollapseButton } from '../collapse-button' +import { ShimmerText } from '../shimmer-text' interface AgentBranchItemProps { name: string @@ -15,8 +17,8 @@ interface AgentBranchItemProps { agentId?: string isCollapsed: boolean isStreaming: boolean - streamingPreview: string - finishedPreview: string + /** Preview text shown when collapsed (empty string = no preview) */ + preview: string statusLabel?: string statusColor?: string statusIndicator?: string @@ -32,8 +34,7 @@ export const AgentBranchItem = memo((props: AgentBranchItemProps) => { agentId, isCollapsed, isStreaming, - streamingPreview, - finishedPreview, + preview, statusLabel, statusColor, statusIndicator = '●', @@ -64,8 +65,7 @@ export const AgentBranchItem = memo((props: AgentBranchItemProps) => { ? `${statusLabel} ${statusIndicator}` : `${statusIndicator} ${statusLabel}` : null - const showCollapsedPreview = - (isStreaming && !!streamingPreview) || (!isStreaming && !!finishedPreview) + const showCollapsedPreview = preview.length > 0 const isTextRenderable = (value: ReactNode): boolean => { if (value === null || value === undefined || typeof value === 'boolean') { @@ -81,8 +81,9 @@ export const AgentBranchItem = memo((props: AgentBranchItemProps) => { } if (React.isValidElement(value)) { + const elProps = value.props as Record if (value.type === React.Fragment) { - return isTextRenderable(value.props.children) + return isTextRenderable(elProps.children as ReactNode) } if (typeof value.type === 'string') { @@ -91,7 +92,7 @@ export const AgentBranchItem = memo((props: AgentBranchItemProps) => { value.type === 'strong' || value.type === 'em' ) { - return isTextRenderable(value.props.children) + return isTextRenderable(elProps.children as ReactNode) } return false @@ -126,7 +127,7 @@ export const AgentBranchItem = memo((props: AgentBranchItemProps) => { if (React.isValidElement(value)) { if (value.key === null || value.key === undefined) { return ( - + {value} ) @@ -136,7 +137,7 @@ export const AgentBranchItem = memo((props: AgentBranchItemProps) => { if (Array.isArray(value)) { return ( - + {value.map((child, idx) => ( { } return ( - + {value} ) @@ -234,7 +235,7 @@ export const AgentBranchItem = memo((props: AgentBranchItemProps) => { fg={isStreaming ? theme.foreground : theme.muted} attributes={getAttributes(TextAttributes.ITALIC)} > - {isStreaming ? streamingPreview : finishedPreview} + {truncateToLines(preview, MAX_COLLAPSED_LINES)} ) : null @@ -286,6 +287,22 @@ export const AgentBranchItem = memo((props: AgentBranchItemProps) => { {onToggle && } )} + {isStreaming && isExpanded && ( + + + + + + )} ) diff --git a/cli/src/components/blocks/agent-branch-wrapper.tsx b/cli/src/components/blocks/agent-branch-wrapper.tsx new file mode 100644 index 0000000000..46da9ea921 --- /dev/null +++ b/cli/src/components/blocks/agent-branch-wrapper.tsx @@ -0,0 +1,493 @@ +import { TextAttributes } from '@opentui/core' +import React, { + memo, + useCallback, + useMemo, + useRef, + type ReactNode, +} from 'react' + +import { AgentBlockGrid } from './agent-block-grid' +import { AgentBranchItem } from './agent-branch-item' +import { trimNewlines, sanitizePreview } from './block-helpers' +import { ContentWithMarkdown } from './content-with-markdown' +import { ImplementorGroup } from './implementor-row' +import { ThinkingBlock } from './thinking-block' +import { ToolBlockGroup } from './tool-block-group' +import { useTheme } from '../../hooks/use-theme' +import { useChatStore } from '../../state/chat-store' +import { isTextBlock } from '../../types/chat' +import { + getAgentDisplayPrompt, + getBasherFinishedOutputPreview, +} from '../../utils/agent-display' +import { getAgentStatusInfo } from '../../utils/agent-helpers' +import { + processBlocks, + type BlockProcessorHandlers, +} from '../../utils/block-processor' +import { getCodeSearcherCollapsedPreview } from '../../utils/code-search-summary' +import { + shouldRenderAsSimpleText, + isMultiPromptEditor, +} from '../../utils/constants' +import { + isImplementorAgent, + getImplementorIndex, + getMultiPromptPreview, +} from '../../utils/implementor-helpers' +import { AGENT_CONTENT_HORIZONTAL_PADDING } from '../../utils/layout-helpers' + +import type { + AgentContentBlock, + ContentBlock, + TextContentBlock, + HtmlContentBlock, + ToolContentBlock, +} from '../../types/chat' +import type { MarkdownPalette } from '../../utils/markdown-renderer' + +/** + * Compute preview text for collapsed agent display. + * Returns empty string when preview shouldn't be shown (expanded state). + */ +function getCollapsedPreview( + agentBlock: AgentContentBlock, + isStreaming: boolean, + isCollapsed: boolean, + availableWidth: number, +): string { + // No preview needed if expanded and not streaming + if (!isStreaming && !isCollapsed) { + return '' + } + + if (!isStreaming) { + const outputPreview = getBasherFinishedOutputPreview( + agentBlock, + Math.max(24, Math.min(120, availableWidth - 4)), + ) + if (outputPreview) { + return outputPreview + } + } + + // For multi-prompt editors, try progress-focused preview first + if (isMultiPromptEditor(agentBlock.agentType)) { + const multiPromptPreview = getMultiPromptPreview( + agentBlock.blocks, + agentBlock.status === 'complete', + ) + if (multiPromptPreview) { + return multiPromptPreview + } + } + + const codeSearcherPreview = getCodeSearcherCollapsedPreview(agentBlock) + if (codeSearcherPreview) { + return codeSearcherPreview + } + + // Default preview: use the displayed prompt or first line of text content. + const displayPrompt = getAgentDisplayPrompt(agentBlock) + if (displayPrompt) { + return sanitizePreview(displayPrompt) + } + + const textContent = + agentBlock.blocks + ?.filter(isTextBlock) + .map((b) => b.content) + .join('') || '' + const firstLine = textContent.split('\n').find((line) => line.trim()) || '' + return `${sanitizePreview(firstLine)}...` +} + +interface AgentBodyProps { + agentBlock: Extract + keyPrefix: string + parentIsStreaming: boolean + availableWidth: number + markdownPalette: MarkdownPalette + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + onBuildLite: () => void + isLastMessage?: boolean +} + +/** Props stored in ref for stable handler access in AgentBody */ +interface AgentBodyPropsRef { + agentBlock: AgentContentBlock + keyPrefix: string + nestedBlocks: ContentBlock[] + parentIsStreaming: boolean + availableWidth: number + markdownPalette: MarkdownPalette + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + onBuildLite: () => void + isLastMessage?: boolean + theme: ReturnType + getAgentMarkdownOptions: (indent: number) => { + codeBlockWidth: number + palette: MarkdownPalette + } +} + +const AgentBody = memo( + ({ + agentBlock, + keyPrefix, + parentIsStreaming, + availableWidth, + markdownPalette, + onToggleCollapsed, + onBuildFast, + onBuildMax, + onBuildLite, + isLastMessage, + }: AgentBodyProps): ReactNode[] => { + const theme = useTheme() + const nestedBlocks = agentBlock.blocks ?? [] + + const getAgentMarkdownOptions = useCallback( + (indent: number) => { + const indentationOffset = indent * 2 + return { + codeBlockWidth: Math.max( + 10, + availableWidth - + AGENT_CONTENT_HORIZONTAL_PADDING - + indentationOffset, + ), + palette: { + ...markdownPalette, + codeTextFg: theme.foreground, + }, + } + }, + [availableWidth, markdownPalette, theme.foreground], + ) + + // Store props in ref for stable handler access (avoids 12+ useMemo dependencies) + const propsRef = useRef(null!) + propsRef.current = { + agentBlock, + keyPrefix, + nestedBlocks, + parentIsStreaming, + availableWidth, + markdownPalette, + onToggleCollapsed, + onBuildFast, + onBuildMax, + onBuildLite, + isLastMessage, + theme, + getAgentMarkdownOptions, + } + + // Handlers are stable (empty deps) and read latest props from ref + const handlers: BlockProcessorHandlers = useMemo( + () => ({ + onReasoningGroup: (reasoningBlocks, startIndex) => { + const p = propsRef.current + return ( + + ) + }, + + onToolGroup: (toolBlocks, startIndex, nextIndex) => { + const p = propsRef.current + return ( + + ) + }, + + onImplementorGroup: (implementors, startIndex) => { + const p = propsRef.current + return ( + + ) + }, + + onAgentGroup: (agentBlocks, startIndex) => { + const p = propsRef.current + return ( + ( + + )} + /> + ) + }, + + onSingleBlock: (block, index) => { + const p = propsRef.current + if (block.type === 'text') { + const textBlock = block as TextContentBlock + const nestedStatus = textBlock.status + const isNestedStreamingText = + p.parentIsStreaming || nestedStatus === 'running' + const filteredNestedContent = isNestedStreamingText + ? trimNewlines(textBlock.content) + : textBlock.content.trim() + if (!filteredNestedContent) { + return null + } + const markdownOptionsForLevel = p.getAgentMarkdownOptions(0) + const explicitColor = textBlock.color + const nestedTextColor = explicitColor ?? p.theme.foreground + + return ( + + + + ) + } + + if (block.type === 'html') { + const htmlBlock = block as HtmlContentBlock + + return ( + + {htmlBlock.render({ + textColor: p.theme.foreground, + theme: p.theme, + })} + + ) + } + + // Fallback for unknown block types + return null + }, + }), + [], // Empty deps - handlers read from propsRef.current + ) + + return processBlocks(nestedBlocks, handlers) as ReactNode[] + }, +) + +export interface AgentBranchWrapperProps { + agentBlock: Extract + keyPrefix: string + availableWidth: number + markdownPalette: MarkdownPalette + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + onBuildLite: () => void + siblingBlocks?: ContentBlock[] + isLastMessage?: boolean +} + +export const AgentBranchWrapper = memo( + ({ + agentBlock, + keyPrefix, + availableWidth, + markdownPalette, + onToggleCollapsed, + onBuildFast, + onBuildMax, + onBuildLite, + siblingBlocks, + isLastMessage, + }: AgentBranchWrapperProps) => { + const theme = useTheme() + // Derive streaming boolean for this specific agent to avoid re-renders when other agents change + const agentIsStreaming = useChatStore((state) => + state.streamingAgents.has(agentBlock.agentId), + ) + + if (shouldRenderAsSimpleText(agentBlock.agentType)) { + const isStreaming = agentBlock.status === 'running' || agentIsStreaming + + const effectiveStatus = isStreaming ? 'running' : agentBlock.status + const { indicator: statusIndicator, color: statusColor } = + getAgentStatusInfo(effectiveStatus, theme) + + let statusText = 'Selecting best' + let reason: string | undefined + + const isComplete = agentBlock.status === 'complete' + if (isComplete && siblingBlocks) { + const blocks = agentBlock.blocks ?? [] + // Find the set_output tool call block (not necessarily the last block) + const setOutputBlock = blocks.find( + (b): b is ToolContentBlock => + b.type === 'tool' && b.toolName === 'set_output', + ) + // set_output wraps data in a 'data' property, so we need to access input.data + const outputData = ( + setOutputBlock?.input as { data?: Record } + )?.data + const implementationId = outputData?.implementationId as + | string + | undefined + if (implementationId) { + const letterIndex = implementationId.charCodeAt(0) - 65 + const implementors = siblingBlocks.filter( + (b): b is AgentContentBlock => + b.type === 'agent' && isImplementorAgent(b), + ) + + reason = outputData?.reason as string | undefined + + const selectedAgent = implementors[letterIndex] + if (selectedAgent) { + const index = getImplementorIndex(selectedAgent, siblingBlocks) + statusText = + index !== undefined + ? `Selected Strategy #${index + 1}` + : 'Selected' + } + } + } + + return ( + + + {statusIndicator} + + {' '} + {statusText} + + + {reason && ( + + {reason} + + )} + + ) + } + + const isCollapsed = agentBlock.isCollapsed ?? false + const isStreaming = agentBlock.status === 'running' || agentIsStreaming + + // Compute collapsed preview text + const preview = getCollapsedPreview( + agentBlock, + isStreaming, + isCollapsed, + availableWidth, + ) + const displayPrompt = getAgentDisplayPrompt(agentBlock) + + const effectiveStatus = isStreaming ? 'running' : agentBlock.status + const { + indicator: statusIndicator, + label: statusLabel, + color: statusColor, + } = getAgentStatusInfo(effectiveStatus, theme) + + const onToggle = useCallback(() => { + onToggleCollapsed(agentBlock.agentId) + }, [onToggleCollapsed, agentBlock.agentId]) + + return ( + + + + + + ) + }, +) diff --git a/cli/src/components/blocks/block-helpers.ts b/cli/src/components/blocks/block-helpers.ts new file mode 100644 index 0000000000..681d771fdd --- /dev/null +++ b/cli/src/components/blocks/block-helpers.ts @@ -0,0 +1,11 @@ + +export function trimNewlines(str: string): string { + return str.replace(/^\n+|\n+$/g, '') +} + +export function sanitizePreview(text: string): string { + return text.replace(/[#*_`~\[\]()]/g, '').trim() +} + +// Re-export from block-processor for backwards compatibility +export { isReasoningTextBlock } from '../../utils/block-processor' diff --git a/cli/src/components/blocks/blocks-renderer.tsx b/cli/src/components/blocks/blocks-renderer.tsx new file mode 100644 index 0000000000..372f650292 --- /dev/null +++ b/cli/src/components/blocks/blocks-renderer.tsx @@ -0,0 +1,209 @@ +import React, { memo, useMemo, useRef } from 'react' + +import { AgentBlockGrid } from './agent-block-grid' +import { AgentBranchWrapper } from './agent-branch-wrapper' +import { ImageBlock } from './image-block' +import { ImplementorGroup } from './implementor-row' +import { SingleBlock } from './single-block' +import { ThinkingBlock } from './thinking-block' +import { ToolBlockGroup } from './tool-block-group' +import { processBlocks, type BlockProcessorHandlers } from '../../utils/block-processor' + +import type { ContentBlock } from '../../types/chat' +import type { MarkdownPalette } from '../../utils/markdown-renderer' + +interface BlocksRendererProps { + sourceBlocks: ContentBlock[] + messageId: string + isLoading: boolean + isComplete?: boolean + isUser: boolean + textColor: string + availableWidth: number + markdownPalette: MarkdownPalette + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + onBuildLite: () => void + isLastMessage?: boolean + contentToCopy?: string +} + +/** Props stored in ref for stable handler access */ +interface BlocksRendererPropsRef { + sourceBlocks: ContentBlock[] + messageId: string + isLoading: boolean + isComplete?: boolean + isUser: boolean + textColor: string + availableWidth: number + markdownPalette: MarkdownPalette + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + onBuildLite: () => void + isLastMessage?: boolean + contentToCopy?: string + lastTextBlockIndex: number +} + +export const BlocksRenderer = memo( + ({ + sourceBlocks, + messageId, + isLoading, + isComplete, + isUser, + textColor, + availableWidth, + markdownPalette, + onToggleCollapsed, + onBuildFast, + onBuildMax, + onBuildLite, + isLastMessage, + contentToCopy, + }: BlocksRendererProps) => { + const lastTextBlockIndex = contentToCopy + ? sourceBlocks.reduceRight( + (acc, block, idx) => + acc === -1 && block.type === 'text' ? idx : acc, + -1, + ) + : -1 + + // Store props in ref for stable handler access (avoids 17 useMemo dependencies) + const propsRef = useRef(null!) + propsRef.current = { + sourceBlocks, + messageId, + isLoading, + isComplete, + isUser, + textColor, + availableWidth, + markdownPalette, + onToggleCollapsed, + onBuildFast, + onBuildMax, + onBuildLite, + isLastMessage, + contentToCopy, + lastTextBlockIndex, + } + + // Handlers are stable (empty deps) and read latest props from ref + const handlers: BlockProcessorHandlers = useMemo( + () => ({ + onReasoningGroup: (reasoningBlocks, startIndex) => { + const p = propsRef.current + return ( + + ) + }, + + onImageBlock: (block, index) => { + const p = propsRef.current + return ( + + ) + }, + + onToolGroup: (toolBlocks, startIndex, nextIndex) => { + const p = propsRef.current + return ( + + ) + }, + + onImplementorGroup: (implementors, startIndex) => { + const p = propsRef.current + return ( + + ) + }, + + onAgentGroup: (agentBlocks, startIndex) => { + const p = propsRef.current + return ( + ( + + )} + /> + ) + }, + + onSingleBlock: (block, index) => { + const p = propsRef.current + return ( + + ) + }, + }), + [], // Empty deps - handlers read from propsRef.current + ) + + return <>{processBlocks(sourceBlocks, handlers)} + }, +) diff --git a/cli/src/components/blocks/image-block.tsx b/cli/src/components/blocks/image-block.tsx index 761295709f..6aada062ed 100644 --- a/cli/src/components/blocks/image-block.tsx +++ b/cli/src/components/blocks/image-block.tsx @@ -62,7 +62,7 @@ export const ImageBlock = memo(({ block, availableWidth }: ImageBlockProps) => { if (inlineSequence) { // Render inline image using terminal escape sequence return ( - + {/* Image caption/metadata */} 📷 @@ -84,8 +84,6 @@ export const ImageBlock = memo(({ block, availableWidth }: ImageBlockProps) => { style={{ flexDirection: 'column', gap: 0, - marginTop: 1, - marginBottom: 1, paddingLeft: 1, borderStyle: 'single', borderColor: theme.border, diff --git a/cli/src/components/blocks/implementor-row.tsx b/cli/src/components/blocks/implementor-row.tsx new file mode 100644 index 0000000000..dcf32844e4 --- /dev/null +++ b/cli/src/components/blocks/implementor-row.tsx @@ -0,0 +1,429 @@ +import { TextAttributes } from '@opentui/core' +import React, { memo, useCallback, useMemo, useState } from 'react' + +/** Horizontal padding inside implementor cards (left + right) */ +const CARD_HORIZONTAL_PADDING = 4 +/** Fixed width for the +/- bar visualization */ +const STATS_BAR_WIDTH = 5 +/** Minimum inner content width */ +const MIN_INNER_WIDTH = 10 + +/** Labels for proposal cards when no file changes exist */ +const EMPTY_STATE_LABELS = { + running: 'generating...', + complete: 'no changes', + failed: 'failed', + cancelled: 'cancelled', +} as const + +import { useGridLayout } from '../../hooks/use-grid-layout' +import { useTheme } from '../../hooks/use-theme' +import { getAgentStatusInfo } from '../../utils/agent-helpers' +import { + buildActivityTimeline, + getImplementorDisplayName, + getImplementorIndex, + getFileStatsFromBlocks, + truncateWithEllipsis, + type FileStats, +} from '../../utils/implementor-helpers' +import { getRelativePath } from '../../utils/path-helpers' +import { PROPOSAL_BORDER_CHARS } from '../../utils/ui-constants' +import { Button } from '../button' +import { CollapseButton } from '../collapse-button' +import { DiffViewer } from '../tools/diff-viewer' + +import type { AgentContentBlock, ContentBlock } from '../../types/chat' + +interface ImplementorGroupProps { + implementors: AgentContentBlock[] + siblingBlocks: ContentBlock[] + availableWidth: number +} + +export const ImplementorGroup = memo( + ({ implementors, siblingBlocks, availableWidth }: ImplementorGroupProps) => { + const { columnWidth: cardWidth, columnGroups } = useGridLayout( + implementors, + availableWidth, + ) + + return ( + + {/* Masonry layout: columns side by side, cards stack vertically in each */} + + {columnGroups.map((columnItems, colIdx) => { + // Use first agent's ID as stable column key + const columnKey = columnItems[0]?.agentId ?? `col-${colIdx}` + return ( + + {columnItems.map((agentBlock) => { + const implementorIndex = getImplementorIndex( + agentBlock, + siblingBlocks, + ) + + return ( + + ) + })} + + ) + })} + + + ) + }, +) + +interface ImplementorCardProps { + agentBlock: AgentContentBlock + implementorIndex?: number + cardWidth: number +} + +const ImplementorCard = memo( + ({ agentBlock, implementorIndex, cardWidth }: ImplementorCardProps) => { + const theme = useTheme() + const [selectedFile, setSelectedFile] = useState(null) + + const isComplete = agentBlock.status === 'complete' + + const displayName = getImplementorDisplayName( + agentBlock.agentType, + implementorIndex, + ) + + // Get file stats for compact view + const fileStats = useMemo( + () => getFileStatsFromBlocks(agentBlock.blocks), + [agentBlock.blocks], + ) + + // Build timeline to extract diffs + const timeline = useMemo( + () => buildActivityTimeline(agentBlock.blocks), + [agentBlock.blocks], + ) + + // Build map of file path -> diff for inline display + const fileDiffs = useMemo(() => { + const diffs = new Map() + for (const item of timeline) { + if (item.type === 'edit' && item.diff) { + diffs.set(item.content, item.diff) + } + } + return diffs + }, [timeline]) + + // Get status info from helper + const { + indicator: statusIndicator, + label: statusLabel, + color: statusColor, + } = getAgentStatusInfo(agentBlock.status, theme) + // Format: "● running" when streaming, "completed ✓" when done (checkmark at end) + const statusText = + statusIndicator === '✓' + ? `${statusLabel} ${statusIndicator}` + : `${statusIndicator} ${statusLabel}` + + // Use cardWidth for internal truncation calculations (approximate internal space) + const innerWidth = Math.max( + MIN_INNER_WIDTH, + cardWidth - CARD_HORIZONTAL_PADDING, + ) + + // Toggle file selection - clicking same file deselects it + const handleFileSelect = useCallback((filePath: string) => { + setSelectedFile((prev) => (prev === filePath ? null : filePath)) + }, []) + + return ( + + {/* Header: Model name + Status */} + + + {displayName} + + + {statusText} + + + + {/* Prompt provided to this proposal */} + {agentBlock.initialPrompt && ( + + + {agentBlock.initialPrompt} + + + )} + + {/* File stats - click file name to view diff inline */} + {fileStats.length > 0 && ( + + )} + + {/* Show status-appropriate message when no file changes */} + {fileStats.length === 0 && ( + + {EMPTY_STATE_LABELS[agentBlock.status]} + + )} + + ) + }, +) + +interface CompactFileStatsProps { + fileStats: FileStats[] + availableWidth: number + selectedFile: string | null + onSelectFile: (filePath: string) => void + /** Map of file path to diff content */ + fileDiffs: Map +} + +const CompactFileStats = memo( + ({ + fileStats, + availableWidth, + selectedFile, + onSelectFile, + fileDiffs, + }: CompactFileStatsProps) => { + const theme = useTheme() + + // Fixed bar width - keeps layout simple and predictable + const maxBarWidth = STATS_BAR_WIDTH + + // Calculate max string widths for alignment (so all bars meet at center axis) + // Always include +0/-0 in width calculation since we always show them + const maxAddedStrWidth = Math.max( + ...fileStats.map((f) => `+${f.stats.linesAdded}`.length), + 2, // Minimum "+0" + ) + const maxRemovedStrWidth = Math.max( + ...fileStats.map((f) => `-${f.stats.linesRemoved}`.length), + 2, // Minimum "-0" + ) + + return ( + + {fileStats.map((file, idx) => ( + onSelectFile(file.path)} + diff={fileDiffs.get(file.path)} + /> + ))} + + ) + }, +) + +interface CompactFileRowProps { + file: FileStats + availableWidth: number + maxBarWidth: number + maxAddedStrWidth: number + maxRemovedStrWidth: number + isSelected: boolean + onSelect: () => void + diff?: string +} + +const CompactFileRow = memo( + ({ + file, + availableWidth, + maxBarWidth, + maxAddedStrWidth, + maxRemovedStrWidth, + isSelected, + onSelect, + diff, + }: CompactFileRowProps) => { + const theme = useTheme() + const [isHovered, setIsHovered] = useState(false) + + // Format numbers - always show counts, including +0 and -0 + const addedStr = `+${file.stats.linesAdded}` + const removedStr = `-${file.stats.linesRemoved}` + + // Full-width colored sections with numbers inside: + // - Added section: green bar extending to center with +N in white (right-aligned) + // - Removed section: red bar extending from center with -N in white (left-aligned) + const addedSectionWidth = maxBarWidth + maxAddedStrWidth + const removedSectionWidth = maxBarWidth + maxRemovedStrWidth + + // +N right-aligned within the green section with 1 space padding before the center edge + const addedContent = (addedStr + ' ').padStart(addedSectionWidth) + // -N left-aligned within the red section with 1 space padding after the center edge + const removedContent = (' ' + removedStr).padEnd(removedSectionWidth) + + // Calculate available width for file path + // Layout: changeType(1) + spaces(2) + filePath + spaces(2) + bars + // Total bar section width: 2*maxBarWidth + maxAddedStrWidth + maxRemovedStrWidth (no center gap) + const barWidth = 2 * maxBarWidth + maxAddedStrWidth + maxRemovedStrWidth + const fixedWidth = 1 + 2 + 2 + barWidth + const maxFilePathWidth = Math.max(10, availableWidth - fixedWidth) + + // Get and truncate file path + const relativePath = getRelativePath(file.path) + const displayPath = truncateWithEllipsis(relativePath, maxFilePathWidth) + + return ( + + {/* File row */} + + {/* Change type: fixed */} + + {file.changeType} + + + + {/* File path: clickable with underline on hover, flexes to push bars right */} + + + + {/* Bar visualization: full-width bars meeting at center with numbers inside */} + + {/* Added section: muted gray-green bar with +N inside */} + + {addedContent} + + {/* Removed section: muted gray-red bar with -N inside */} + + {removedContent} + + + + + {/* Inline diff viewer when selected - aligns with card content (full width) */} + {isSelected && diff && ( + + + + + + + )} + + ) + }, +) + +// Keep the old exports for backward compatibility during transition +export { ImplementorCard as ImplementorRow } diff --git a/cli/src/components/blocks/single-block.tsx b/cli/src/components/blocks/single-block.tsx new file mode 100644 index 0000000000..1728e01053 --- /dev/null +++ b/cli/src/components/blocks/single-block.tsx @@ -0,0 +1,201 @@ +import { TextAttributes } from '@opentui/core' +import React, { memo, type ReactNode } from 'react' + +import { AgentBranchWrapper } from './agent-branch-wrapper' +import { AgentListBranch } from './agent-list-branch' +import { AskUserBranch } from './ask-user-branch' +import { trimNewlines, isReasoningTextBlock } from './block-helpers' +import { ContentWithMarkdown } from './content-with-markdown' +import { ImageBlock } from './image-block' +import { UserBlockTextWithInlineCopy } from './user-content-copy' +import { useTheme } from '../../hooks/use-theme' +import { PlanBox } from '../renderers/plan-box' + +import type { + ContentBlock, + TextContentBlock, + ImageContentBlock, +} from '../../types/chat' +import type { MarkdownPalette } from '../../utils/markdown-renderer' + +interface SingleBlockProps { + block: ContentBlock + idx: number + messageId: string + blocks?: ContentBlock[] + isLoading: boolean + isComplete?: boolean + isUser: boolean + textColor: string + availableWidth: number + markdownPalette: MarkdownPalette + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + onBuildLite: () => void + isLastMessage?: boolean + contentToCopy?: string +} + +export const SingleBlock = memo( + ({ + block, + idx, + messageId, + blocks, + isLoading, + isComplete, + isUser, + textColor, + availableWidth, + markdownPalette, + onToggleCollapsed, + onBuildFast, + onBuildMax, + onBuildLite, + isLastMessage, + contentToCopy, + }: SingleBlockProps): ReactNode => { + const theme = useTheme() + const codeBlockWidth = Math.max(10, availableWidth - 8) + + switch (block.type) { + case 'text': { + if (isReasoningTextBlock(block)) { + return null + } + const textBlock = block as TextContentBlock + const isStreamingText = isLoading || !isComplete + const filteredContent = isStreamingText + ? trimNewlines(textBlock.content) + : textBlock.content.trim() + if (!filteredContent) { + return null + } + const renderKey = `${messageId}-text-${idx}` + const explicitColor = textBlock.color + const blockTextColor = explicitColor ?? textColor + + if (contentToCopy) { + return ( + + ) + } + + return ( + + + + ) + } + + case 'plan': { + return ( + + + + ) + } + + case 'html': { + return ( + + {block.render({ textColor, theme })} + + ) + } + + case 'tool': { + return null + } + + case 'ask-user': { + return ( + + ) + } + + case 'image': { + return ( + + ) + } + + case 'agent': { + return ( + + ) + } + + case 'agent-list': { + return ( + + ) + } + + default: + return null + } + }, +) diff --git a/cli/src/components/blocks/thinking-block.tsx b/cli/src/components/blocks/thinking-block.tsx index 6e2988c1b3..a29f5ff2c2 100644 --- a/cli/src/components/blocks/thinking-block.tsx +++ b/cli/src/components/blocks/thinking-block.tsx @@ -13,6 +13,8 @@ interface ThinkingBlockProps { onToggleCollapsed: (id: string) => void availableWidth: number isNested: boolean + /** Whether the parent message is complete (used to hide native reasoning blocks) */ + isMessageComplete: boolean } export const ThinkingBlock = memo( @@ -21,6 +23,7 @@ export const ThinkingBlock = memo( onToggleCollapsed, availableWidth, isNested, + isMessageComplete, }: ThinkingBlockProps) => { const firstBlock = blocks[0] const thinkingId = firstBlock?.thinkingId @@ -29,7 +32,7 @@ export const ThinkingBlock = memo( .join('') .trim() - const isCollapsed = firstBlock?.isCollapsed ?? true + const thinkingCollapseState = firstBlock?.thinkingCollapseState ?? 'preview' const offset = isNested ? NESTED_WIDTH_OFFSET : WIDTH_OFFSET const availWidth = Math.max(10, availableWidth - offset) @@ -39,6 +42,12 @@ export const ThinkingBlock = memo( } }, [onToggleCollapsed, thinkingId]) + // thinkingOpen === false means explicitly closed (with tag or message completion) + // Otherwise (true or undefined), completion is determined by message completion + const isThinkingComplete = + firstBlock?.thinkingOpen === false || isMessageComplete + + // Hide if no content or no thinkingId (but NOT when thinking is complete) if (!combinedContent || !thinkingId) { return null } @@ -47,7 +56,8 @@ export const ThinkingBlock = memo( diff --git a/cli/src/components/blocks/tool-block-group.tsx b/cli/src/components/blocks/tool-block-group.tsx new file mode 100644 index 0000000000..1da064412d --- /dev/null +++ b/cli/src/components/blocks/tool-block-group.tsx @@ -0,0 +1,57 @@ +import React, { memo, type ReactNode } from 'react' + +import { ToolBranch } from './tool-branch' + +import type { ContentBlock } from '../../types/chat' +import type { MarkdownPalette } from '../../utils/markdown-renderer' + +interface ToolBlockGroupProps { + toolBlocks: Extract[] + keyPrefix: string + startIndex: number + /** @deprecated No longer used for margin calculation */ + nextIndex: number + /** @deprecated No longer used for margin calculation */ + siblingBlocks: ContentBlock[] + availableWidth: number + onToggleCollapsed: (id: string) => void + markdownPalette: MarkdownPalette +} + +export const ToolBlockGroup = memo( + ({ + toolBlocks, + keyPrefix, + startIndex, + availableWidth, + onToggleCollapsed, + markdownPalette, + }: ToolBlockGroupProps): ReactNode => { + const groupNodes = toolBlocks + .map((toolBlock) => ( + + )) + .filter(Boolean) + + if (groupNodes.length === 0) return null + + return ( + + {groupNodes} + + ) + }, +) diff --git a/cli/src/components/blocks/tool-branch.tsx b/cli/src/components/blocks/tool-branch.tsx index f63274f066..cc1c632d44 100644 --- a/cli/src/components/blocks/tool-branch.tsx +++ b/cli/src/components/blocks/tool-branch.tsx @@ -2,7 +2,9 @@ import { memo, useCallback } from 'react' import { ContentWithMarkdown } from './content-with-markdown' import { useTheme } from '../../hooks/use-theme' +import { useChatStore } from '../../state/chat-store' import { getToolDisplayInfo } from '../../utils/codebuff-client' +import { shouldCollapseToolByDefault } from '../../utils/constants' import { renderToolComponent } from '../tools/registry' import { ToolCallItem } from '../tools/tool-call-item' @@ -13,7 +15,6 @@ interface ToolBranchProps { toolBlock: Extract keyPrefix: string availableWidth: number - streamingAgents: Set onToggleCollapsed: (id: string) => void markdownPalette: MarkdownPalette } @@ -23,11 +24,12 @@ export const ToolBranch = memo( toolBlock, keyPrefix, availableWidth, - streamingAgents, onToggleCollapsed, markdownPalette, }: ToolBranchProps) => { const theme = useTheme() + // Derive streaming boolean for this specific tool to avoid re-renders when other tools/agents change + const isStreaming = useChatStore((state) => state.streamingAgents.has(toolBlock.toolCallId)) const sanitizePreview = (value: string): string => value.replace(/[#*_`~\[\]()]/g, '').trim() @@ -43,8 +45,19 @@ export const ToolBranch = memo( } const displayInfo = getToolDisplayInfo(toolBlock.toolName) - const isCollapsed = toolBlock.isCollapsed ?? false - const isStreaming = streamingAgents.has(toolBlock.toolCallId) + + // Check if there's a registered custom component for this tool + const toolRenderConfig = renderToolComponent(toolBlock, theme, { + availableWidth, + indentationOffset: 0, + previewPrefix: '', + labelWidth: 0, + }) + + // Tools without a registered component (fallback rendering) should be collapsed by default + const hasRegisteredComponent = toolRenderConfig !== undefined + const isCollapsed = toolBlock.isCollapsed ?? + (hasRegisteredComponent ? shouldCollapseToolByDefault(toolBlock.toolName) : true) const inputContent = `\`\`\`json\n${JSON.stringify(toolBlock.input, null, 2)}\n\`\`\`` const codeBlockLang = @@ -66,13 +79,6 @@ export const ToolBranch = memo( ? `$ ${toolBlock.input.command.trim()}` : null - let toolRenderConfig = renderToolComponent(toolBlock, theme, { - availableWidth, - indentationOffset: 0, - previewPrefix: '', - labelWidth: 0, - }) - const streamingPreview = isStreaming ? commandPreview ?? `${sanitizePreview(firstLine)}...` : '' diff --git a/cli/src/components/blocks/user-content-copy.tsx b/cli/src/components/blocks/user-content-copy.tsx new file mode 100644 index 0000000000..256b8177f9 --- /dev/null +++ b/cli/src/components/blocks/user-content-copy.tsx @@ -0,0 +1,157 @@ +import { TextAttributes } from '@opentui/core' +import React, { memo } from 'react' + +import { CopyButton } from '../copy-button' +import { trimNewlines } from './block-helpers' +import { ContentWithMarkdown } from './content-with-markdown' + +import type { MarkdownPalette } from '../../utils/markdown-renderer' + +interface UserContentWithCopyButtonProps { + content: string + messageId: string + isLoading: boolean + isComplete?: boolean + isUser: boolean + textColor: string + codeBlockWidth: number + palette: MarkdownPalette + showCopyButton: boolean +} + +export const UserContentWithCopyButton = memo( + ({ + content, + messageId, + isLoading, + isComplete, + isUser, + textColor, + codeBlockWidth, + palette, + showCopyButton, + }: UserContentWithCopyButtonProps) => { + const isStreamingMessage = isLoading || !isComplete + const normalizedContent = isStreamingMessage + ? trimNewlines(content) + : content.trim() + + const hasContent = normalizedContent.length > 0 + + if (!hasContent) { + return null + } + + if (!showCopyButton) { + return ( + + + + ) + } + + return ( + + ) + }, +) + +interface UserTextWithInlineCopyProps { + messageId: string + content: string + normalizedContent: string + isStreamingMessage: boolean + textColor: string + codeBlockWidth: number + palette: MarkdownPalette +} + +const UserTextWithInlineCopy = memo( + ({ + messageId, + content, + normalizedContent, + isStreamingMessage, + textColor, + codeBlockWidth, + palette, + }: UserTextWithInlineCopyProps) => { + return ( + + + + + + ) + }, +) + +interface UserBlockTextWithInlineCopyProps { + content: string + contentToCopy: string + isStreaming: boolean + textColor: string + codeBlockWidth: number + palette: MarkdownPalette + marginTop: number + marginBottom: number +} + +export const UserBlockTextWithInlineCopy = memo( + ({ + content, + contentToCopy, + isStreaming, + textColor, + codeBlockWidth, + palette, + marginTop, + marginBottom, + }: UserBlockTextWithInlineCopyProps) => { + return ( + + + + + + ) + }, +) diff --git a/cli/src/components/bottom-banner.tsx b/cli/src/components/bottom-banner.tsx index f6bc3a1d78..217209b48f 100644 --- a/cli/src/components/bottom-banner.tsx +++ b/cli/src/components/bottom-banner.tsx @@ -32,6 +32,8 @@ export interface BottomBannerConfig { children?: React.ReactNode /** Called when close button is clicked. If not provided, no close button is shown. */ onClose?: () => void + /** Which border sides to render. Defaults to ['bottom', 'left', 'right']. */ + border?: ('top' | 'bottom' | 'left' | 'right')[] } export type BottomBannerProps = BottomBannerConfig @@ -66,6 +68,7 @@ export const BottomBanner: React.FC = ({ text, children, onClose, + border, }) => { const { width, terminalWidth } = useTerminalLayout() const theme = useTheme() @@ -96,7 +99,7 @@ export const BottomBanner: React.FC = ({ marginTop: 0, marginBottom: 0, }} - border={['bottom', 'left', 'right']} + border={border ?? ['bottom', 'left', 'right']} customBorderChars={BORDER_CHARS} > {hasTextContent ? ( diff --git a/cli/src/components/bottom-status-line.tsx b/cli/src/components/bottom-status-line.tsx deleted file mode 100644 index 893114b2d8..0000000000 --- a/cli/src/components/bottom-status-line.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react' - -import { useTheme } from '../hooks/use-theme' - -import { formatResetTime } from '../utils/time-format' - -import type { ClaudeQuotaData } from '../hooks/use-claude-quota-query' - -interface BottomStatusLineProps { - /** Whether Claude OAuth is connected */ - isClaudeConnected: boolean - /** Whether Claude is actively being used (streaming/waiting) */ - isClaudeActive: boolean - /** Quota data from Anthropic API */ - claudeQuota?: ClaudeQuotaData | null -} - -/** - * Bottom status line component - shows below the input box - * Currently displays Claude subscription status when connected - */ -export const BottomStatusLine: React.FC = ({ - isClaudeConnected, - isClaudeActive, - claudeQuota, -}) => { - const theme = useTheme() - - // Don't render if there's nothing to show - if (!isClaudeConnected) { - return null - } - - // Use the more restrictive of the two quotas (5-hour window is usually the limiting factor) - const displayRemaining = claudeQuota - ? Math.min(claudeQuota.fiveHourRemaining, claudeQuota.sevenDayRemaining) - : null - - // Check if quota is exhausted (0%) - const isExhausted = displayRemaining !== null && displayRemaining <= 0 - - // Get the reset time for the limiting quota window - const resetTime = claudeQuota - ? claudeQuota.fiveHourRemaining <= claudeQuota.sevenDayRemaining - ? claudeQuota.fiveHourResetsAt - : claudeQuota.sevenDayResetsAt - : null - - // Determine dot color: red if exhausted, green if active, muted otherwise - const dotColor = isExhausted - ? theme.error - : isClaudeActive - ? theme.success - : theme.muted - - return ( - - - - Claude subscription - {isExhausted && resetTime ? ( - {` · resets in ${formatResetTime(resetTime)}`} - ) : displayRemaining !== null ? ( - {` ${Math.round(displayRemaining)}%`} - ) : null} - - - ) -} diff --git a/cli/src/components/build-mode-buttons.tsx b/cli/src/components/build-mode-buttons.tsx index cce0c89844..e03239c1e7 100644 --- a/cli/src/components/build-mode-buttons.tsx +++ b/cli/src/components/build-mode-buttons.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Button } from './button' +import { IS_FREEBUFF } from '../utils/constants' import { useTerminalLayout } from '../hooks/use-terminal-layout' import { BORDER_CHARS } from '../utils/ui-constants' @@ -10,12 +11,16 @@ export const BuildModeButtons = ({ theme, onBuildFast, onBuildMax, + onBuildLite, }: { theme: ChatTheme onBuildFast: () => void onBuildMax: () => void + onBuildLite: () => void }) => { - const [hoveredButton, setHoveredButton] = useState<'fast' | 'max' | null>( + if (IS_FREEBUFF) return null + + const [hoveredButton, setHoveredButton] = useState<'fast' | 'max' | 'lite' | null>( null, ) const { width } = useTerminalLayout() @@ -80,6 +85,25 @@ export const BuildModeButtons = ({ Build MAX + ) diff --git a/cli/src/components/chat-history-screen.tsx b/cli/src/components/chat-history-screen.tsx index 7255380f2e..01f3e03322 100644 --- a/cli/src/components/chat-history-screen.tsx +++ b/cli/src/components/chat-history-screen.tsx @@ -7,7 +7,11 @@ import { SelectableList } from './selectable-list' import { useSearchableList } from '../hooks/use-searchable-list' import { useTerminalLayout } from '../hooks/use-terminal-layout' import { useTheme } from '../hooks/use-theme' -import { getAllChats, formatRelativeTime } from '../utils/chat-history' +import { + deleteChatSession, + formatRelativeTime, + getAllChats, +} from '../utils/chat-history' import type { SelectableListItem } from './selectable-list' @@ -17,10 +21,11 @@ const LAYOUT = { NARROW_WIDTH_THRESHOLD: 70, // Hide buttons when terminal width is below this MAIN_CONTENT_PADDING: 2, INITIAL_CHATS: 25, // Load this many immediately for fast display - BACKGROUND_CHATS: 975, // Load this many more in the background for search + BACKGROUND_CHATS: 475, // Load this many more in the background for search MAX_RENDERED_CHATS: 100, // Only render this many in the list TIME_COL_WIDTH: 12, // e.g., "2 hours ago" MSGS_COL_WIDTH: 8, // e.g., "99 msgs" + DELETE_COL_WIDTH: 6, // e.g., "[×]" + marginRight GAP_WIDTH: 3, // gap between columns } as const @@ -42,34 +47,39 @@ export const ChatHistoryScreen: React.FC = ({ const contentWidth = terminalWidth - LAYOUT.CONTENT_PADDING // Two-phase loading: load initial chats immediately, then more in background - const initialChats = useMemo(() => getAllChats(LAYOUT.INITIAL_CHATS), []) - const [backgroundChats, setBackgroundChats] = useState( - [], - ) + const [chats, setChats] = useState(() => getAllChats(LAYOUT.INITIAL_CHATS)) + const [statusMessage, setStatusMessage] = useState(null) // Load more chats in the background after initial render useEffect(() => { // Use setTimeout to defer the expensive loading to after first paint const timer = setTimeout(() => { - const moreChats = getAllChats( - LAYOUT.INITIAL_CHATS + LAYOUT.BACKGROUND_CHATS, - ) - // Only keep the chats beyond the initial set - setBackgroundChats(moreChats.slice(LAYOUT.INITIAL_CHATS)) + setChats(getAllChats(LAYOUT.INITIAL_CHATS + LAYOUT.BACKGROUND_CHATS)) }, 0) return () => clearTimeout(timer) }, []) - // Combine initial and background chats - const chats = useMemo( - () => [...initialChats, ...backgroundChats], - [initialChats, backgroundChats], - ) + const handleDeleteChat = useCallback((chatId: string) => { + const deleted = deleteChatSession(chatId) + if (deleted) { + setChats((prev) => prev.filter((chat) => chat.chatId !== chatId)) + setStatusMessage('Chat deleted') + return + } + + setStatusMessage('Could not delete chat') + }, []) // Calculate available width for the prompt text (last column, variable width) - // Format: "[time] [msgs] [prompt...]" + // Format: "[time] [msgs] [prompt...] [×]" + // reservedWidth accounts for: time col, msgs col, delete button area, + // 2 gaps between columns, list border (2), scrollbar (1), and button padding (2) const reservedWidth = - LAYOUT.TIME_COL_WIDTH + LAYOUT.MSGS_COL_WIDTH + LAYOUT.GAP_WIDTH * 2 + 2 // +2 for padding + LAYOUT.TIME_COL_WIDTH + + LAYOUT.MSGS_COL_WIDTH + + LAYOUT.DELETE_COL_WIDTH + + LAYOUT.GAP_WIDTH * 2 + + 5 // border + scrollbar + button padding const maxPromptWidth = Math.max(20, contentWidth - reservedWidth) // Truncate text to fit single line @@ -81,8 +91,10 @@ export const ChatHistoryScreen: React.FC = ({ // Pad text to fixed width (right-pad with spaces) const padRight = (text: string, width: number): string => { - if (text.length >= width) return text.slice(0, width) - return text + ' '.repeat(width - text.length) + // Use Array.from to count code points so emoji/wide chars don't break padding + const len = Array.from(text).length + if (len >= width) return text + return text + ' '.repeat(width - len) } // Convert chats to SelectableListItem format with aligned columns @@ -98,7 +110,10 @@ export const ChatHistoryScreen: React.FC = ({ `${chat.messageCount} msgs`, LAYOUT.MSGS_COL_WIDTH, ) - const prompt = truncateText(chat.lastPrompt, maxPromptWidth) + const prompt = padRight( + truncateText(chat.lastPrompt, maxPromptWidth), + maxPromptWidth, + ) return { id: chat.chatId, @@ -146,6 +161,13 @@ export const ChatHistoryScreen: React.FC = ({ [onSelectChat], ) + const handleChatDelete = useCallback( + (item: SelectableListItem) => { + handleDeleteChat(item.id) + }, + [handleDeleteChat], + ) + // Handle keyboard input const handleKeyIntercept = useCallback( (key: { name?: string; shift?: boolean; ctrl?: boolean }) => { @@ -275,9 +297,11 @@ export const ChatHistoryScreen: React.FC = ({ items={filteredItems.slice(0, LAYOUT.MAX_RENDERED_CHATS)} focusedIndex={focusedIndex} onSelect={handleChatSelect} + actionLabel="[×]" + onAction={handleChatDelete} onFocusChange={handleFocusChange} emptyMessage={ - initialChats.length === 0 + chats.length === 0 ? 'No chat history yet' : searchQuery ? 'No matching chats' @@ -314,8 +338,14 @@ export const ChatHistoryScreen: React.FC = ({ {/* Help text */} - ↑↓ navigate · Enter select · Esc cancel + ↑↓ navigate · Enter select · Click [×] to remove · Esc cancel + {statusMessage && ( + + {' · '} + {statusMessage} + + )} {/* Buttons - hidden on narrow screens */} diff --git a/cli/src/components/chat-input-bar.tsx b/cli/src/components/chat-input-bar.tsx index c6bac4cccf..cee0a296eb 100644 --- a/cli/src/components/chat-input-bar.tsx +++ b/cli/src/components/chat-input-bar.tsx @@ -12,10 +12,11 @@ import { useAskUserBridge } from '../hooks/use-ask-user-bridge' import { useEvent } from '../hooks/use-event' import { useChatStore } from '../state/chat-store' import { getInputModeConfig } from '../utils/input-modes' +import { isLinefeedActingAsEnter } from '../utils/terminal-enter-detection' import { BORDER_CHARS } from '../utils/ui-constants' import type { useTheme } from '../hooks/use-theme' -import type { InputValue } from '../state/chat-store' +import type { InputValue } from '../types/store' import type { AgentMode } from '../utils/constants' type Theme = ReturnType @@ -70,6 +71,7 @@ interface ChatInputBarProps { // Handlers handleSubmit: () => Promise onPaste: (fallbackText?: string) => void + onInterruptStream: () => void } export const ChatInputBar = ({ @@ -107,6 +109,7 @@ export const ChatInputBar = ({ handlePublish, handleSubmit, onPaste, + onInterruptStream, }: ChatInputBarProps) => { const inputMode = useChatStore((state) => state.inputMode) const setInputMode = useChatStore((state) => state.setInputMode) @@ -114,44 +117,53 @@ export const ChatInputBar = ({ const modeConfig = getInputModeConfig(inputMode) const askUserState = useChatStore((state) => state.askUserState) const hasAnyPreview = hasSuggestionMenu + + // Increase menu size on larger screen heights + const normalModeMaxVisible = terminalHeight > 35 ? 15 : 10 const { submitAnswers, skip } = useAskUserBridge() const [askUserTitle] = React.useState(' Some questions for you ') - // Shared key intercept handler for suggestion menu navigation + // Shared key intercept handler for suggestion menu navigation and history navigation const handleKeyIntercept = useEvent( (key: { name?: string + sequence?: string shift?: boolean ctrl?: boolean meta?: boolean option?: boolean }) => { - // Intercept navigation keys when suggestion menu is active - // The useChatKeyboard hook will handle menu selection/navigation - const hasSuggestions = hasSlashSuggestions || hasMentionSuggestions - if (!hasSuggestions) return false - const isPlainEnter = - (key.name === 'return' || key.name === 'enter') && + (key.name === 'return' || key.name === 'enter' || + (key.name === 'linefeed' && isLinefeedActingAsEnter())) && !key.shift && !key.ctrl && !key.meta && !key.option const isTab = key.name === 'tab' && !key.ctrl && !key.meta && !key.option - const isUpDown = - (key.name === 'up' || key.name === 'down') && - !key.ctrl && - !key.meta && - !key.option + const isUp = key.name === 'up' && !key.ctrl && !key.meta && !key.option + const isDown = key.name === 'down' && !key.ctrl && !key.meta && !key.option + const isUpDown = isUp || isDown - // Don't intercept Up/Down when user is navigating history - if (isUpDown && lastEditDueToNav) { - return false + const hasSuggestions = hasSlashSuggestions || hasMentionSuggestions + if (hasSuggestions) { + if (isUpDown && lastEditDueToNav) { + return true + } + if (isPlainEnter || isTab || isUpDown) { + return true + } } - if (isPlainEnter || isTab || isUpDown) { + const historyUpEnabled = lastEditDueToNav || cursorPosition === 0 + const historyDownEnabled = lastEditDueToNav || cursorPosition === inputValue.length + if (isUp && historyUpEnabled) { + return true + } + if (isDown && historyDownEnabled) { return true } + return false }, ) @@ -182,6 +194,16 @@ export const ChatInputBar = ({ return } + // Subscription limit mode: show only the limit banner (no input box) + if (inputMode === 'subscriptionLimit') { + return + } + + // ChatGPT connect mode: show only the connect panel (no input box) + if (inputMode === 'connect:chatgpt') { + return + } + // Handle input changes with special mode entry detection const handleInputChange = (value: InputValue) => { // Detect entering bash mode: user typed exactly '!' when in default mode @@ -265,6 +287,7 @@ export const ChatInputBar = ({ const handleFormSkip = () => { if (!askUserState) return skip() + onInterruptStream() } const effectivePlaceholder = @@ -325,6 +348,13 @@ export const ChatInputBar = ({ backgroundColor: theme.surface, }} > + {modeConfig.label && ( + + + {` ${modeConfig.label} `} + + + )} {modeConfig.icon && ( @@ -386,7 +416,7 @@ export const ChatInputBar = ({ @@ -408,6 +438,13 @@ export const ChatInputBar = ({ width: '100%', }} > + {modeConfig.label && ( + + + {` ${modeConfig.label} `} + + + )} {modeConfig.icon && ( { + const theme = useTheme() + const setInputMode = useChatStore((state) => state.setInputMode) + const [flowState, setFlowState] = useState('checking') + const [error, setError] = useState(null) + const [authUrl, setAuthUrl] = useState(null) + const [hovered, setHovered] = useState(false) + const [isCloseHovered, setIsCloseHovered] = useState(false) + + useEffect(() => { + const status = getChatGptOAuthStatus() + if (!status.connected) { + setFlowState('waiting-for-code') + const result = connectChatGptOAuth() + setAuthUrl(result.authUrl) + result.credentials + .then(() => { + setFlowState('connected') + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to connect') + setFlowState('error') + }) + } else { + setFlowState('connected') + } + + return () => { + stopChatGptOAuthServer() + } + }, []) + + const handleConnect = () => { + setFlowState('waiting-for-code') + const result = connectChatGptOAuth() + setAuthUrl(result.authUrl) + result.credentials + .then(() => { + setFlowState('connected') + }) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to connect') + setFlowState('error') + }) + } + + const handleDisconnect = () => { + disconnectChatGptOAuth() + setFlowState('not-connected') + } + + const panelStyle = { + width: '100%' as const, + borderStyle: 'single' as const, + borderColor: theme.border, + customBorderChars: BORDER_CHARS, + paddingLeft: 1, + paddingRight: 1, + } + + const actionButtonStyle = { + flexDirection: 'row' as const, + alignItems: 'center' as const, + paddingLeft: 1, + paddingRight: 1, + borderStyle: 'single' as const, + borderColor: hovered ? theme.foreground : theme.border, + customBorderChars: BORDER_CHARS, + } + + const handleClose = () => { + setInputMode('default') + } + + const closeButton = ( + + ) + + if (flowState === 'connected') { + return ( + + ✓ ChatGPT connected + + + {closeButton} + + + ) + } + + if (flowState === 'error') { + return ( + + + {error ?? 'Unknown error'} + + + + {closeButton} + + + ) + } + + if (flowState === 'waiting-for-code') { + return ( + + + Connecting to ChatGPT... + {closeButton} + + + Sign in via your browser to connect. + + {authUrl ? ( + + {authUrl} + + ) : null} + + ) + } + + if (flowState === 'not-connected') { + return ( + + + {closeButton} + + ) + } + + return null +} + +export async function handleChatGptAuthCode(code: string): Promise<{ + success: boolean + message: string +}> { + try { + await exchangeChatGptCodeForTokens(code) + stopChatGptOAuthServer() + return { + success: true, + message: + 'Successfully connected your ChatGPT subscription! Codebuff will use it for supported OpenAI streaming requests.', + } + } catch (err) { + return { + success: false, + message: + err instanceof Error + ? err.message + : 'Failed to exchange ChatGPT authorization code', + } + } +} diff --git a/cli/src/components/choice-ad-banner.tsx b/cli/src/components/choice-ad-banner.tsx new file mode 100644 index 0000000000..ccacbe53b5 --- /dev/null +++ b/cli/src/components/choice-ad-banner.tsx @@ -0,0 +1,181 @@ +import { TextAttributes } from '@opentui/core' +import { safeOpen } from '../utils/open-url' +import React, { useState, useMemo, useEffect } from 'react' + +import { Button } from './button' +import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' +import { useTheme } from '../hooks/use-theme' +import { BORDER_CHARS } from '../utils/ui-constants' + +import type { AdResponse } from '../hooks/use-gravity-ad' + +interface ChoiceAdBannerProps { + ads: AdResponse[] + onClick?: (ad: AdResponse) => void + onImpression?: (ad: AdResponse) => void +} + +export const CHOICE_AD_BANNER_HEIGHT = 5 // border-top + 2 lines description + spacer + cta row + border-bottom +const MAX_DESC_LINES = 2 +const MIN_CARD_WIDTH = 60 // Minimum width per ad card to remain readable + +function truncateToLines(text: string, lineWidth: number, maxLines: number): string { + if (lineWidth <= 0) return text + const maxChars = lineWidth * maxLines + if (text.length <= maxChars) return text + return text.slice(0, maxChars - 1) + '…' +} + +function truncateToWidth(text: string, width: number): string { + if (width <= 0) return '' + if (text.length <= width) return text + return text.slice(0, width - 1) + '…' +} + +export const extractDomain = (url: string): string => { + try { + const parsed = new URL(url) + return parsed.hostname.replace(/^www\./, '') + } catch { + return url + } +} + +export function getAdDisplayLabel( + ad: Pick, +): { text: string; variant: 'domain' | 'title' } { + const url = ad.url.trim() + if (url) { + return { text: extractDomain(url), variant: 'domain' } + } + + return { text: ad.title.trim() || 'Sponsored', variant: 'title' } +} + +/** + * Calculate evenly distributed column widths that sum exactly to availableWidth. + * Distributes remainder pixels across the first N columns so there's no gap. + */ +function columnWidths(count: number, availableWidth: number): number[] { + const base = Math.floor(availableWidth / count) + const remainder = availableWidth - base * count + return Array.from({ length: count }, (_, i) => base + (i < remainder ? 1 : 0)) +} + +export const ChoiceAdBanner: React.FC = ({ + ads, + onClick, + onImpression, +}) => { + const theme = useTheme() + const { terminalWidth } = useTerminalDimensions() + const [hoveredIndex, setHoveredIndex] = useState(null) + + // Available width for cards (terminal minus left/right margin of 1 each) + const colAvail = terminalWidth - 2 + + // Only show as many ads as fit with a healthy minimum width; hide the rest + const maxVisible = Math.max(1, Math.floor(colAvail / MIN_CARD_WIDTH)) + const visibleAds = useMemo( + () => (ads.length > maxVisible ? ads.slice(0, maxVisible) : ads), + [ads, maxVisible], + ) + + const widths = useMemo(() => columnWidths(visibleAds.length, colAvail), [visibleAds.length, colAvail]) + + // Fire impressions only for visible ads + useEffect(() => { + if (onImpression) { + for (const ad of visibleAds) { + onImpression(ad) + } + } + }, [visibleAds, onImpression]) + + const hoverBorderColor = theme.primary + + return ( + + {/* Card columns */} + + {visibleAds.map((ad, i) => { + const isHovered = hoveredIndex === i + const ctaText = ad.cta || ad.title || 'Learn more' + const label = getAdDisplayLabel(ad) + const labelMaxWidth = Math.max(0, widths[i] - ctaText.length - 5) + const labelText = truncateToWidth(label.text, labelMaxWidth) + + return ( + + ) + })} + + + + + ) +} diff --git a/cli/src/components/claude-connect-banner.tsx b/cli/src/components/claude-connect-banner.tsx deleted file mode 100644 index e1989b7104..0000000000 --- a/cli/src/components/claude-connect-banner.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import React, { useState, useEffect } from 'react' - -import { BottomBanner } from './bottom-banner' -import { Button } from './button' -import { useChatStore } from '../state/chat-store' -import { - openOAuthInBrowser, - exchangeCodeForTokens, - disconnectClaudeOAuth, - getClaudeOAuthStatus, -} from '../utils/claude-oauth' -import { useTheme } from '../hooks/use-theme' - -type FlowState = - | 'checking' - | 'not-connected' - | 'waiting-for-code' - | 'connected' - | 'error' - -export const ClaudeConnectBanner = () => { - const setInputMode = useChatStore((state) => state.setInputMode) - const theme = useTheme() - const [flowState, setFlowState] = useState('checking') - const [error, setError] = useState(null) - const [isDisconnectHovered, setIsDisconnectHovered] = useState(false) - const [isConnectHovered, setIsConnectHovered] = useState(false) - - // Check initial connection status and auto-open browser if not connected - useEffect(() => { - const status = getClaudeOAuthStatus() - if (status.connected) { - setFlowState('connected') - } else { - // Automatically start OAuth flow when not connected - setFlowState('waiting-for-code') - openOAuthInBrowser().catch((err) => { - setError(err instanceof Error ? err.message : 'Failed to open browser') - setFlowState('error') - }) - } - }, []) - - const handleConnect = async () => { - try { - setFlowState('waiting-for-code') - await openOAuthInBrowser() - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to open browser') - setFlowState('error') - } - } - - const handleDisconnect = () => { - disconnectClaudeOAuth() - setFlowState('not-connected') - } - - const handleClose = () => { - setInputMode('default') - } - - // Connected state - if (flowState === 'connected') { - const status = getClaudeOAuthStatus() - const connectedDate = status.connectedAt - ? new Date(status.connectedAt).toLocaleDateString() - : 'Unknown' - - return ( - - - ✓ Connected to Claude - - Since {connectedDate} - · - - - - - ) - } - - // Error state - if (flowState === 'error') { - return ( - - ) - } - - // Waiting for code state - if (flowState === 'waiting-for-code') { - return ( - - - Waiting for authorization - - Sign in with your Claude account in the browser, then paste the code - here. - - - - ) - } - - // Not connected / checking state - show connect button - return ( - - - Connect to Claude - - Use your Pro/Max subscription - · - - - - - ) -} - -/** - * Handle the authorization code input from the user. - * This is called when the user pastes their code in connect:claude mode. - */ -export async function handleClaudeAuthCode(code: string): Promise<{ - success: boolean - message: string -}> { - try { - await exchangeCodeForTokens(code) - return { - success: true, - message: - 'Successfully connected your Claude subscription! Codebuff will now use it for Claude model requests.', - } - } catch (err) { - return { - success: false, - message: - err instanceof Error - ? err.message - : 'Failed to exchange authorization code', - } - } -} diff --git a/cli/src/components/clickable.tsx b/cli/src/components/clickable.tsx index 1899c73a36..b9f4bbb516 100644 --- a/cli/src/components/clickable.tsx +++ b/cli/src/components/clickable.tsx @@ -1,4 +1,5 @@ import React, { cloneElement, isValidElement, memo } from 'react' + import type { ReactElement, ReactNode } from 'react' /** @@ -27,18 +28,18 @@ export function makeTextUnselectable(node: ReactNode): ReactNode { if (!isValidElement(node)) return node - const el = node as ReactElement + const el = node as ReactElement<{ children?: ReactNode; [key: string]: unknown }> const type = el.type // Ensure text and span nodes are not selectable if (typeof type === 'string' && (type === 'text' || type === 'span')) { const nextProps = { ...el.props, selectable: false } - const nextChildren = el.props?.children ? makeTextUnselectable(el.props.children) : el.props?.children + const nextChildren = el.props.children ? makeTextUnselectable(el.props.children) : el.props.children return cloneElement(el, nextProps, nextChildren) } // Recurse into other host elements and components' children - const nextChildren = el.props?.children ? makeTextUnselectable(el.props.children) : el.props?.children + const nextChildren = el.props.children ? makeTextUnselectable(el.props.children) : el.props.children return cloneElement(el, el.props, nextChildren) } diff --git a/cli/src/components/error-boundary.tsx b/cli/src/components/error-boundary.tsx new file mode 100644 index 0000000000..7495db4740 --- /dev/null +++ b/cli/src/components/error-boundary.tsx @@ -0,0 +1,55 @@ +import { memo, type ReactNode } from 'react' + +interface ErrorBoundaryPlaceholderProps { + children: ReactNode + fallback: ReactNode + componentName?: string +} + +/** + * **WARNING: This component does NOT catch render errors.** + * + * This is a placeholder/passthrough component that exists for structural purposes. + * OpenTUI's JSX types don't support React class components, which are required + * for true error boundary functionality. + * + * For actual error catching in render functions, use `withErrorFallback()` instead. + * + * @example + * // Use withErrorFallback for catching render errors: + * const safeContent = withErrorFallback( + * () => riskyRenderFunction(), + * , + * 'MyComponent' + * ) + */ +export const ErrorBoundaryPlaceholder = memo( + ({ children }: ErrorBoundaryPlaceholderProps) => { + // This component does NOT catch errors - it's a passthrough. + // Use withErrorFallback() for actual error catching. + return <>{children} + }, +) + +/** + * @deprecated Use `ErrorBoundaryPlaceholder` instead. This alias exists for backward + * compatibility but the name is misleading since it doesn't actually catch errors. + */ +export const ErrorBoundary = ErrorBoundaryPlaceholder + +/** + * Helper to safely render content with error handling. + * Use this when you need to catch render errors in a functional context. + */ +export function withErrorFallback( + renderFn: () => T, + fallback: T, + componentName?: string, +): T { + try { + return renderFn() + } catch (error) { + console.error(`[${componentName ?? 'withErrorFallback'}] Error caught:`, error) + return fallback + } +} diff --git a/cli/src/components/feedback-container.tsx b/cli/src/components/feedback-container.tsx index 6c0fa01b66..29fd47613e 100644 --- a/cli/src/components/feedback-container.tsx +++ b/cli/src/components/feedback-container.tsx @@ -1,4 +1,3 @@ -import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import React, { useCallback, useEffect } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -6,10 +5,11 @@ import { FeedbackInputMode } from './feedback-input-mode' import { useChatStore } from '../state/chat-store' import { useFeedbackStore } from '../state/feedback-store' import { showClipboardMessage } from '../utils/clipboard' +import { getApiClient } from '../utils/codebuff-api' +import { buildFeedbackPayload, buildMessageContext } from '../utils/feedback-helpers' +import { resolveFeedbackSubmission } from '../utils/feedback-submission' import { logger } from '../utils/logger' -import type { ChatMessage } from '../types/chat' - interface FeedbackContainerProps { inputRef: React.MutableRefObject onExitFeedback?: () => void @@ -28,13 +28,11 @@ export const FeedbackContainer: React.FC = ({ feedbackCategory, feedbackMessageId, feedbackFooterMessage, + isSubmitting, errors, setFeedbackText, setFeedbackCursor, setFeedbackCategory, - closeFeedback, - resetFeedbackForm, - markMessageFeedbackSubmitted, } = useFeedbackStore( useShallow((state) => ({ feedbackMode: state.feedbackMode, @@ -43,113 +41,116 @@ export const FeedbackContainer: React.FC = ({ feedbackCategory: state.feedbackCategory, feedbackMessageId: state.feedbackMessageId, feedbackFooterMessage: state.feedbackFooterMessage, + isSubmitting: state.isSubmitting, errors: state.errors, setFeedbackText: state.setFeedbackText, setFeedbackCursor: state.setFeedbackCursor, setFeedbackCategory: state.setFeedbackCategory, - closeFeedback: state.closeFeedback, - resetFeedbackForm: state.resetFeedbackForm, - markMessageFeedbackSubmitted: state.markMessageFeedbackSubmitted, })), ) - const { messages, agentMode, sessionCreditsUsed, runState } = useChatStore( + const { messages, agentMode, sessionCreditsUsed } = useChatStore( useShallow((state) => ({ messages: state.messages, agentMode: state.agentMode, sessionCreditsUsed: state.sessionCreditsUsed, - runState: state.runState, })), ) - const buildMessageContext = useCallback( - (targetMessageId: string | null) => { - const target = targetMessageId - ? messages.find((m: ChatMessage) => m.id === targetMessageId) - : null - - const targetIndex = target - ? messages.indexOf(target) - : messages.length - 1 - const startIndex = Math.max(0, targetIndex - 9) - const recentMessages = messages - .slice(startIndex, targetIndex + 1) - .map((m: ChatMessage) => ({ - type: m.variant, - id: m.id, - ...(m.completionTime && { completionTime: m.completionTime }), - ...(m.credits && { credits: m.credits }), - })) - - return { target, recentMessages } - }, - [messages], - ) - const handleFeedbackSubmit = useCallback(() => { + const store = useFeedbackStore.getState() + if (store.isSubmitting) return + + const { clientFeedbackId } = store + if (!clientFeedbackId) return + const text = feedbackText.trim() if (!text) { return } - const { target, recentMessages } = buildMessageContext(feedbackMessageId) - - logger.info( - { - eventId: AnalyticsEvent.FEEDBACK_SUBMITTED, - source: 'cli', - messageId: target?.id || null, - variant: target?.variant || null, - completionTime: target?.completionTime || null, - credits: target?.credits || null, - agentMode, - sessionCreditsUsed, - recentMessages, - feedback: { - text, - category: feedbackCategory, - type: feedbackMessageId ? 'message' : 'general', - errors, - }, - runState, - }, - 'User submitted feedback', - ) - - if (feedbackMessageId) { - markMessageFeedbackSubmitted(feedbackMessageId, feedbackCategory) - } - - resetFeedbackForm() - closeFeedback() - showClipboardMessage('Thanks, your feedback helps! 💖', { - durationMs: 5000, + store.setIsSubmitting(true) + + const { target, recentMessages } = buildMessageContext(messages, feedbackMessageId) + const payload = buildFeedbackPayload({ + text, + feedbackCategory, + feedbackMessageId, + target, + recentMessages, + agentMode, + sessionCreditsUsed, + errors, + clientFeedbackId, }) - if (onExitFeedback) { - onExitFeedback() - } + const submittedMessageId = feedbackMessageId + const submittedCategory = feedbackCategory + const submittedClientFeedbackId = clientFeedbackId + + getApiClient() + .feedback(payload) + .then((response) => { + const store = useFeedbackStore.getState() + const { isCurrentSubmission, shouldSettleSubmission } = resolveFeedbackSubmission( + store.clientFeedbackId, + submittedClientFeedbackId, + ) + + if (!response.ok) { + logger.warn( + { status: response.status, error: response.error }, + 'Feedback API returned error', + ) + if (!shouldSettleSubmission) return + store.setIsSubmitting(false) + showClipboardMessage('Feedback failed to send', { durationMs: 5000 }) + return + } + + if (submittedMessageId) { + store.markMessageFeedbackSubmitted(submittedMessageId, submittedCategory) + } + + if (isCurrentSubmission) { + store.resetFeedbackForm() + store.closeFeedback() + store.setIsSubmitting(false) + if (onExitFeedback) onExitFeedback() + } else if (shouldSettleSubmission) { + store.setIsSubmitting(false) + } + + if (shouldSettleSubmission) { + showClipboardMessage('Feedback sent!', { durationMs: 5000 }) + } + }) + .catch((error: unknown) => { + logger.warn({ error }, 'Failed to submit feedback to API') + const store = useFeedbackStore.getState() + if (!resolveFeedbackSubmission(store.clientFeedbackId, submittedClientFeedbackId).shouldSettleSubmission) { + return + } + store.setIsSubmitting(false) + showClipboardMessage('Feedback failed to send', { durationMs: 5000 }) + }) }, [ feedbackText, feedbackMessageId, feedbackCategory, errors, - buildMessageContext, + messages, agentMode, sessionCreditsUsed, - runState, - markMessageFeedbackSubmitted, - resetFeedbackForm, - closeFeedback, onExitFeedback, ]) const handleFeedbackCancel = useCallback(() => { - closeFeedback() + useFeedbackStore.getState().closeFeedback() if (onExitFeedback) { onExitFeedback() } - }, [closeFeedback, onExitFeedback]) + }, [onExitFeedback]) useEffect(() => { if (feedbackMode && inputRef.current) { @@ -174,6 +175,7 @@ export const FeedbackContainer: React.FC = ({ inputRef={inputRef} width={width} footerMessage={feedbackFooterMessage} + isSubmitting={isSubmitting} /> ) } diff --git a/cli/src/components/feedback-input-mode.tsx b/cli/src/components/feedback-input-mode.tsx index ed9debc9e7..48b709589f 100644 --- a/cli/src/components/feedback-input-mode.tsx +++ b/cli/src/components/feedback-input-mode.tsx @@ -6,20 +6,23 @@ import { MultilineInput, type MultilineInputHandle } from './multiline-input' import { Separator } from './separator' import { useTheme } from '../hooks/use-theme' import { useChatStore } from '../state/chat-store' -import { BORDER_CHARS } from '../utils/ui-constants' +import { IS_FREEBUFF } from '../utils/constants' import { createTextPasteHandler } from '../utils/strings' +import { BORDER_CHARS } from '../utils/ui-constants' + +import type { FeedbackCategory } from '@codebuff/common/constants/feedback' type CategoryHighlightKey = 'success' | 'error' | 'warning' | 'info' type CategoryOption = { - id: 'good_result' | 'bad_result' | 'app_bug' | 'other' + id: FeedbackCategory label: string shortLabel: string highlightKey: CategoryHighlightKey placeholder: string } -const CATEGORY_OPTIONS: readonly CategoryOption[] = [ +const CATEGORY_OPTIONS = [ { id: 'good_result', label: 'Good result', @@ -41,8 +44,9 @@ const CATEGORY_OPTIONS: readonly CategoryOption[] = [ label: 'App bug', shortLabel: 'Bug', highlightKey: 'warning', - placeholder: - 'Report a problem with Codebuff (crashes, errors, UI issues, etc.)', + placeholder: IS_FREEBUFF + ? 'Report a problem with Freebuff (crashes, errors, UI issues, etc.)' + : 'Report a problem with Codebuff (crashes, errors, UI issues, etc.)', }, { id: 'other', @@ -51,7 +55,15 @@ const CATEGORY_OPTIONS: readonly CategoryOption[] = [ highlightKey: 'info', placeholder: 'Tell us more (what happened, what you expected)...', }, -] as const +] as const satisfies readonly CategoryOption[] + +// Compile-time exhaustiveness: ensures every FeedbackCategory has a CATEGORY_OPTIONS entry. +// If a new category is added to FEEDBACK_CATEGORIES, TypeScript will error here until +// a corresponding entry is added to CATEGORY_OPTIONS above. +type CoveredCategories = (typeof CATEGORY_OPTIONS)[number]['id'] +type _AssertAllCategoriesCovered = [FeedbackCategory] extends [CoveredCategories] ? true : never +const _exhaustiveCheck: _AssertAllCategoriesCovered = true +void _exhaustiveCheck const FEEDBACK_CONTAINER_HORIZONTAL_INSET = 4 // border + padding on each side const CATEGORY_BUTTON_EXTRA_WIDTH = 6 // indicator + padding + border @@ -77,6 +89,7 @@ interface FeedbackTextSectionProps { placeholder: string inputRef?: React.MutableRefObject width: number + isSubmitting?: boolean } const FeedbackTextSection: React.FC = ({ @@ -88,6 +101,7 @@ const FeedbackTextSection: React.FC = ({ placeholder, inputRef, width, + isSubmitting = false, }) => { const inputFocused = useChatStore((state) => state.inputFocused) @@ -119,7 +133,7 @@ const FeedbackTextSection: React.FC = ({ onCursorChange(cursorPosition) })} placeholder={placeholder} - focused={inputFocused} + focused={inputFocused && !isSubmitting} maxHeight={5} minHeight={3} ref={inputRef} @@ -136,15 +150,16 @@ const FeedbackTextSection: React.FC = ({ interface FeedbackInputModeProps { value: string cursor: number - feedbackCategory: string + feedbackCategory: FeedbackCategory onChange: (text: string) => void onCursorChange: (cursor: number) => void - onCategoryChange: (category: string) => void + onCategoryChange: (category: FeedbackCategory) => void onSubmit: () => void onCancel: () => void inputRef?: React.MutableRefObject width: number footerMessage?: string | null + isSubmitting?: boolean } export const FeedbackInputMode: React.FC = ({ @@ -159,11 +174,12 @@ export const FeedbackInputMode: React.FC = ({ inputRef: externalInputRef, width, footerMessage, + isSubmitting = false, }) => { const theme = useTheme() const internalInputRef = useRef(null) const inputRef = externalInputRef || internalInputRef - const canSubmit = value.trim().length > 0 + const canSubmit = value.trim().length > 0 && !isSubmitting const [closeButtonHovered, setCloseButtonHovered] = useState(false) const availableWidth = Math.max( 0, @@ -265,16 +281,19 @@ export const FeedbackInputMode: React.FC = ({ {} : onChange} + onCursorChange={isSubmitting ? () => {} : onCursorChange} onSubmit={onSubmit} placeholder={ - CATEGORY_OPTIONS.find((opt) => opt.id === feedbackCategory) - ?.placeholder || - 'Tell us more (what happened, what you expected)...' + isSubmitting + ? 'Sending feedback...' + : CATEGORY_OPTIONS.find((opt) => opt.id === feedbackCategory) + ?.placeholder || + 'Tell us more (what happened, what you expected)...' } inputRef={inputRef} width={width} + isSubmitting={isSubmitting} /> {/* Footer with auto-attached info and submit button */} @@ -314,7 +333,9 @@ export const FeedbackInputMode: React.FC = ({ canSubmit ? undefined : TextAttributes.DIM | TextAttributes.ITALIC } > - SUBMIT + + {isSubmitting ? 'SENDING...' : 'SUBMIT'} + 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/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx new file mode 100644 index 0000000000..63560c5082 --- /dev/null +++ b/cli/src/components/freebuff-model-selector.tsx @@ -0,0 +1,500 @@ +import { TextAttributes } from '@opentui/core' +import { useKeyboard } from '@opentui/react' +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +import { Button } from './button' +import { + FALLBACK_FREEBUFF_MODEL_ID, + getFreebuffDeploymentAvailabilityLabel, + getFreebuffModelsForAccessTier, + isFreebuffModelAvailable, + isFreebuffPremiumModelId, +} from '@codebuff/common/constants/freebuff-models' +import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' + +import { joinFreebuffQueue } from '../hooks/use-freebuff-session' +import { useNow } from '../hooks/use-now' +import { useFreebuffModelStore } from '../state/freebuff-model-store' +import { useFreebuffSessionStore } from '../state/freebuff-session-store' +import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' +import { useTheme } from '../hooks/use-theme' +import { + freebuffModelNavigationDirectionForKey, + nextFreebuffModelId, +} from '../utils/freebuff-model-navigation' + +import type { FreebuffModelOption } from '@codebuff/common/constants/freebuff-models' +import type { KeyEvent, ScrollBoxRenderable } from '@opentui/core' + +// Section grouping: premium models share one quota pool, unlimited has none. +// Putting the tier on a section header lets each row drop its redundant +// "Premium"/"Unlimited" chip. The shared 0/5 counter lives in the page title +// (rendered by the parent), not the section header — this picker is purely a +// list of choices grouped by tier. Empty sections are filtered so a model set +// with no premium (or no unlimited) entries doesn't render an orphan header. +// +// `label` may be empty: limited-tier users only ever see one section, so the +// "LIMITED" header would just leak the internal tier name without organizing +// anything. Renderer treats an empty label as "no header row". +type Section = { + key: 'premium' | 'unlimited' | 'limited' + label: string + models: readonly FreebuffModelOption[] +} + +/** + * Dual-purpose model picker: + * - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking + * a model is their explicit commitment to enter — this triggers the POST. + * - In-queue switcher (session 'queued'): picking a *different* model moves + * the user to the back of that queue (lose place in original). Picking the + * model they're already in is a no-op. + * + * Keyboard navigation: Tab / arrow keys move the green highlight; Enter (or + * Space) commits the focused row. Mouse click commits in one step. + * + * Layout: rows are grouped into PREMIUM / UNLIMITED sections so the tier is + * visible without a per-row chip; the shared 0/5 counter sits inside the + * PREMIUM section header. Names align in a column so taglines line up across + * rows. On narrow terminals the secondary details (warning / deployment + * hours) drop onto an indented second line under the row. + * + * On short terminals the parent passes `maxHeight`: the row list then lives + * in a scrollbox capped at that many rows, a scrollbar appears when the + * models don't all fit, and Tab/arrow navigation keeps the focused row + * scrolled into view. + */ +interface FreebuffModelSelectorProps { + /** Max vertical rows the picker may occupy. When the rendered rows exceed + * this, the list scrolls (scrollbar shown, focused row kept in view); + * otherwise the scrollbox shrinks to fit and no scrollbar appears. */ + maxHeight: number +} + +export const FreebuffModelSelector: React.FC = ({ + maxHeight, +}) => { + const theme = useTheme() + // contentMaxWidth (not terminalWidth) is the real budget — the parent + // waiting-room screen wraps this picker in a `maxWidth: contentMaxWidth` + // box (capped at 80 cols), so a wide terminal doesn't actually let us + // sprawl the buttons across it. + const { contentMaxWidth } = useTerminalDimensions() + const selectedModel = useFreebuffModelStore((s) => s.selectedModel) + const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel) + const session = useFreebuffSessionStore((s) => s.session) + const accessTier = + session && 'accessTier' in session ? session.accessTier : 'full' + const now = useNow(60_000) + const deploymentAvailabilityLabel = useMemo( + () => getFreebuffDeploymentAvailabilityLabel(new Date(now)), + [now], + ) + const [pending, setPending] = useState(null) + const [hoveredId, setHoveredId] = useState(null) + // Keyboard cursor — separate from the actually-selected model so that + // Tab/arrow navigation can preview without committing. Re-syncs to the + // selected model whenever the selection changes (after a successful switch + // or an external selectedModel update). + const [focusedId, setFocusedId] = useState(selectedModel) + const availableModels = useMemo( + () => getFreebuffModelsForAccessTier(accessTier), + [accessTier], + ) + // Limited tier only ever surfaces one model, so a comparative tagline + // ("Most efficient") reads as filler. Hide it; the warning (data-collection) + // is the row's real content. + const showTagline = accessTier !== 'limited' + const availableModelIds = useMemo( + () => availableModels.map((m) => m.id), + [availableModels], + ) + const sections = useMemo(() => { + if (accessTier === 'limited') { + return [ + { + key: 'limited', + label: '', + models: availableModels, + }, + ] satisfies readonly Section[] + } + return ( + [ + { + key: 'premium', + label: 'PREMIUM', + models: availableModels.filter((m) => isFreebuffPremiumModelId(m.id)), + }, + { + key: 'unlimited', + label: 'UNLIMITED', + models: availableModels.filter( + (m) => !isFreebuffPremiumModelId(m.id), + ), + }, + ] satisfies readonly Section[] + ).filter((section) => section.models.length > 0) + }, [accessTier, availableModels]) + useEffect(() => { + setFocusedId( + availableModelIds.includes(selectedModel) + ? selectedModel + : availableModelIds[0]!, + ) + }, [availableModelIds, selectedModel]) + + useEffect(() => { + // Landing-screen safety net: if the in-memory selection becomes + // unavailable (e.g. deployment hours close while the picker is open), + // swap to the always-available fallback so Enter doesn't POST a model + // the server will immediately reject. In-memory only — the user's saved + // preference (e.g. Kimi or DeepSeek) is preserved for the next launch. + if ( + (session?.status === 'none' || !session) && + (!availableModelIds.includes(selectedModel) || + !isFreebuffModelAvailable(selectedModel, new Date(now))) + ) { + setSelectedModel(availableModelIds[0] ?? FALLBACK_FREEBUFF_MODEL_ID) + } + }, [availableModelIds, now, selectedModel, session, setSelectedModel]) + + const committedModelId = session?.status === 'queued' ? session.model : null + const rateLimitsByModel = getRateLimitsByModel(session) + + const BUTTON_CHROME = 4 // 2 border + 2 padding + const NAME_GAP = 2 // spaces between name column and details column + + // Two-column layout: a fixed name column (padded to the longest displayName + // across all rows) followed by a details column (tagline · warning · + // deployment-hours/closed). Falls back to single-column mode on narrow + // terminals where the secondary details spill to an indented second line. + const { wrapDetails, buttonOuterWidth, nameColumnWidth } = useMemo(() => { + const nameLen = (m: FreebuffModelOption) => m.displayName.length + const maxNameLen = Math.max(...availableModels.map(nameLen)) + + const detailsParts = (model: FreebuffModelOption): number[] => { + const parts: number[] = [] + if (showTagline) parts.push(model.tagline.length) + if (model.warning) parts.push(model.warning.length) + if (model.availability === 'deployment_hours') { + parts.push(deploymentAvailabilityLabel.length) + } + return parts + } + + const joinedLen = (parts: number[]): number => + parts.reduce((a, b) => a + b, 0) + Math.max(0, parts.length - 1) * 3 // " · " + + const oneLineLen = (model: FreebuffModelOption): number => + 2 /* indicator + space */ + + maxNameLen + + NAME_GAP + + joinedLen(detailsParts(model)) + + const maxOneLineOuter = + Math.max(...availableModels.map(oneLineLen)) + BUTTON_CHROME + if (maxOneLineOuter <= contentMaxWidth) { + return { + wrapDetails: false, + buttonOuterWidth: maxOneLineOuter, + nameColumnWidth: maxNameLen, + } + } + + // Narrow: line 1 = "indicator name · tagline", line 2 (if any) = + // " warning · hours". Compute the max of both so all buttons stay the + // same width. When taglines are hidden (limited tier), line 1 is just + // "indicator name" with no separator. + const labelLineLen = (m: FreebuffModelOption) => + 2 + m.displayName.length + (showTagline ? 3 + m.tagline.length : 0) + const detailsLineLen = (m: FreebuffModelOption) => { + const parts: number[] = [] + if (m.warning) parts.push(m.warning.length) + if (m.availability === 'deployment_hours') { + parts.push(deploymentAvailabilityLabel.length) + } + return parts.length === 0 ? 0 : 2 /* indent */ + joinedLen(parts) + } + const maxTwoLineInner = Math.max( + ...availableModels.map((m) => + Math.max(labelLineLen(m), detailsLineLen(m)), + ), + ) + return { + wrapDetails: true, + buttonOuterWidth: Math.min( + maxTwoLineInner + BUTTON_CHROME, + contentMaxWidth, + ), + nameColumnWidth: maxNameLen, + } + }, [availableModels, contentMaxWidth, deploymentAvailabilityLabel, showTagline]) + + // Flattened vertical layout: every model's top offset + height within the + // scroll content, plus the total. Mirrors the JSX below exactly so the + // auto-scroll math lands the focused row precisely. A button is 2 border + // rows + its text line(s); in wrapDetails mode a row with a warning or + // deployment-hours label spills its details onto a second indented line. + // Headers add 1 row; sections after the first add 1 row of marginTop. + const SECTION_GAP = 1 + const { totalHeight, offsetById } = useMemo(() => { + const offsets: Record = {} + let y = 0 + sections.forEach((section, idx) => { + if (idx > 0) y += SECTION_GAP + if (section.label) y += 1 + section.models.forEach((m) => { + const wraps = + wrapDetails && (!!m.warning || m.availability === 'deployment_hours') + const h = 2 /* borders */ + (wraps ? 2 : 1) + offsets[m.id] = { top: y, height: h } + y += h + }) + }) + return { totalHeight: y, offsetById: offsets } + }, [sections, wrapDetails]) + + const needsScroll = totalHeight > maxHeight + const scrollViewportHeight = Math.max(1, Math.min(totalHeight, maxHeight)) + const scrollRef = useRef(null) + + // Keep the keyboard-focused row inside the viewport as the user Tabs/arrows + // through a list taller than the available rows. + useEffect(() => { + const sb = scrollRef.current + if (!sb || !needsScroll) return + const entry = offsetById[focusedId] + if (!entry) return + const viewportHeight = sb.viewport.height + const currentScroll = sb.scrollTop + if (entry.top < currentScroll) { + sb.scrollTop = entry.top + } else if (entry.top + entry.height > currentScroll + viewportHeight) { + sb.scrollTop = entry.top + entry.height - viewportHeight + } + }, [focusedId, offsetById, needsScroll]) + + const isJoinable = useCallback( + (modelId: string) => { + if (!isFreebuffModelAvailable(modelId, new Date(now))) return false + const rateLimit = rateLimitsByModel?.[modelId] + return !rateLimit || rateLimit.recentCount < rateLimit.limit + }, + [now, rateLimitsByModel], + ) + + const pick = useCallback( + (modelId: string) => { + if (pending) return + if (modelId === committedModelId) return + if (!isJoinable(modelId)) return + setPending(modelId) + joinFreebuffQueue(modelId).finally(() => setPending(null)) + }, + [pending, committedModelId, isJoinable], + ) + + // Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or + // Space commits the focused row. Two-step navigation lets the user preview + // the highlight before committing. + useKeyboard( + useCallback( + (key: KeyEvent) => { + if (pending) return + const name = key.name ?? '' + const direction = freebuffModelNavigationDirectionForKey(key) + const isCommit = + name === 'return' || name === 'enter' || name === 'space' + if (isCommit) { + if (isJoinable(focusedId) && focusedId !== committedModelId) { + key.preventDefault?.() + key.stopPropagation?.() + pick(focusedId) + } + return + } + if (!direction) return + const targetId = nextFreebuffModelId({ + modelIds: availableModelIds, + focusedId, + direction, + }) + if (targetId) { + key.preventDefault?.() + key.stopPropagation?.() + setFocusedId(targetId) + } + }, + [ + pending, + pick, + focusedId, + committedModelId, + isJoinable, + availableModelIds, + ], + ), + ) + + const renderModelButton = (model: FreebuffModelOption) => { + // Single visual state: the focused row IS the highlight. The user's + // saved/committed pick is not shown separately — it just sets where + // focus lands when the picker opens. Pressing Enter on the focused + // row commits it. + const isHovered = hoveredId === model.id + const isFocused = focusedId === model.id + const canJoin = isJoinable(model.id) + // Clickable whenever picking would actually do something — i.e. + // anything except re-picking the queue we're already in. + const interactable = !pending && canJoin && model.id !== committedModelId + + // Focused row: green border + arrow indicator + bold name. The name + // itself stays the normal foreground color so it doesn't shout — the + // border and arrow do the highlighting. Off-focus rows are default. + const indicator = isFocused ? '›' : ' ' + const fgColor = canJoin ? theme.foreground : theme.muted + const mutedColor = theme.muted + const warningColor = theme.secondary + + const borderColor = isFocused + ? theme.primary + : isHovered + ? theme.foreground + : theme.border + + // Deployment-hours rows show "until 5pm PT" while open and "opens 9am ET" + // while closed (the label flips inside getFreebuffDeploymentAvailabilityLabel), + // so the same string carries both the in-hours and out-of-hours signals + // without a separate "Closed" chip. Greyed-out fgColor handles the rest. + const hasHours = model.availability === 'deployment_hours' + const hasWarning = !!model.warning + + // Spaces inside s render verbatim, so we hand-pad the name to align + // taglines into a column. nameColumnWidth is the longest name across all + // rows, so the diff is >= 0; +NAME_GAP guarantees breathing room even on + // the widest row. + const namePadding = ' '.repeat( + nameColumnWidth - model.displayName.length + NAME_GAP, + ) + + return ( + + ) + } + + const sectionsContent = sections.map((section, sectionIdx) => ( + + {section.label && ( + {section.label} + )} + {section.models.map(renderModelButton)} + + )) + + // Scrollbox clamped to the rows the parent can spare. When everything fits + // it shrinks to the content height and no scrollbar shows, so tall + // terminals look exactly like a plain column. + return ( + + {sectionsContent} + + ) +} diff --git a/cli/src/components/freebuff-superseded-screen.tsx b/cli/src/components/freebuff-superseded-screen.tsx new file mode 100644 index 0000000000..c10c22a884 --- /dev/null +++ b/cli/src/components/freebuff-superseded-screen.tsx @@ -0,0 +1,62 @@ +import { TextAttributes } from '@opentui/core' +import React from 'react' + +import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit' +import { useLogo } from '../hooks/use-logo' +import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' +import { useTheme } from '../hooks/use-theme' +import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system' + +/** + * Terminal state shown after a 409 session_superseded response. Another CLI on + * the same account rotated our instance id and we've stopped polling — the + * user needs to close the other instance and restart. + */ +export const FreebuffSupersededScreen: React.FC = () => { + const theme = useTheme() + const { contentMaxWidth } = useTerminalDimensions() + const blockColor = getLogoBlockColor(theme.name) + const accentColor = getLogoAccentColor(theme.name) + const { component: logoComponent } = useLogo({ + availableWidth: contentMaxWidth, + accentColor, + blockColor, + }) + + useFreebuffCtrlCExit() + + return ( + + {logoComponent} + + Another freebuff instance took over this account. + + + Only one CLI per account can be active at a time. + + + Close the other instance, then restart freebuff here. + + + + Press Ctrl+C to exit. + + + + ) +} diff --git a/cli/src/components/grid-layout.tsx b/cli/src/components/grid-layout.tsx new file mode 100644 index 0000000000..606b115b69 --- /dev/null +++ b/cli/src/components/grid-layout.tsx @@ -0,0 +1,80 @@ +import React, { memo, type ReactNode } from 'react' + +import { useGridLayout } from '../hooks/use-grid-layout' +import { MIN_COLUMN_WIDTH } from '../utils/layout-helpers' + +export interface GridLayoutProps { + items: T[] + availableWidth: number + getItemKey: (item: T) => string + renderItem: (item: T, index: number, columnWidth: number) => ReactNode + footer?: ReactNode + marginTop?: number +} + +function GridLayoutInner({ + items, + availableWidth, + getItemKey, + renderItem, + footer, + marginTop = 0, +}: GridLayoutProps): ReactNode { + const { columns, columnWidth, columnGroups } = useGridLayout(items, availableWidth) + + if (items.length === 0) return null + + // Unified structure for both single and multi-column layouts + // Using a consistent DOM structure prevents reconciliation issues during resize transitions + const isMultiColumn = columns > 1 + + return ( + + + {columnGroups.map((columnItems, colIdx) => { + const columnKey = columnItems[0] + ? getItemKey(columnItems[0]) + : `col-${colIdx}` + return ( + + {columnItems.map((item, idx) => ( + + {renderItem(item, idx, columnWidth)} + + ))} + + ) + })} + + {footer} + + ) +} + +export const GridLayout = memo(GridLayoutInner) as typeof GridLayoutInner diff --git a/cli/src/components/help-banner.tsx b/cli/src/components/help-banner.tsx index eb1b1fdb8a..ccf39bdf82 100644 --- a/cli/src/components/help-banner.tsx +++ b/cli/src/components/help-banner.tsx @@ -1,13 +1,44 @@ import React from 'react' import { BottomBanner } from './bottom-banner' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' +import { useTheme } from '../hooks/use-theme' +import { IS_FREEBUFF } from '../utils/constants' import { useChatStore } from '../state/chat-store' +import { getChatGptOAuthStatus } from '../utils/chatgpt-oauth' -const HELP_TIMEOUT = 30 * 1000 // 30 seconds +const HELP_TIMEOUT = 60 * 1000 // 60 seconds -/** Help banner showing keyboard shortcuts and tips. */ +/** Section header component for consistent styling */ +const SectionHeader = ({ children }: { children: React.ReactNode }) => { + const theme = useTheme() + return {children} +} + +/** Keyboard shortcut item */ +const Shortcut = ({ + keys, + action, +}: { + keys: string + action: string +}) => { + const theme = useTheme() + return ( + + {keys} + {action} + + ) +} + +/** Help banner showing keyboard shortcuts and tips in an organized layout. */ export const HelpBanner = () => { const setInputMode = useChatStore((state) => state.setInputMode) + const theme = useTheme() + const { data: subscriptionData } = useSubscriptionQuery() + const hasSubscription = subscriptionData?.hasSubscription ?? false + const chatGptOAuth = getChatGptOAuthStatus() // Auto-hide after timeout React.useEffect(() => { @@ -20,8 +51,79 @@ export const HelpBanner = () => { return ( setInputMode('default')} - /> + > + + {/* Shortcuts Section */} + + Shortcuts + + + + + + + + + {/* Features Section */} + + Features + + + + + + + + + {/* Tips Section */} + + Tips + + {IS_FREEBUFF && !chatGptOAuth.connected && ( + + Connect via /connect to unlock /plan & /review + + )} + {IS_FREEBUFF && chatGptOAuth.connected && ( + + Try workflow: /interview → /plan → implement → /review + + )} + + Use @ to reference agents to spawn or files to read + + + Esc to cancel the current response + + + + + {/* Credits Section — hidden in Freebuff */} + {!IS_FREEBUFF && ( + + Credits + + + 1 credit = 1 cent + · + /subscribe + · + /usage + {!hasSubscription && ( + <> + · + /ads:enable + + )} + + + Subscribe for the best credit rates — /subscribe + + + + )} + + ) } diff --git a/cli/src/components/image-card.tsx b/cli/src/components/image-card.tsx index 34dd9d62e6..01cf547eb8 100644 --- a/cli/src/components/image-card.tsx +++ b/cli/src/components/image-card.tsx @@ -34,6 +34,10 @@ export interface ImageCardImage { filename: string status?: 'processing' | 'ready' | 'error' // Defaults to 'ready' if not provided note?: string // Display note: 'compressed' | error message + processedImage?: { + base64: string + mediaType: string + } } interface ImageCardProps { @@ -56,20 +60,35 @@ export const ImageCard = ({ // Load thumbnail if terminal supports inline images (iTerm2/Kitty) useEffect(() => { if (!canShowInlineImages) return + // Skip loading while image is processing or has error to avoid race condition and unnecessary failed reads + if ((image.status ?? 'ready') !== 'ready') return let cancelled = false const loadThumbnail = async () => { try { - const imageData = fs.readFileSync(image.path) - const base64Data = imageData.toString('base64') - const sequence = renderInlineImage(base64Data, { - width: INLINE_IMAGE_WIDTH, - height: INLINE_IMAGE_HEIGHT, - filename: image.filename, - }) - if (!cancelled) { - setThumbnailSequence(sequence) + let base64Data: string | undefined + + if (image.processedImage) { + base64Data = image.processedImage.base64 + } else if (!image.path.startsWith('clipboard:')) { + const imageData = fs.readFileSync(image.path) + base64Data = imageData.toString('base64') + } + + if (base64Data) { + const sequence = renderInlineImage(base64Data, { + width: INLINE_IMAGE_WIDTH, + height: INLINE_IMAGE_HEIGHT, + filename: image.filename, + }) + if (!cancelled) { + setThumbnailSequence(sequence) + } + } else { + if (!cancelled) { + setThumbnailSequence(null) + } } } catch { // Failed to load image, will show icon fallback @@ -84,7 +103,7 @@ export const ImageCard = ({ return () => { cancelled = true } - }, [image.path, image.filename, canShowInlineImages]) + }, [image, image.filename, canShowInlineImages]) const truncatedName = truncateFilename(image.filename) @@ -106,7 +125,7 @@ export const ImageCard = ({ {thumbnailSequence} ) : ( 🖼️} diff --git a/cli/src/components/image-thumbnail.tsx b/cli/src/components/image-thumbnail.tsx index 0c45aee175..951e43f139 100644 --- a/cli/src/components/image-thumbnail.tsx +++ b/cli/src/components/image-thumbnail.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useState, memo } from 'react' +import { type ImageCardImage } from './image-card' import { extractThumbnailColors, rgbToHex, @@ -13,7 +14,7 @@ import { } from '../utils/image-thumbnail' interface ImageThumbnailProps { - imagePath: string + image: ImageCardImage width: number // Width in cells height: number // Height in rows (each row uses half-blocks for 2 pixel rows) fallback?: React.ReactNode @@ -27,7 +28,7 @@ interface ImageThumbnailProps { * - ▀ (upper half block) character */ export const ImageThumbnail = memo(({ - imagePath, + image, width, height, fallback, @@ -35,10 +36,24 @@ export const ImageThumbnail = memo(({ const [thumbnailData, setThumbnailData] = useState(null) useEffect(() => { + // Skip loading while image is processing or has error to avoid race condition and unnecessary failed reads + if ((image.status ?? 'ready') !== 'ready') return + let cancelled = false const loadThumbnail = async () => { - const data = await extractThumbnailColors(imagePath, width, height) + let data: ThumbnailData | null = null + try { + if (image.processedImage) { + const imageBuffer = Buffer.from(image.processedImage.base64, 'base64') + data = await extractThumbnailColors(imageBuffer, width, height) + } else if (!image.path.startsWith('clipboard:')) { + data = await extractThumbnailColors(image.path, width, height) + } + } catch { + // Ignore errors, will show fallback + } + if (!cancelled) { setThumbnailData(data) } @@ -49,7 +64,7 @@ export const ImageThumbnail = memo(({ return () => { cancelled = true } - }, [imagePath, width, height]) + }, [image, width, height]) if (!thumbnailData) { return <>{fallback} diff --git a/cli/src/components/implementor-row.tsx b/cli/src/components/implementor-row.tsx deleted file mode 100644 index dacaf65a9d..0000000000 --- a/cli/src/components/implementor-row.tsx +++ /dev/null @@ -1,474 +0,0 @@ -import { pluralize } from '@codebuff/common/util/string' -import { TextAttributes } from '@opentui/core' -import React, { memo, useMemo, useState, useCallback } from 'react' - -import { getAgentStatusInfo } from '../utils/agent-helpers' -import { - buildActivityTimeline, - getImplementorDisplayName, - getImplementorIndex, - getFileStatsFromBlocks, - truncateWithEllipsis, - type FileStats, -} from '../utils/implementor-helpers' -import { useTheme } from '../hooks/use-theme' -import { useTerminalLayout } from '../hooks/use-terminal-layout' -import { computeSmartColumns } from '../utils/layout-helpers' -import { getRelativePath } from '../utils/path-helpers' -import { PROPOSAL_BORDER_CHARS } from '../utils/ui-constants' -import { Button } from './button' -import { CollapseButton } from './collapse-button' -import { DiffViewer } from './tools/diff-viewer' -import type { AgentContentBlock, ContentBlock } from '../types/chat' - -interface ImplementorGroupProps { - implementors: AgentContentBlock[] - siblingBlocks: ContentBlock[] - onToggleCollapsed: (id: string) => void - availableWidth: number -} - -/** - * Responsive card grid for comparing implementor proposals - */ -export const ImplementorGroup = memo( - ({ - implementors, - siblingBlocks, - availableWidth, - }: ImplementorGroupProps) => { - const theme = useTheme() - const { width } = useTerminalLayout() - - // Determine max columns based on terminal width - const maxColumns = useMemo(() => { - if (width.is('xs')) return 1 - if (width.is('sm')) return 1 - if (width.is('md')) return 2 - return 3 // lg - }, [width]) - - // Smart column selection based on item count - const columns = useMemo(() => - computeSmartColumns(implementors.length, maxColumns), - [implementors.length, maxColumns]) - - // Calculate card width based on columns and available space - const cardWidth = useMemo(() => { - // No gap between columns - cards are flush - return Math.floor(availableWidth / columns) - }, [availableWidth, columns]) - - // Masonry layout: distribute items to columns round-robin style - // (simpler than height-based, but still gives masonry effect) - const columnGroups = useMemo(() => { - const result: AgentContentBlock[][] = Array.from({ length: columns }, () => []) - implementors.forEach((impl, idx) => { - result[idx % columns].push(impl) - }) - return result - }, [implementors, columns]) - - // Check if any implementors are still running - const anyRunning = implementors.some(impl => impl.status === 'running') - const headerText = anyRunning - ? `${pluralize(implementors.length, 'proposal')} being generated` - : `${pluralize(implementors.length, 'proposal')} generated` - - return ( - - - {headerText} - - - {/* Masonry layout: columns side by side, cards stack vertically in each */} - - {columnGroups.map((columnItems, colIdx) => ( - - {columnItems.map((agentBlock) => { - const implementorIndex = getImplementorIndex( - agentBlock, - siblingBlocks, - ) - - return ( - - ) - })} - - ))} - - - ) - }, -) - -interface ImplementorCardProps { - agentBlock: AgentContentBlock - implementorIndex?: number - cardWidth: number -} - -/** - * Individual proposal card with dashed border - * Click file rows to view their diffs - */ -const ImplementorCard = memo( - ({ - agentBlock, - implementorIndex, - cardWidth, - }: ImplementorCardProps) => { - const theme = useTheme() - const [selectedFile, setSelectedFile] = useState(null) - - const isComplete = agentBlock.status === 'complete' - - const displayName = getImplementorDisplayName( - agentBlock.agentType, - implementorIndex, - ) - - // Get file stats for compact view - const fileStats = useMemo( - () => getFileStatsFromBlocks(agentBlock.blocks), - [agentBlock.blocks] - ) - - // Build timeline to extract diffs - const timeline = useMemo( - () => buildActivityTimeline(agentBlock.blocks), - [agentBlock.blocks] - ) - - // Build map of file path -> diff for inline display - const fileDiffs = useMemo(() => { - const diffs = new Map() - for (const item of timeline) { - if (item.type === 'edit' && item.diff) { - diffs.set(item.content, item.diff) - } - } - return diffs - }, [timeline]) - - // Get status info from helper - const { indicator: statusIndicator, label: statusLabel, color: statusColor } = getAgentStatusInfo( - agentBlock.status, - theme, - ) - // Format: "● running" when streaming, "completed ✓" when done (checkmark at end) - const statusText = statusIndicator === '✓' - ? `${statusLabel} ${statusIndicator}` - : `${statusIndicator} ${statusLabel}` - - // Use cardWidth for internal truncation calculations (approximate internal space) - const innerWidth = Math.max(10, cardWidth - 4) - - // Toggle file selection - clicking same file deselects it - const handleFileSelect = useCallback((filePath: string) => { - setSelectedFile(prev => prev === filePath ? null : filePath) - }, []) - - return ( - - {/* Header: Model name + Status */} - - - {displayName} - - - {statusText} - - - - {/* Prompt provided to this proposal */} - {agentBlock.initialPrompt && ( - - - {agentBlock.initialPrompt} - - - )} - - {/* File stats - click file name to view diff inline */} - {fileStats.length > 0 && ( - - )} - - {/* No file edits yet */} - {fileStats.length === 0 && timeline.length > 0 && ( - - No file changes yet - - )} - - {/* No content at all */} - {fileStats.length === 0 && timeline.length === 0 && ( - - {agentBlock.status === 'running' ? 'generating...' : 'waiting...'} - - )} - - ) - }, -) - -// ============================================================================ -// COMPACT FILE STATS VIEW -// ============================================================================ - -interface CompactFileStatsProps { - fileStats: FileStats[] - availableWidth: number - selectedFile: string | null - onSelectFile: (filePath: string) => void - /** Map of file path to diff content */ - fileDiffs: Map -} - -/** - * Compact view showing file changes with full-width, center-aligned addition/deletion bars. - * The left side is a green bar (additions) and the right side is a red bar (deletions), - * both extending to the center with their +N / -N counts rendered in white inside the bars. - * Click a file name to view its diff inline below that row. - */ -const CompactFileStats = memo(({ - fileStats, - availableWidth, - selectedFile, - onSelectFile, - fileDiffs, -}: CompactFileStatsProps) => { - const theme = useTheme() - - if (fileStats.length === 0) { - return ( - - No file changes yet - - ) - } - - // Fixed bar width - keeps layout simple and predictable - const maxBarWidth = 5 - - // Calculate max string widths for alignment (so all bars meet at center axis) - // Always include +0/-0 in width calculation since we always show them - const maxAddedStrWidth = Math.max( - ...fileStats.map(f => `+${f.stats.linesAdded}`.length), - 2 // Minimum "+0" - ) - const maxRemovedStrWidth = Math.max( - ...fileStats.map(f => `-${f.stats.linesRemoved}`.length), - 2 // Minimum "-0" - ) - - return ( - - {fileStats.map((file, idx) => ( - onSelectFile(file.path)} - diff={fileDiffs.get(file.path)} - /> - ))} - - ) -}) - -interface CompactFileRowProps { - file: FileStats - availableWidth: number - maxBarWidth: number - maxAddedStrWidth: number - maxRemovedStrWidth: number - isSelected: boolean - onSelect: () => void - diff?: string -} - -/** - * Single file row with full-width colored bars meeting at center. - * File name is underlined on hover, clickable to show diff inline below. - */ -const CompactFileRow = memo(({ - file, - availableWidth, - maxBarWidth, - maxAddedStrWidth, - maxRemovedStrWidth, - isSelected, - onSelect, - diff, -}: CompactFileRowProps) => { - const theme = useTheme() - const [isHovered, setIsHovered] = useState(false) - - // Format numbers - always show counts, including +0 and -0 - const addedStr = `+${file.stats.linesAdded}` - const removedStr = `-${file.stats.linesRemoved}` - - // Full-width colored sections with numbers inside: - // - Added section: green bar extending to center with +N in white (right-aligned) - // - Removed section: red bar extending from center with -N in white (left-aligned) - const addedSectionWidth = maxBarWidth + maxAddedStrWidth - const removedSectionWidth = maxBarWidth + maxRemovedStrWidth - - // +N right-aligned within the green section with 1 space padding before the center edge - const addedContent = (addedStr + ' ').padStart(addedSectionWidth) - // -N left-aligned within the red section with 1 space padding after the center edge - const removedContent = (' ' + removedStr).padEnd(removedSectionWidth) - - // Calculate available width for file path - // Layout: changeType(1) + spaces(2) + filePath + spaces(2) + hunks + spaces(2) + bars - const hunkText = `${file.stats.hunks} ${file.stats.hunks === 1 ? 'hunk' : 'hunks'}` - // Total bar section width: 2*maxBarWidth + maxAddedStrWidth + maxRemovedStrWidth (no center gap) - const barWidth = 2 * maxBarWidth + maxAddedStrWidth + maxRemovedStrWidth - const fixedWidth = 1 + 2 + 2 + hunkText.length + 2 + barWidth - const maxFilePathWidth = Math.max(10, availableWidth - fixedWidth) - - // Get and truncate file path - const relativePath = getRelativePath(file.path) - const displayPath = truncateWithEllipsis(relativePath, maxFilePathWidth) - - return ( - - {/* File row */} - - {/* Change type: fixed */} - {file.changeType} - - - {/* File path: clickable with underline on hover, flexes to push bars right */} - - - - {/* Hunk count */} - - {hunkText} - - - - {/* Bar visualization: full-width bars meeting at center with numbers inside */} - - {/* Added section: full green bar with +N in white inside, right-aligned to center */} - {addedContent} - {/* Removed section: full red bar with -N in white inside, left-aligned from center */} - {removedContent} - - - - {/* Inline diff viewer when selected - aligns with card content (full width) */} - {isSelected && diff && ( - - - - - - - )} - - ) -}) - -// Keep the old exports for backward compatibility during transition -export { ImplementorCard as ImplementorRow } diff --git a/cli/src/components/input-mode-banner.tsx b/cli/src/components/input-mode-banner.tsx index e73b74f8a7..b37eeacb7f 100644 --- a/cli/src/components/input-mode-banner.tsx +++ b/cli/src/components/input-mode-banner.tsx @@ -1,9 +1,11 @@ +import { CHATGPT_OAUTH_ENABLED } from '@codebuff/common/constants/chatgpt-oauth' import React from 'react' +import { IS_FREEBUFF } from '../utils/constants' -import { ClaudeConnectBanner } from './claude-connect-banner' +import { ChatGptConnectBanner } from './chatgpt-connect-banner' import { HelpBanner } from './help-banner' import { PendingAttachmentsBanner } from './pending-attachments-banner' -import { ReferralBanner } from './referral-banner' +import { SubscriptionLimitBanner } from './subscription-limit-banner' import { UsageBanner } from './usage-banner' import { useChatStore } from '../state/chat-store' @@ -22,10 +24,12 @@ const BANNER_REGISTRY: Record< > = { default: () => , image: () => , - usage: ({ showTime }) => , - referral: () => , + ...(IS_FREEBUFF ? {} : { usage: ({ showTime }: { showTime: number }) => }), help: () => , - 'connect:claude': () => , + ...(IS_FREEBUFF ? {} : { subscriptionLimit: () => }), + ...(CHATGPT_OAUTH_ENABLED + ? { 'connect:chatgpt': () => } + : {}), } /** diff --git a/cli/src/components/limited-landing-panel.tsx b/cli/src/components/limited-landing-panel.tsx new file mode 100644 index 0000000000..0dc0f7753a --- /dev/null +++ b/cli/src/components/limited-landing-panel.tsx @@ -0,0 +1,188 @@ +import { TextAttributes } from '@opentui/core' +import { useKeyboard } from '@opentui/react' +import React, { useCallback, useRef, useState } from 'react' + +import { Button } from './button' +import { joinFreebuffQueue } from '../hooks/use-freebuff-session' +import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' +import { useTheme } from '../hooks/use-theme' +import { + getFreebuffModel, + LIMITED_FREEBUFF_MODEL_ID, +} from '@codebuff/common/constants/freebuff-models' + +import type { KeyEvent, ScrollBoxRenderable } from '@opentui/core' + +interface LimitedLandingPanelProps { + /** Pre-composed session-counter line (e.g. "0 of 5 sessions used · resets + * in 8h 21m"). Parent owns the colors so the "used" count can flip to + * the warning color when exhausted without this component re-deriving the + * quota math. */ + sessionCounter: React.ReactNode + /** Plain-text form of the same counter, used only to measure how many rows + * it wraps to so the scroll budget is exact. */ + sessionCounterText: string + /** True when the shared per-day quota is fully spent. Disables the CTA. */ + isQuotaExhausted: boolean + /** Max vertical rows the panel may occupy. When its content is taller the + * panel scrolls (scrollbar shown) instead of letting flexbox compress the + * bordered button onto its own border. */ + maxHeight: number +} + +/** + * Limited-tier landing screen. + * + * Limited users only ever see one model, so this screen is a confirm gate, + * not a picker. Layout reads top-down as: model identity → caveat (data + * collection) → quota → CTA — so the action and the thing being acted on + * stay visually grouped. + */ +export const LimitedLandingPanel: React.FC = ({ + sessionCounter, + sessionCounterText, + isQuotaExhausted, + maxHeight, +}) => { + const theme = useTheme() + const { contentMaxWidth } = useTerminalDimensions() + const model = getFreebuffModel(LIMITED_FREEBUFF_MODEL_ID) + const [pending, setPending] = useState(false) + const scrollRef = useRef(null) + + // Rendered height of the panel, matching the JSX below row-for-row so the + // scroll budget is exact: name + warning (each wrap-aware) + the counter + // line with its 1-row top/bottom margins + the 3-row bordered button. + const wrappedRows = (text: string) => + Math.max(1, Math.ceil(text.length / contentMaxWidth)) + const contentHeight = + wrappedRows(model.displayName) + + (model.warning ? wrappedRows(model.warning) : 0) + + 1 /* counter marginTop */ + + wrappedRows(sessionCounterText) + + 1 /* counter marginBottom */ + + 3 /* button: 2 border rows + label */ + const needsScroll = contentHeight > maxHeight + const viewportHeight = Math.max(1, Math.min(contentHeight, maxHeight)) + + // A scrollbox stretches to fill its parent, which would left-align the + // panel; the old plain box sized to its content and the parent centered + // it. Restore that by pinning the scrollbox to its content width (widest + // of name / warning / counter / the bordered button) so `alignItems: + // 'center'` on the parent can center the whole block again. + const BUTTON_LABEL = 'Start session Enter' + const BUTTON_CHROME = 6 // 2 border + 4 padding (paddingLeft/Right 2) + const panelWidth = + Math.min( + contentMaxWidth, + Math.max( + model.displayName.length, + model.warning?.length ?? 0, + sessionCounterText.length, + BUTTON_LABEL.length + BUTTON_CHROME, + ), + ) + (needsScroll ? 1 : 0) /* scrollbar gutter */ + + const interactable = !pending && !isQuotaExhausted + + const start = useCallback(() => { + if (!interactable) return + setPending(true) + joinFreebuffQueue(LIMITED_FREEBUFF_MODEL_ID).finally(() => + setPending(false), + ) + }, [interactable]) + + useKeyboard( + useCallback( + (key: KeyEvent) => { + const name = key.name ?? '' + const isCommit = + name === 'return' || name === 'enter' || name === 'space' + if (!isCommit || !interactable) return + key.preventDefault?.() + key.stopPropagation?.() + start() + }, + [interactable, start], + ), + ) + + return ( + + + + {model.displayName} + + + {model.warning && ( + + {model.warning} + + )} + + {sessionCounter} + + + + ) +} diff --git a/cli/src/components/login-modal-utils.ts b/cli/src/components/login-modal-utils.ts deleted file mode 100644 index 1b83608e3b..0000000000 --- a/cli/src/components/login-modal-utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Utility functions for the login screen component - */ - -/** - * Formats a URL for display by wrapping it at logical breakpoints - */ -export function formatUrl(url: string, maxWidth?: number): string[] { - if (!maxWidth || maxWidth <= 0 || url.length <= maxWidth) { - return [url] - } - - const lines: string[] = [] - let remaining = url - - while (remaining.length > 0) { - if (remaining.length <= maxWidth) { - lines.push(remaining) - break - } - - // Try to break at a logical point (after /, ?, &, =) - let breakPoint = maxWidth - for (let i = maxWidth - 1; i > maxWidth - 20 && i > 0; i--) { - if (['/', '?', '&', '='].includes(remaining[i])) { - breakPoint = i + 1 - break - } - } - - lines.push(remaining.substring(0, breakPoint)) - remaining = remaining.substring(breakPoint) - } - - return lines -} - -/** - * Generates a unique fingerprint ID for CLI authentication - */ -export function generateFingerprintId(): string { - return `codecane-cli-${Math.random().toString(36).substring(2, 15)}` -} - - -/** - * Parses the logo string into individual lines - */ -export function parseLogoLines(logo: string): string[] { - return logo.split('\n').filter((line) => line.length > 0) -} diff --git a/cli/src/components/login-modal.tsx b/cli/src/components/login-modal.tsx index 1d2b229f28..aa0a9f7b89 100644 --- a/cli/src/components/login-modal.tsx +++ b/cli/src/components/login-modal.tsx @@ -1,24 +1,22 @@ import { useRenderer } from '@opentui/react' -import open from 'open' import React, { useCallback, useEffect, useRef, useState } from 'react' -import { TerminalLink } from './terminal-link' +import { Button } from './button' import { useLoginMutation } from '../hooks/use-auth-query' +import { useClipboard } from '../hooks/use-clipboard' import { useFetchLoginUrl } from '../hooks/use-fetch-login-url' import { useLoginKeyboardHandlers } from '../hooks/use-login-keyboard-handlers' import { useLoginPolling } from '../hooks/use-login-polling' import { useLogo } from '../hooks/use-logo' import { useSheenAnimation } from '../hooks/use-sheen-animation' import { useTheme } from '../hooks/use-theme' -import { getLogoBlockColor, getLogoAccentColor } from '../utils/theme-system' -import { - formatUrl, - generateFingerprintId, - calculateResponsiveLayout, -} from '../login/utils' +import { formatUrl, calculateResponsiveLayout } from '../login/utils' import { useLoginStore } from '../state/login-store' -import { copyTextToClipboard } from '../utils/clipboard' +import { IS_FREEBUFF } from '../utils/constants' +import { copyTextToClipboard, isRemoteSession } from '../utils/clipboard' +import { getFingerprintId } from '../utils/fingerprint' import { logger } from '../utils/logger' +import { getLogoBlockColor, getLogoAccentColor } from '../utils/theme-system' import type { User } from '../utils/auth' @@ -39,17 +37,17 @@ export const LoginModal = ({ loginUrl, loading, error, + fingerprintId, fingerprintHash, expiresAt, isWaitingForEnter, hasOpenedBrowser, sheenPosition, - copyMessage, justCopied, - hasClickedLink, setLoginUrl, setLoading, setError, + setFingerprintId, setFingerprintHash, setExpiresAt, setIsWaitingForEnter, @@ -60,8 +58,8 @@ export const LoginModal = ({ setHasClickedLink, } = useLoginStore() - // Generate fingerprint ID (only once on mount) - const [fingerprintId] = useState(() => generateFingerprintId()) + // Track hover state for copy button + const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false) // Use TanStack Query for login mutation const loginMutation = useLoginMutation() @@ -95,11 +93,8 @@ export const LoginModal = ({ setJustCopied(false) }, 3000) } catch (err) { + // Silently fail - the URL is visible for manual copying logger.error(err, 'Failed to copy to clipboard') - setCopyMessage('✗ Failed to copy to clipboard') - setTimeout(() => { - setCopyMessage(null) - }, 3000) } }, [setHasClickedLink, setJustCopied, setCopyMessage], @@ -112,17 +107,22 @@ export const LoginModal = ({ setLoading(true) setError(null) - fetchLoginUrlMutation.mutate(fingerprintId, { + // Near-instant after the prefetch in initializeApp; falls back to the + // sync legacy fingerprint if hardware hashing fails. + const id = await getFingerprintId() + setFingerprintId(id) + + fetchLoginUrlMutation.mutate(id, { onSettled: () => { setLoading(false) }, }) }, [ - fingerprintId, loading, hasOpenedBrowser, setLoading, setError, + setFingerprintId, fetchLoginUrlMutation, ]) @@ -192,12 +192,6 @@ export const LoginModal = ({ onCopyUrl: copyToClipboard, }) - // Auto-copy URL when browser is opened - useEffect(() => { - if (hasOpenedBrowser && loginUrl) { - copyToClipboard(loginUrl) - } - }, [hasOpenedBrowser, loginUrl, copyToClipboard]) // Calculate terminal width and height for responsive display const terminalWidth = renderer?.width || 80 @@ -221,19 +215,6 @@ export const LoginModal = ({ [maxUrlWidth], ) - // Handle login URL activation - const handleActivateLoginUrl = useCallback(async () => { - if (!loginUrl) { - return - } - try { - await open(loginUrl) - } catch (err) { - logger.error(err, 'Failed to open browser on link click') - } - return copyToClipboard(loginUrl) - }, [loginUrl, copyToClipboard]) - // Use custom hook for sheen animation const blockColor = getLogoBlockColor(theme.name) const accentColor = getLogoAccentColor(theme.name) @@ -253,6 +234,10 @@ export const LoginModal = ({ textColor: theme.foreground, }) + // Enable auto-copy when user selects text (drag to select) + // hasSelection provides visual feedback when text is being selected + const { hasSelection } = useClipboard() + // Format URL for display (wrap if needed) return ( - {isNarrow - ? 'Press ENTER to login...' - : 'Press ENTER to open your browser and login...'} + Press ENTER to login... )} - {/* After opening browser - show URL as fallback */} + {/* After pressing enter - show URL prominently for all users */} {!loading && !error && loginUrl && hasOpenedBrowser && ( - + {isNarrow - ? 'Opening browser...' - : 'Opening browser to complete login...'} + ? 'Open this URL to login:' + : 'Open this URL in your browser to login:'} - + {formatLoginUrlLines(loginUrl, maxUrlWidth).map((line, index) => ( + + + {line} + + + ))} - {copyMessage && ( - + + - - - {isNarrow ? 'Or copy URL:' : "Or copy this URL if browser didn't open:"} - - - - - {loginUrl} + + + Waiting for login... + {isRemoteSession() && !isVerySmall && ( + + + Tip: Can't copy? Exit and run{' '} + + {IS_FREEBUFF ? 'freebuff' : 'codebuff'} login + + {' '}instead. + + + )} )} diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 48439318f8..adbd6fd488 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -1,43 +1,28 @@ import { TextAttributes } from '@opentui/core' -import React, { memo, useCallback, useState, type ReactNode } from 'react' +import { memo, useState } from 'react' -import { AgentBranchItem } from './agent-branch-item' +import { BlocksRenderer } from './blocks/blocks-renderer' +import { UserContentWithCopyButton } from './blocks/user-content-copy' import { Button } from './button' -import { CopyButton } from './copy-button' +import { FileAttachmentCard } from './file-attachment-card' import { ImageCard } from './image-card' -import { TextAttachmentCard } from './text-attachment-card' -import { ImplementorGroup } from './implementor-row' import { MessageFooter } from './message-footer' +import { TextAttachmentCard } from './text-attachment-card' +import { UserErrorBanner } from './user-error-banner' import { ValidationErrorPopover } from './validation-error-popover' import { useTheme } from '../hooks/use-theme' import { useWhyDidYouUpdateById } from '../hooks/use-why-did-you-update' import { getCliEnv } from '../utils/env' -import { isTextBlock, isToolBlock, isImageBlock } from '../types/chat' -import { shouldRenderAsSimpleText } from '../utils/constants' -import { - isImplementorAgent, - getImplementorIndex, - groupConsecutiveImplementors, -} from '../utils/implementor-helpers' -import { getAgentStatusInfo } from '../utils/agent-helpers' import { type MarkdownPalette } from '../utils/markdown-renderer' import { formatCwd } from '../utils/path-helpers' -import { AgentListBranch } from './blocks/agent-list-branch' -import { AskUserBranch } from './blocks/ask-user-branch' -import { ContentWithMarkdown } from './blocks/content-with-markdown' -import { ImageBlock } from './blocks/image-block' -import { ThinkingBlock } from './blocks/thinking-block' -import { ToolBranch } from './blocks/tool-branch' -import { PlanBox } from './renderers/plan-box' + +import type { FeedbackCategory } from '@codebuff/common/constants/feedback' import type { ContentBlock, - TextContentBlock, - HtmlContentBlock, - AgentContentBlock, + FileAttachment, ImageAttachment, TextAttachment, - ImageContentBlock, ChatMessageMetadata, } from '../types/chat' import type { ThemeColor } from '../types/theme-system' @@ -59,32 +44,37 @@ interface MessageBlockProps { markdownOptions: { codeBlockWidth: number; palette: MarkdownPalette } availableWidth: number markdownPalette: MarkdownPalette - streamingAgents: Set onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void + onBuildLite: () => void onFeedback?: (messageId: string) => void onCloseFeedback?: () => void validationErrors?: Array<{ id: string; message: string }> + /** Runtime error to display in UI but NOT send to LLM */ + userError?: string onOpenFeedback?: (options?: { - category?: string + category?: FeedbackCategory footerMessage?: string errors?: Array<{ id: string; message: string }> }) => void attachments?: ImageAttachment[] textAttachments?: TextAttachment[] + fileAttachments?: FileAttachment[] metadata?: ChatMessageMetadata isLastMessage?: boolean } -const MessageAttachments = ({ +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 } @@ -94,7 +84,6 @@ const MessageAttachments = ({ flexDirection: 'row', gap: 1, flexWrap: 'wrap', - marginTop: 1, }} > {imageAttachments.map((attachment) => ( @@ -111,11 +100,18 @@ const MessageAttachments = ({ showRemoveButton={false} /> ))} + {fileAttachments.map((attachment) => ( + + ))} ) -} +}) -export const MessageBlock: React.FC = ({ +export const MessageBlock = memo(({ messageId, blocks, content, @@ -132,19 +128,21 @@ export const MessageBlock: React.FC = ({ markdownOptions, availableWidth, markdownPalette, - streamingAgents, onToggleCollapsed, onBuildFast, onBuildMax, + onBuildLite, onFeedback, onCloseFeedback, validationErrors, + userError, onOpenFeedback, attachments, textAttachments, + fileAttachments, metadata, isLastMessage, -}) => { +}: MessageBlockProps) => { const [showValidationPopover, setShowValidationPopover] = useState(false) const bashCwd = metadata?.bashCwd ? formatCwd(metadata.bashCwd) : undefined @@ -169,10 +167,10 @@ export const MessageBlock: React.FC = ({ markdownOptions, availableWidth, markdownPalette, - streamingAgents, onToggleCollapsed, onBuildFast, onBuildMax, + onBuildLite, onFeedback, onCloseFeedback, validationErrors, @@ -274,52 +272,60 @@ export const MessageBlock: React.FC = ({ )} - {blocks ? ( - - + {blocks ? ( + + + + ) : ( + - - ) : ( - - )} - {/* Show attachments for user messages */} - {isUser && ((attachments && attachments.length > 0) || (textAttachments && textAttachments.length > 0)) && ( - - )} + )} + {/* Show attachments for user messages */} + {isUser && + ((attachments && attachments.length > 0) || + (textAttachments && textAttachments.length > 0) || + (fileAttachments && fileAttachments.length > 0)) && ( + + )} + + + {/* Display runtime error banner for AI messages */} + {isAi && userError && } {isAi && ( = ({ )} ) -} - -const trimTrailingNewlines = (value: string): string => - value.replace(/[\r\n]+$/g, '') - -const sanitizePreview = (value: string): string => - value.replace(/[#*_`~\[\]()]/g, '').trim() - -// Extract all text content from blocks recursively - -const isReasoningTextBlock = ( - b: ContentBlock | null | undefined, -): b is TextContentBlock => { - if (!b || b.type !== 'text') return false - return b.textType === 'reasoning' -} - -const isRenderableTimelineBlock = ( - block: ContentBlock | null | undefined, -): boolean => { - if (!block) { - return false - } - - if (block.type === 'tool') { - return block.toolName !== 'end_turn' - } - - switch (block.type) { - case 'text': - case 'html': - case 'agent': - case 'agent-list': - case 'plan': - case 'mode-divider': - case 'ask-user': - case 'image': - return true - default: - return false - } -} - -interface AgentBodyProps { - agentBlock: Extract - keyPrefix: string - parentIsStreaming: boolean - availableWidth: number - markdownPalette: MarkdownPalette - streamingAgents: Set - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - isLastMessage?: boolean -} - -const AgentBody = memo( - ({ - agentBlock, - keyPrefix, - parentIsStreaming, - availableWidth, - markdownPalette, - streamingAgents, - onToggleCollapsed, - onBuildFast, - onBuildMax, - isLastMessage, - }: AgentBodyProps): ReactNode[] => { - const theme = useTheme() - const nestedBlocks = agentBlock.blocks ?? [] - const nodes: React.ReactNode[] = [] - - const getAgentMarkdownOptions = useCallback( - (indent: number) => { - const indentationOffset = indent * 2 - return { - codeBlockWidth: Math.max(10, availableWidth - 12 - indentationOffset), - palette: { - ...markdownPalette, - codeTextFg: theme.foreground, - }, - } - }, - [availableWidth, markdownPalette, theme.foreground], - ) - - for (let nestedIdx = 0; nestedIdx < nestedBlocks.length; ) { - const nestedBlock = nestedBlocks[nestedIdx] - - // Handle reasoning text blocks first - if (isReasoningTextBlock(nestedBlock)) { - const start = nestedIdx - const reasoningBlocks: Extract[] = [] - while (nestedIdx < nestedBlocks.length) { - const block = nestedBlocks[nestedIdx] - if (!isReasoningTextBlock(block)) break - reasoningBlocks.push(block) - nestedIdx++ - } - - nodes.push( - , - ) - continue - } - - switch ((nestedBlock as ContentBlock).type) { - case 'text': { - const textBlock = nestedBlock as unknown as TextContentBlock - const nestedStatus = textBlock.status - const isNestedStreamingText = - parentIsStreaming || nestedStatus === 'running' - const filteredNestedContent = isNestedStreamingText - ? trimTrailingNewlines(textBlock.content) - : textBlock.content.trim() - const renderKey = `${keyPrefix}-text-${nestedIdx}` - const markdownOptionsForLevel = getAgentMarkdownOptions(0) - const marginTop = textBlock.marginTop ?? 0 - const marginBottom = textBlock.marginBottom ?? 0 - const explicitColor = textBlock.color - const nestedTextColor = explicitColor ?? theme.foreground - nodes.push( - - - , - ) - nestedIdx++ - break - } - - case 'html': { - const htmlBlock = nestedBlock as HtmlContentBlock - const marginTop = htmlBlock.marginTop ?? 0 - const marginBottom = htmlBlock.marginBottom ?? 0 - nodes.push( - - {htmlBlock.render({ - textColor: theme.foreground, - theme, - })} - , - ) - nestedIdx++ - break - } - - case 'tool': { - const start = nestedIdx - const toolGroup: Extract[] = [] - while (nestedIdx < nestedBlocks.length) { - const block = nestedBlocks[nestedIdx] - if (!isToolBlock(block)) break - toolGroup.push(block) - nestedIdx++ - } - - const groupNodes = toolGroup.map((toolBlock) => ( - - )) - - const nonNullGroupNodes = groupNodes.filter( - Boolean, - ) as React.ReactNode[] - if (nonNullGroupNodes.length > 0) { - const hasRenderableBefore = - start > 0 && isRenderableTimelineBlock(nestedBlocks[start - 1]) - let hasRenderableAfter = false - for (let i = nestedIdx; i < nestedBlocks.length; i++) { - if (isRenderableTimelineBlock(nestedBlocks[i])) { - hasRenderableAfter = true - break - } - } - nodes.push( - - {nonNullGroupNodes} - , - ) - } - break - } - - case 'agent': { - const agentBlock = nestedBlock as AgentContentBlock - - // Group consecutive implementor agents and render with ImplementorGroup - if (isImplementorAgent(agentBlock)) { - const start = nestedIdx - const { group: implementors, nextIndex } = groupConsecutiveImplementors(nestedBlocks, nestedIdx) - nestedIdx = nextIndex - - nodes.push( - , - ) - break - } - - nodes.push( - , - ) - nestedIdx++ - break - } - } - } - - return nodes - }, -) - -interface AgentBranchWrapperProps { - agentBlock: Extract - keyPrefix: string - availableWidth: number - markdownPalette: MarkdownPalette - streamingAgents: Set - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - siblingBlocks?: ContentBlock[] - isLastMessage?: boolean -} - -const AgentBranchWrapper = memo( - ({ - agentBlock, - keyPrefix, - availableWidth, - markdownPalette, - streamingAgents, - onToggleCollapsed, - onBuildFast, - onBuildMax, - siblingBlocks, - isLastMessage, - }: AgentBranchWrapperProps) => { - const theme = useTheme() - - if (shouldRenderAsSimpleText(agentBlock.agentType)) { - const isStreaming = - agentBlock.status === 'running' || - streamingAgents.has(agentBlock.agentId) - - // Get base status info, but override if streaming - const effectiveStatus = isStreaming ? 'running' : agentBlock.status - const { indicator: statusIndicator, color: statusColor } = getAgentStatusInfo( - effectiveStatus, - theme, - ) - - let statusText = 'Selecting best' - let reason: string | undefined - - // If complete, try to show which implementation was selected - const isComplete = agentBlock.status === 'complete' - if (isComplete && siblingBlocks) { - const blocks = agentBlock.blocks ?? [] - const lastBlock = blocks[blocks.length - 1] as - | { input: { implementationId: string; reason: string } } - | undefined - const implementationId = lastBlock?.input?.implementationId - if (implementationId) { - // Convert letter to index: 'A' -> 0, 'B' -> 1, etc. - const letterIndex = implementationId.charCodeAt(0) - 65 - const implementors = siblingBlocks.filter( - (b): b is AgentContentBlock => b.type === 'agent' && isImplementorAgent(b), - ) - - const selectedAgent = implementors[letterIndex] - if (selectedAgent) { - const index = getImplementorIndex(selectedAgent, siblingBlocks) - // Just show "Selected Prompt #N" without repeating the prompt text - statusText = index !== undefined ? `Selected Strategy #${index + 1}` : 'Selected' - reason = lastBlock?.input?.reason - } - } - } - - return ( - - - {statusIndicator} - - {' '} - {statusText} - - - {reason && ( - - {reason} - - )} - - ) - } - - const isCollapsed = agentBlock.isCollapsed ?? false - const isStreaming = - agentBlock.status === 'running' || streamingAgents.has(agentBlock.agentId) - - const allTextContent = - agentBlock.blocks - ?.filter(isTextBlock) - .map((nested) => nested.content) - .join('') || '' - - const lines = allTextContent.split('\n').filter((line) => line.trim()) - const firstLine = lines[0] || '' - - const streamingPreview = isStreaming - ? agentBlock.initialPrompt - ? sanitizePreview(agentBlock.initialPrompt) - : `${sanitizePreview(firstLine)}...` - : '' - - const finishedPreview = - !isStreaming && isCollapsed && agentBlock.initialPrompt - ? sanitizePreview(agentBlock.initialPrompt) - : '' - - const isActive = isStreaming || agentBlock.status === 'running' - const effectiveStatus = isActive ? 'running' : agentBlock.status - const { indicator: statusIndicator, label: statusLabel, color: statusColor } = getAgentStatusInfo( - effectiveStatus, - theme, - ) - - const onToggle = useCallback(() => { - onToggleCollapsed(agentBlock.agentId) - }, [onToggleCollapsed, agentBlock.agentId]) - - return ( - - - - - - ) - }, -) - -interface UserContentWithCopyButtonProps { - content: string - messageId: string - isLoading: boolean - isComplete?: boolean - isUser: boolean - textColor: string - codeBlockWidth: number - palette: MarkdownPalette - showCopyButton: boolean -} - -/** - * Renders user content with an inline copy button. - * The text flows naturally with word wrapping, and the copy button appears inline after the content. - */ -const UserContentWithCopyButton = memo( - ({ - content, - messageId, - isLoading, - isComplete, - isUser, - textColor, - codeBlockWidth, - palette, - showCopyButton, - }: UserContentWithCopyButtonProps) => { - const isStreamingMessage = isLoading || !isComplete - const normalizedContent = isStreamingMessage - ? trimTrailingNewlines(content) - : content.trim() - - if (!showCopyButton) { - return ( - - - - ) - } - - // Render text content with inline copy icon - clicking the icon copies the text - return ( - - ) - }, -) - -interface UserTextWithInlineCopyProps { - messageId: string - content: string - normalizedContent: string - isStreamingMessage: boolean - textColor: string - codeBlockWidth: number - palette: MarkdownPalette -} - -/** - * Renders user text content with an inline copy icon at the end. - * Clicking the copy icon copies the text to clipboard. - */ -const UserTextWithInlineCopy = memo( - ({ - messageId, - content, - normalizedContent, - isStreamingMessage, - textColor, - codeBlockWidth, - palette, - }: UserTextWithInlineCopyProps) => { - return ( - - - - - - ) - }, -) - -interface UserBlockTextWithInlineCopyProps { - content: string - contentToCopy: string - isStreaming: boolean - textColor: string - codeBlockWidth: number - palette: MarkdownPalette - marginTop: number - marginBottom: number -} - -/** - * Renders a text block for user messages with an inline copy icon at the end. - */ -const UserBlockTextWithInlineCopy = memo( - ({ - content, - contentToCopy, - isStreaming, - textColor, - codeBlockWidth, - palette, - marginTop, - marginBottom, - }: UserBlockTextWithInlineCopyProps) => { - return ( - - - - - - ) - }, -) - -interface SingleBlockProps { - block: ContentBlock - idx: number - messageId: string - blocks?: ContentBlock[] - isLoading: boolean - isComplete?: boolean - isUser: boolean - textColor: string - availableWidth: number - markdownPalette: MarkdownPalette - streamingAgents: Set - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - isLastMessage?: boolean - contentToCopy?: string -} - -const SingleBlock = memo( - ({ - block, - idx, - messageId, - blocks, - isLoading, - isComplete, - isUser, - textColor, - availableWidth, - markdownPalette, - streamingAgents, - onToggleCollapsed, - onBuildFast, - onBuildMax, - isLastMessage, - contentToCopy, - }: SingleBlockProps): ReactNode => { - const theme = useTheme() - const codeBlockWidth = Math.max(10, availableWidth - 8) - - switch (block.type) { - case 'text': { - // Skip raw rendering for reasoning; grouped above into - if (isReasoningTextBlock(block)) { - return null - } - const textBlock = block as TextContentBlock - const isStreamingText = isLoading || !isComplete - const filteredContent = isStreamingText - ? trimTrailingNewlines(textBlock.content) - : textBlock.content.trim() - const renderKey = `${messageId}-text-${idx}` - const prevBlock = idx > 0 && blocks ? blocks[idx - 1] : null - const marginTop = - prevBlock && (prevBlock.type === 'tool' || prevBlock.type === 'agent') - ? 0 - : textBlock.marginTop ?? 0 - const marginBottom = textBlock.marginBottom ?? 0 - const explicitColor = textBlock.color - const blockTextColor = explicitColor ?? textColor - - // If this block should have an inline copy icon, use the special component - if (contentToCopy) { - return ( - - ) - } - - return ( - - - - ) - } - - case 'plan': { - return ( - - - - ) - } - - case 'html': { - const marginTop = block.marginTop ?? 0 - const marginBottom = block.marginBottom ?? 0 - return ( - - {block.render({ textColor, theme })} - - ) - } - - case 'tool': { - // Handled in BlocksRenderer grouping logic - return null - } - - case 'ask-user': { - return ( - - ) - } - - case 'image': { - return ( - - ) - } - - case 'agent': { - return ( - - ) - } - - case 'agent-list': { - return ( - - ) - } - - default: - return null - } - }, -) - -interface BlocksRendererProps { - sourceBlocks: ContentBlock[] - messageId: string - isLoading: boolean - isComplete?: boolean - isUser: boolean - textColor: string - availableWidth: number - markdownPalette: MarkdownPalette - streamingAgents: Set - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - isLastMessage?: boolean - contentToCopy?: string -} - -const BlocksRenderer = memo( - ({ - sourceBlocks, - messageId, - isLoading, - isComplete, - isUser, - textColor, - availableWidth, - markdownPalette, - streamingAgents, - onToggleCollapsed, - onBuildFast, - onBuildMax, - isLastMessage, - contentToCopy, - }: BlocksRendererProps) => { - const nodes: React.ReactNode[] = [] - - // Find the index of the last text block for inline copy icon - const lastTextBlockIndex = contentToCopy - ? sourceBlocks.reduceRight( - (acc, block, idx) => (acc === -1 && block.type === 'text' ? idx : acc), - -1, - ) - : -1 - - for (let i = 0; i < sourceBlocks.length; ) { - const block = sourceBlocks[i] - // Handle reasoning text blocks - if (isReasoningTextBlock(block)) { - const start = i - const reasoningBlocks: Extract[] = [] - while (i < sourceBlocks.length) { - const currentBlock = sourceBlocks[i] - if (!isReasoningTextBlock(currentBlock)) break - reasoningBlocks.push(currentBlock) - i++ - } - - nodes.push( - , - ) - continue - } - // Handle image blocks - if (isImageBlock(block)) { - nodes.push( - , - ) - i++ - continue - } - - if (block.type === 'tool') { - const start = i - const group: Extract[] = [] - while (i < sourceBlocks.length) { - const currentBlock = sourceBlocks[i] - if (!isToolBlock(currentBlock)) break - group.push(currentBlock) - i++ - } - - const groupNodes = group.map((toolBlock) => ( - - )) - - const nonNullGroupNodes = groupNodes.filter( - Boolean, - ) as React.ReactNode[] - if (nonNullGroupNodes.length > 0) { - const hasRenderableBefore = - start > 0 && isRenderableTimelineBlock(sourceBlocks[start - 1]) - // Check for any subsequent renderable blocks without allocating a slice - let hasRenderableAfter = false - for (let j = i; j < sourceBlocks.length; j++) { - if (isRenderableTimelineBlock(sourceBlocks[j])) { - hasRenderableAfter = true - break - } - } - nodes.push( - - {nonNullGroupNodes} - , - ) - } - continue - } - - // Group consecutive implementor agents and render with ImplementorGroup - if (block.type === 'agent' && isImplementorAgent(block)) { - const start = i - const { group: implementors, nextIndex } = groupConsecutiveImplementors(sourceBlocks, i) - i = nextIndex - - nodes.push( - , - ) - continue - } - - nodes.push( - , - ) - i++ - } - return nodes - }, -) +}) diff --git a/cli/src/components/message-footer.tsx b/cli/src/components/message-footer.tsx index 13c2b3e9c3..34289a2666 100644 --- a/cli/src/components/message-footer.tsx +++ b/cli/src/components/message-footer.tsx @@ -1,3 +1,5 @@ +import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' +import { IS_FREEBUFF } from '../utils/constants' import { pluralize } from '@codebuff/common/util/string' import { TextAttributes } from '@opentui/core' import React, { useCallback, useMemo } from 'react' @@ -5,6 +7,11 @@ import React, { useCallback, useMemo } from 'react' import { CopyButton } from './copy-button' import { ElapsedTimer } from './elapsed-timer' import { FeedbackIconButton } from './feedback-icon-button' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' +import { + getBlockPercentRemaining, + isCoveredBySubscription, +} from '../utils/subscription' import { useTheme } from '../hooks/use-theme' import { useFeedbackStore, @@ -154,22 +161,10 @@ export const MessageFooter: React.FC = ({ ), }) } - if (typeof credits === 'number' && credits > 0) { + if (typeof credits === 'number' && credits > 0 && !IS_FREEBUFF) { footerItems.push({ key: 'credits', - node: ( - - {pluralize(credits, 'credit')} - - ), + node: , }) } if (shouldRenderFeedbackButton) { @@ -222,3 +217,42 @@ export const MessageFooter: React.FC = ({ ) } + +const CreditsOrSubscriptionIndicator: React.FC<{ credits: number }> = ({ credits }) => { + const theme = useTheme() + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: false, + refetchOnActivity: false, + pauseWhenIdle: false, + }) + + const blockPercentRemaining = useMemo( + () => getBlockPercentRemaining(subscriptionData), + [subscriptionData], + ) + + const showSubscriptionIndicator = isCoveredBySubscription(subscriptionData) + + if (showSubscriptionIndicator) { + const label = (blockPercentRemaining ?? 0) < 20 + ? `✓ ${SUBSCRIPTION_DISPLAY_NAME} (${blockPercentRemaining}% left)` + : `✓ ${SUBSCRIPTION_DISPLAY_NAME}` + return ( + + {label} + + ) + } + + return ( + + {pluralize(credits, 'credit')} + + ) +} diff --git a/cli/src/components/message-with-agents.tsx b/cli/src/components/message-with-agents.tsx index cb3af6abcb..ee97d60bb9 100644 --- a/cli/src/components/message-with-agents.tsx +++ b/cli/src/components/message-with-agents.tsx @@ -1,10 +1,21 @@ import { TextAttributes } from '@opentui/core' import { memo, useCallback, useMemo, type ReactNode } from 'react' import React from 'react' +import { useShallow } from 'zustand/react/shallow' import { Button } from './button' +import { ErrorBoundary } from './error-boundary' +import { GridLayout } from './grid-layout' import { MessageBlock } from './message-block' import { ModeDivider } from './mode-divider' +import { useChatStore } from '../state/chat-store' +import { useMessageBlockStore } from '../state/message-block-store' +import { splitByAgentSize } from '../utils/block-processor' +import { getCliEnv } from '../utils/env' +import { + AGENT_CONTENT_HORIZONTAL_PADDING, + MAX_AGENT_DEPTH, +} from '../utils/layout-helpers' import { renderMarkdown, hasMarkdown, @@ -12,62 +23,116 @@ import { } from '../utils/markdown-renderer' import type { ChatMessage } from '../types/chat' -import type { ChatTheme } from '../types/theme-system' +import type { FeedbackCategory } from '@codebuff/common/constants/feedback' + +interface AgentChildrenGridProps { + agentChildren: ChatMessage[] + depth: number + availableWidth: number +} + +const AgentChildrenGrid = memo( + ({ agentChildren, depth, availableWidth }: AgentChildrenGridProps) => { + const theme = useMessageBlockStore((state) => state.context.theme) + + const getItemKey = useCallback((agent: ChatMessage) => agent.id, []) + + const renderAgentChild = useCallback( + (agent: ChatMessage, _idx: number, columnWidth: number) => ( + + ), + [depth], + ) + + const subGroups = useMemo( + () => splitByAgentSize(agentChildren, (m) => m.agent?.agentType ?? ''), + [agentChildren], + ) + + if (agentChildren.length === 0) return null + + if (depth >= MAX_AGENT_DEPTH) { + if (getCliEnv().NODE_ENV === 'development') { + console.warn( + `[AgentChildrenGrid] Depth limit (${MAX_AGENT_DEPTH}) reached, truncating agent tree`, + ) + } + return ( + + {`${agentChildren.length} nested agent${ + agentChildren.length > 1 ? 's' : '' + } not shown (depth limit)`} + + ) + } + + const errorFallback = ( + Error rendering agent children + ) + + return ( + + + {subGroups.map((group) => ( + + ))} + + + ) + }, +) interface MessageWithAgentsProps { message: ChatMessage depth: number isLastMessage: boolean - theme: ChatTheme - markdownPalette: MarkdownPalette - streamingAgents: Set - messageTree: Map - messages: ChatMessage[] availableWidth: number - setFocusedAgentId: React.Dispatch> - isWaitingForResponse: boolean - timerStartTime: number | null - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - onFeedback: ( - messageId: string, - options?: { - category?: string - footerMessage?: string - errors?: Array<{ id: string; message: string }> - }, - ) => void - onCloseFeedback: () => void } export const MessageWithAgents = memo( - ({ - message, - depth, - isLastMessage, - theme, - markdownPalette, - streamingAgents, - messageTree, - messages, - availableWidth, - setFocusedAgentId, - isWaitingForResponse, - timerStartTime, - onToggleCollapsed, - onBuildFast, - onBuildMax, - onFeedback, - onCloseFeedback, - }: MessageWithAgentsProps): ReactNode => { + ({ message, depth, isLastMessage, availableWidth }: MessageWithAgentsProps): ReactNode => { const SIDE_GUTTER = 1 const isAgent = message.variant === 'agent' + // Use useShallow for grouped selectors to prevent unnecessary re-renders + const { theme, markdownPalette, messageTree, isWaitingForResponse, timerStartTime } = + useMessageBlockStore( + useShallow((state) => ({ + theme: state.context.theme, + markdownPalette: state.context.markdownPalette, + messageTree: state.context.messageTree, + isWaitingForResponse: state.context.isWaitingForResponse, + timerStartTime: state.context.timerStartTime, + })), + ) + + const { onToggleCollapsed, onBuildFast, onBuildMax, onBuildLite, onFeedback, onCloseFeedback } = + useMessageBlockStore( + useShallow((state) => ({ + onToggleCollapsed: state.callbacks.onToggleCollapsed, + onBuildFast: state.callbacks.onBuildFast, + onBuildMax: state.callbacks.onBuildMax, + onBuildLite: state.callbacks.onBuildLite, + onFeedback: state.callbacks.onFeedback, + onCloseFeedback: state.callbacks.onCloseFeedback, + })), + ) + // Memoize onOpenFeedback to prevent unnecessary re-renders const onOpenFeedback = useCallback( (options?: { - category?: string + category?: FeedbackCategory footerMessage?: string errors?: Array<{ id: string; message: string }> }) => { @@ -78,7 +143,7 @@ export const MessageWithAgents = memo( const contentBoxStyle = useMemo( () => ({ - backgroundColor: theme.background, + backgroundColor: theme?.background, padding: 0, paddingLeft: SIDE_GUTTER, paddingRight: SIDE_GUTTER, @@ -89,30 +154,11 @@ export const MessageWithAgents = memo( flexGrow: 1, justifyContent: 'center' as const, }), - [theme.background], + [theme?.background], ) if (isAgent) { - return ( - - ) + return } const isAi = message.variant === 'ai' @@ -133,31 +179,39 @@ export const MessageWithAgents = memo( /> ) } - const lineColor = isError ? 'red' : isAi ? theme.aiLine : theme.userLine - const textColor = isError - ? theme.foreground + + const lineColor = isError + ? 'red' + : isAi + ? theme?.aiLine ?? 'white' + : theme?.userLine ?? 'white' + const textColor = theme?.foreground ?? 'white' + const timestampColor = isError + ? 'red' : isAi - ? theme.foreground - : theme.foreground - const timestampColor = isError ? 'red' : isAi ? theme.muted : theme.muted + ? theme?.muted ?? 'white' + : theme?.muted ?? 'white' + const estimatedMessageWidth = availableWidth const codeBlockWidth = Math.max(10, estimatedMessageWidth - 8) - const paletteForMessage: MarkdownPalette = useMemo( - () => ({ + + const paletteForMessage: MarkdownPalette | undefined = useMemo( + () => markdownPalette ? { ...markdownPalette, codeTextFg: textColor, - }), + } : undefined, [markdownPalette, textColor], ) + const markdownOptions = useMemo( - () => ({ codeBlockWidth, palette: paletteForMessage }), + () => ({ codeBlockWidth, palette: paletteForMessage! }), [codeBlockWidth, paletteForMessage], ) const isLoading = isAi && message.content === '' && !message.blocks && isWaitingForResponse - const agentChildren = messageTree.get(message.id) ?? [] + const agentChildren = messageTree?.get(message.id) ?? [] const hasAgentChildren = agentChildren.length > 0 // Show vertical line for user messages (including bash commands which are now user messages) const showVerticalLine = isUser @@ -213,17 +267,19 @@ export const MessageWithAgents = memo( timestampColor={timestampColor} markdownOptions={markdownOptions} availableWidth={availableWidth} - markdownPalette={markdownPalette} - streamingAgents={streamingAgents} + markdownPalette={markdownPalette!} onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} onBuildMax={onBuildMax} + onBuildLite={onBuildLite} onFeedback={onFeedback} onCloseFeedback={onCloseFeedback} validationErrors={message.validationErrors} + userError={message.userError} onOpenFeedback={onOpenFeedback} attachments={message.attachments} textAttachments={message.textAttachments} + fileAttachments={message.fileAttachments} metadata={message.metadata} isLastMessage={isLastMessage} /> @@ -247,15 +303,19 @@ export const MessageWithAgents = memo( timestampColor={timestampColor} markdownOptions={markdownOptions} availableWidth={availableWidth} - markdownPalette={markdownPalette} - streamingAgents={streamingAgents} + markdownPalette={markdownPalette!} onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} onBuildMax={onBuildMax} + onBuildLite={onBuildLite} onFeedback={onFeedback} onCloseFeedback={onCloseFeedback} + validationErrors={message.validationErrors} + userError={message.userError} + onOpenFeedback={onOpenFeedback} attachments={message.attachments} textAttachments={message.textAttachments} + fileAttachments={message.fileAttachments} metadata={message.metadata} isLastMessage={isLastMessage} /> @@ -264,31 +324,11 @@ export const MessageWithAgents = memo( {hasAgentChildren && ( - - {agentChildren.map((agent) => ( - - - - ))} - + )} ) @@ -298,55 +338,39 @@ export const MessageWithAgents = memo( interface AgentMessageProps { message: ChatMessage depth: number - theme: ChatTheme - markdownPalette: MarkdownPalette - streamingAgents: Set - messageTree: Map - messages: ChatMessage[] availableWidth: number - setFocusedAgentId: React.Dispatch> - isWaitingForResponse: boolean - timerStartTime: number | null - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - onFeedback: ( - messageId: string, - options?: { - category?: string - footerMessage?: string - errors?: Array<{ id: string; message: string }> - }, - ) => void - onCloseFeedback: () => void } const AgentMessage = memo( - ({ - message, - depth, - theme, - markdownPalette, - streamingAgents, - messageTree, - messages, - availableWidth, - setFocusedAgentId, - isWaitingForResponse, - timerStartTime, - onToggleCollapsed, - onBuildFast, - onBuildMax, - onFeedback, - onCloseFeedback, - }: AgentMessageProps): ReactNode => { - const agentInfo = message.agent! + ({ message, depth, availableWidth }: AgentMessageProps): ReactNode => { + // Use useShallow for grouped selectors to prevent unnecessary re-renders + const { theme, markdownPalette, messageTree, onToggleCollapsed } = useMessageBlockStore( + useShallow((state) => ({ + theme: state.context.theme, + markdownPalette: state.context.markdownPalette, + messageTree: state.context.messageTree, + onToggleCollapsed: state.callbacks.onToggleCollapsed, + })), + ) + + // Derive streaming boolean for this specific message to avoid re-renders when other agents change + const isStreaming = useChatStore((state) => state.streamingAgents.has(message.id)) + const setFocusedAgentId = useChatStore((state) => state.setFocusedAgentId) + + // Guard against missing agent info (should not happen for agent variant messages) + if (!message.agent) { + return ( + + Error: Missing agent info for agent message + + ) + } + const agentInfo = message.agent // Get or initialize collapse state from message metadata const isCollapsed = message.metadata?.isCollapsed ?? false - const isStreaming = streamingAgents.has(message.id) - const agentChildren = messageTree.get(message.id) ?? [] + const agentChildren = messageTree?.get(message.id) ?? [] const bulletChar = '• ' const fullPrefix = bulletChar @@ -365,33 +389,28 @@ const AgentMessage = memo( ? lastLine.replace(/[#*_`~\[\]()]/g, '').trim() : '' - const agentCodeBlockWidth = Math.max(10, availableWidth - 12) - const agentPalette: MarkdownPalette = { + const agentCodeBlockWidth = Math.max( + 10, + availableWidth - AGENT_CONTENT_HORIZONTAL_PADDING, + ) + const agentPalette: MarkdownPalette | undefined = markdownPalette ? { ...markdownPalette, - codeTextFg: theme.foreground, - } + codeTextFg: theme?.foreground ?? markdownPalette.codeTextFg, + } : undefined const agentMarkdownOptions = { codeBlockWidth: agentCodeBlockWidth, - palette: agentPalette, + palette: agentPalette!, } const displayContent = hasMarkdown(rawDisplayContent) ? renderMarkdown(rawDisplayContent, agentMarkdownOptions) : rawDisplayContent - const handleTitleClick = (e: any): void => { - if (e && e.stopPropagation) { - e.stopPropagation() - } - + const handleTitleClick = (): void => { onToggleCollapsed(message.id) setFocusedAgentId(message.id) } - const handleContentClick = (e: any): void => { - if (e && e.stopPropagation) { - e.stopPropagation() - } - + const handleContentClick = (): void => { if (!isCollapsed) { return } @@ -416,7 +435,7 @@ const AgentMessage = memo( }} > - {fullPrefix} + {fullPrefix} - {isCollapsed ? '▸ ' : '▾ '} - + {isCollapsed ? '▸ ' : '▾ '} + {agentInfo.agentName} @@ -449,7 +468,7 @@ const AgentMessage = memo( > {isStreaming && isCollapsed && streamingPreview && ( {streamingPreview} @@ -457,7 +476,7 @@ const AgentMessage = memo( )} {!isStreaming && isCollapsed && finishedPreview && ( {finishedPreview} @@ -466,7 +485,7 @@ const AgentMessage = memo( {!isCollapsed && ( {displayContent} @@ -475,37 +494,11 @@ const AgentMessage = memo( {agentChildren.length > 0 && ( - - {agentChildren.map((childAgent) => ( - - - - ))} - + )} ) diff --git a/cli/src/components/mode-divider.tsx b/cli/src/components/mode-divider.tsx index cdd05be55b..40b9fb3845 100644 --- a/cli/src/components/mode-divider.tsx +++ b/cli/src/components/mode-divider.tsx @@ -3,6 +3,7 @@ import React from 'react' import stringWidth from 'string-width' import { useTheme } from '../hooks/use-theme' +import { IS_FREEBUFF } from '../utils/constants' interface ModeDividerProps { mode: string @@ -10,6 +11,8 @@ interface ModeDividerProps { } export const ModeDivider = ({ mode, width }: ModeDividerProps) => { + if (IS_FREEBUFF) return null + const theme = useTheme() const label = ` ${mode} ` diff --git a/cli/src/components/multiline-input.tsx b/cli/src/components/multiline-input.tsx index 31398f29fe..f6f40b31db 100644 --- a/cli/src/components/multiline-input.tsx +++ b/cli/src/components/multiline-input.tsx @@ -1,5 +1,9 @@ -import { TextAttributes } from '@opentui/core' -import { useKeyboard, useRenderer } from '@opentui/react' +import { + decodePasteBytes, + stripAnsiSequences, + TextAttributes, +} from '@opentui/core' +import { useAppContext, useKeyboard, useRenderer } from '@opentui/react' import { forwardRef, useCallback, @@ -11,21 +15,26 @@ import { import { InputCursor } from './input-cursor' import { useTheme } from '../hooks/use-theme' -import { supportsTruecolor } from '../utils/theme-system' import { useChatStore } from '../state/chat-store' -import { logger } from '../utils/logger' import { clamp } from '../utils/math' +import { isLinefeedActingAsEnter, markReturnKeySeen } from '../utils/terminal-enter-detection' +import { supportsTruecolor } from '../utils/theme-system' import { calculateNewCursorPosition } from '../utils/word-wrap-utils' -import type { InputValue } from '../state/chat-store' +import type { InputValue } from '../types/store' import type { KeyEvent, MouseEvent, + PasteEvent, ScrollBoxRenderable, TextBufferView, TextRenderable, } from '@opentui/core' +function getPasteText(event: PasteEvent): string { + return stripAnsiSequences(decodePasteBytes(event.bytes)) +} + // Helper functions for text manipulation function findLineStart(text: string, cursor: number): number { let pos = Math.max(0, Math.min(cursor, text.length)) @@ -142,6 +151,12 @@ function isAltModifier(key: KeyEvent): boolean { ) } +// Helper type for scrollbox with focus/blur methods (not exposed in OpenTUI types but available at runtime) +interface FocusableScrollBox { + focus?: () => void + blur?: () => void +} + interface MultilineInputProps { value: string onChange: (value: InputValue) => void @@ -154,10 +169,12 @@ interface MultilineInputProps { maxHeight?: number minHeight?: number cursorPosition: number + showScrollbar?: boolean } export type MultilineInputHandle = { focus: () => void + blur: () => void } export const MultilineInput = forwardRef< @@ -176,11 +193,14 @@ export const MultilineInput = forwardRef< minHeight = 1, onKeyIntercept, cursorPosition, + showScrollbar = false, }: MultilineInputProps, forwardedRef, ) { const theme = useTheme() const renderer = useRenderer() + const appContext = useAppContext() + const { keyHandler } = appContext const hookBlinkValue = useChatStore((state) => state.isFocusSupported) const effectiveShouldBlinkCursor = shouldBlinkCursor ?? hookBlinkValue @@ -189,6 +209,17 @@ export const MultilineInput = forwardRef< const stickyColumnRef = useRef(null) + // Refs to track latest value and cursor position synchronously for IME input handling. + // When IME sends multiple character events rapidly (e.g., Chinese input), React batches + // state updates, causing subsequent events to see stale closure values. These refs are + // updated synchronously to ensure each keystroke builds on the previous one. + const valueRef = useRef(value) + const cursorPositionRef = useRef(cursorPosition) + + // Keep refs current on every render (synchronous assignment avoids useEffect timing issues) + valueRef.current = value + cursorPositionRef.current = cursorPosition + // Helper to get or set the sticky column for vertical navigation. // When stickyColumnRef.current is set, we return it (preserving column across // multiple up/down presses). When null, we calculate from current cursor position. @@ -224,14 +255,26 @@ export const MultilineInput = forwardRef< ).lineInfo : null + // Focus/blur scrollbox when focused prop changes + const prevFocusedRef = useRef(false) + useEffect(() => { + if (focused && !prevFocusedRef.current) { + (scrollBoxRef.current as FocusableScrollBox | null)?.focus?.() + } else if (!focused && prevFocusedRef.current) { + (scrollBoxRef.current as FocusableScrollBox | null)?.blur?.() + } + prevFocusedRef.current = focused + }, [focused]) + + // Expose focus/blur for imperative use cases useImperativeHandle( forwardedRef, () => ({ focus: () => { - const node = scrollBoxRef.current - if (node && typeof (node as any).focus === 'function') { - ;(node as any).focus() - } + (scrollBoxRef.current as FocusableScrollBox | null)?.focus?.() + }, + blur: () => { + (scrollBoxRef.current as FocusableScrollBox | null)?.blur?.() }, }), [], @@ -240,7 +283,7 @@ export const MultilineInput = forwardRef< const cursorRow = lineInfo ? Math.max( 0, - lineInfo.lineStarts.findLastIndex( + lineInfo.lineStartCols.findLastIndex( (lineStart) => lineStart <= cursorPosition, ), ) @@ -316,31 +359,50 @@ export const MultilineInput = forwardRef< const selection = getSelectionRange() if (selection) { // Replace selected text with the new text + clearSelection() + // Read from refs which have the latest values (updated synchronously below) + const currentValue = valueRef.current const newValue = - value.slice(0, selection.start) + + currentValue.slice(0, selection.start) + textToInsert + - value.slice(selection.end) - clearSelection() + currentValue.slice(selection.end) + const newCursor = selection.start + textToInsert.length + + // Update refs synchronously BEFORE calling onChange - critical for IME input + // where multiple characters may arrive before React processes state updates + valueRef.current = newValue + cursorPositionRef.current = newCursor + onChange({ text: newValue, - cursorPosition: selection.start + textToInsert.length, + cursorPosition: newCursor, lastEditDueToNav: false, }) return } // No selection, insert at cursor + // Read from refs to get latest state (handles rapid IME input) + const currentValue = valueRef.current + const currentCursor = cursorPositionRef.current const newValue = - value.slice(0, cursorPosition) + + currentValue.slice(0, currentCursor) + textToInsert + - value.slice(cursorPosition) + currentValue.slice(currentCursor) + const newCursor = currentCursor + textToInsert.length + + // Update refs synchronously BEFORE calling onChange - critical for IME input + // where multiple characters may arrive before React processes state updates + valueRef.current = newValue + cursorPositionRef.current = newCursor + onChange({ text: newValue, - cursorPosition: cursorPosition + textToInsert.length, + cursorPosition: newCursor, lastEditDueToNav: false, }) }, - [cursorPosition, onChange, value, getSelectionRange, clearSelection], + [onChange, getSelectionRange, clearSelection], ) const moveCursor = useCallback( @@ -367,7 +429,7 @@ export const MultilineInput = forwardRef< const scrollBox = scrollBoxRef.current if (!scrollBox) return - const lineStarts = lineInfo?.lineStarts ?? [0] + const lineStarts = lineInfo?.lineStartCols ?? [0] const viewport = (scrollBox as any).viewport const viewportTop = Number(viewport?.y ?? 0) @@ -470,11 +532,17 @@ export const MultilineInput = forwardRef< const handleEnterKeys = useCallback( (key: KeyEvent): boolean => { const lowerKeyName = (key.name ?? '').toLowerCase() - const isEnterKey = key.name === 'return' || key.name === 'enter' - // Ctrl+J is translated by the terminal to a linefeed character (0x0a) - // So we detect it by checking for name === 'linefeed' rather than ctrl + j + const isReturnOrEnter = key.name === 'return' || key.name === 'enter' + + if (isReturnOrEnter) { + markReturnKeySeen() + } + + const linefeedIsEnter = lowerKeyName === 'linefeed' && isLinefeedActingAsEnter() + const isEnterKey = isReturnOrEnter || linefeedIsEnter + const isCtrlJ = - lowerKeyName === 'linefeed' || + (lowerKeyName === 'linefeed' && !linefeedIsEnter) || (key.ctrl && !key.meta && !key.option && @@ -491,6 +559,7 @@ export const MultilineInput = forwardRef< const hasBackslashBeforeCursor = cursorPosition > 0 && value[cursorPosition - 1] === '\\' + // Plain Enter: no modifiers, sequence is '\r' (macOS) or '\n' (Linux) const isPlainEnter = isEnterKey && !key.shift && @@ -499,10 +568,9 @@ export const MultilineInput = forwardRef< !key.option && !isAltLikeModifier && !hasEscapePrefix && - key.sequence === '\r' && + (key.sequence === '\r' || key.sequence === '\n') && !hasBackslashBeforeCursor - const isShiftEnter = - isEnterKey && (Boolean(key.shift) || key.sequence === '\n') + const isShiftEnter = isEnterKey && Boolean(key.shift) const isOptionEnter = isEnterKey && (isAltLikeModifier || hasEscapePrefix) const isBackslashEnter = isEnterKey && hasBackslashBeforeCursor @@ -563,15 +631,7 @@ export const MultilineInput = forwardRef< if (key.ctrl && lowerKeyName === 'u' && !key.meta && !key.option) { preventKeyDefault(key) if (handleSelectionDeletion()) return true - const visualLineStart = lineInfo?.lineStarts?.[cursorRow] ?? lineStart - - logger.debug('Ctrl+U:', { - cursorPosition, - cursorRow, - visualLineStart, - oldLineStart: lineStart, - lineStarts: lineInfo?.lineStarts, - }) + const visualLineStart = lineInfo?.lineStartCols?.[cursorRow] ?? lineStart if (cursorPosition > visualLineStart) { const newValue = @@ -756,7 +816,7 @@ export const MultilineInput = forwardRef< // Calculate visual line boundaries from lineInfo (accounts for word wrap) // Fall back to logical line boundaries if visual info is unavailable - const lineStarts = currentLineInfo?.lineStarts ?? [] + const lineStarts = currentLineInfo?.lineStartCols ?? [] const visualLineIndex = lineStarts.findLastIndex( (start) => start <= cursorPosition, ) @@ -963,6 +1023,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(getPasteText(event)) + // 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( @@ -1002,7 +1106,7 @@ export const MultilineInput = forwardRef< const effectiveMinHeight = Math.max(1, Math.min(minHeight, safeMaxHeight)) const totalLines = - lineInfo === null ? 0 : lineInfo.lineStarts.length + lineInfo === null ? 0 : lineInfo.lineStartCols.length // Add bottom gutter when cursor is on line 2 of exactly 2 lines const gutterEnabled = @@ -1015,9 +1119,13 @@ export const MultilineInput = forwardRef< const heightLines = Math.max(effectiveMinHeight, rawHeight) + // Content is scrollable when total lines exceed max height + const isScrollable = totalLines > safeMaxHeight + return { heightLines, gutterEnabled, + isScrollable, } })() @@ -1037,7 +1145,16 @@ export const MultilineInput = forwardRef< stickyScroll={true} stickyStart="bottom" scrollbarOptions={{ visible: false }} - onPaste={(event) => onPaste(event.text)} + verticalScrollbarOptions={{ + visible: showScrollbar && layoutMetrics.isScrollable, + trackOptions: { width: 1 }, + }} + 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(getPasteText(event)) + }} onMouseDown={handleMouseDown} style={{ flexGrow: 0, diff --git a/cli/src/components/out-of-credits-banner.tsx b/cli/src/components/out-of-credits-banner.tsx index 054e9b7978..3d68f9f408 100644 --- a/cli/src/components/out-of-credits-banner.tsx +++ b/cli/src/components/out-of-credits-banner.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useState } from 'react' +import { IS_FREEBUFF } from '../utils/constants' +import { ShimmerText } from './shimmer-text' import { getActivityQueryData } from '../hooks/use-activity-query' +import { useTheme } from '../hooks/use-theme' import { usageQueryKeys, useUsageQuery } from '../hooks/use-usage-query' import { useChatStore } from '../state/chat-store' -import { useTheme } from '../hooks/use-theme' import { BORDER_CHARS } from '../utils/ui-constants' -import { ShimmerText } from './shimmer-text' const CREDIT_POLL_INTERVAL = 5 * 1000 // Poll every 5 seconds @@ -15,6 +16,8 @@ let creditsRestoredGlobal = false export const areCreditsRestored = () => creditsRestoredGlobal export const OutOfCreditsBanner = () => { + if (IS_FREEBUFF) return null + const sessionCreditsUsed = useChatStore((state) => state.sessionCreditsUsed) const [creditsRestored, setCreditsRestored] = useState(false) diff --git a/cli/src/components/pending-attachments-banner.tsx b/cli/src/components/pending-attachments-banner.tsx index 79c9e8553b..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 '../state/chat-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/components/pending-bash-message.tsx b/cli/src/components/pending-bash-message.tsx index 95fd2901bb..fc65096968 100644 --- a/cli/src/components/pending-bash-message.tsx +++ b/cli/src/components/pending-bash-message.tsx @@ -4,7 +4,7 @@ import { TerminalCommandDisplay } from './terminal-command-display' import { useTheme } from '../hooks/use-theme' import { DASHED_BORDER_CHARS } from '../utils/ui-constants' -import type { PendingBashMessage as PendingBashMessageType } from '../state/chat-store' +import type { PendingBashMessage as PendingBashMessageType } from '../types/store' interface PendingBashMessageProps { message: PendingBashMessageType diff --git a/cli/src/components/progress-bar.tsx b/cli/src/components/progress-bar.tsx index e161772d27..e9e18353d0 100644 --- a/cli/src/components/progress-bar.tsx +++ b/cli/src/components/progress-bar.tsx @@ -32,14 +32,16 @@ const getProgressColor = ( /** * Get color for the filled portion of the bar + * Uses muted color for healthy capacity (>25%) to avoid drawing attention, + * warning/error colors only when running low */ const getBarColor = ( value: number, - theme: { primary: string; warning: string; error: string }, + theme: { muted: string; warning: string; error: string }, ): string => { if (value <= 10) return theme.error if (value <= 25) return theme.warning - return theme.primary // Use primary for the bar itself + return theme.muted } /** @@ -70,7 +72,7 @@ export const ProgressBar: React.FC = ({ {label && {label} } {filled} - {empty} + {emptyWidth > 0 && {empty}} {showPercentage && ( {Math.round(clampedValue)}% )} diff --git a/cli/src/components/project-picker-screen.tsx b/cli/src/components/project-picker-screen.tsx index ce9a47f6f2..71fdb1cc1b 100644 --- a/cli/src/components/project-picker-screen.tsx +++ b/cli/src/components/project-picker-screen.tsx @@ -67,7 +67,6 @@ export const ProjectPickerScreen: React.FC = ({ currentPath, setCurrentPath, directories, - isGitRepo, expandPath, tryNavigateToPath, navigateToDirectory, diff --git a/cli/src/components/publish-confirmation.tsx b/cli/src/components/publish-confirmation.tsx index 1a982099bd..270bda37ef 100644 --- a/cli/src/components/publish-confirmation.tsx +++ b/cli/src/components/publish-confirmation.tsx @@ -302,11 +302,6 @@ export const PublishConfirmation: React.FC = ({ [dependentIds, allAgents] ) - const totalCount = - selectedList.length + - dependencyList.length + - (includeDependents ? dependentList.length : 0) - const hasDependents = dependentList.length > 0 const hasDependencies = dependencyList.length > 0 diff --git a/cli/src/components/publish-container.tsx b/cli/src/components/publish-container.tsx index 207d4c1c7e..729b5b14e7 100644 --- a/cli/src/components/publish-container.tsx +++ b/cli/src/components/publish-container.tsx @@ -1,8 +1,8 @@ +import { pluralize } from '@codebuff/common/util/string' import { TextAttributes } from '@opentui/core' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' -import { pluralize } from '@codebuff/common/util/string' import { AgentChecklist } from './agent-checklist' import { Button } from './button' @@ -14,10 +14,9 @@ import { useTerminalLayout } from '../hooks/use-terminal-layout' import { useTheme } from '../hooks/use-theme' import { useChatStore } from '../state/chat-store' import { usePublishStore } from '../state/publish-store' -import { BORDER_CHARS } from '../utils/ui-constants' import { loadLocalAgents, loadAgentDefinitions } from '../utils/local-agent-registry' +import { BORDER_CHARS } from '../utils/ui-constants' -import type { LocalAgentInfo } from '../utils/local-agent-registry' interface PublishContainerProps { inputRef: React.MutableRefObject diff --git a/cli/src/components/referral-banner.tsx b/cli/src/components/referral-banner.tsx deleted file mode 100644 index 36c5000c17..0000000000 --- a/cli/src/components/referral-banner.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { WEBSITE_URL } from '@codebuff/sdk' -import React from 'react' - -import { BottomBanner } from './bottom-banner' -import { useChatStore } from '../state/chat-store' - -export const ReferralBanner = () => { - const setInputMode = useChatStore((state) => state.setInputMode) - - const referralUrl = `${WEBSITE_URL}/referrals` - - return ( - setInputMode('default')} - /> - ) -} diff --git a/cli/src/components/renderers/plan-box.tsx b/cli/src/components/renderers/plan-box.tsx index 80a0895339..c7853032ad 100644 --- a/cli/src/components/renderers/plan-box.tsx +++ b/cli/src/components/renderers/plan-box.tsx @@ -11,6 +11,7 @@ interface PlanBoxProps { markdownPalette: MarkdownPalette onBuildFast: () => void onBuildMax: () => void + onBuildLite: () => void } export const PlanBox = memo( @@ -20,6 +21,7 @@ export const PlanBox = memo( markdownPalette, onBuildFast, onBuildMax, + onBuildLite, }: PlanBoxProps) => { const theme = useTheme() @@ -48,6 +50,7 @@ export const PlanBox = memo( theme={theme} onBuildFast={onBuildFast} onBuildMax={onBuildMax} + onBuildLite={onBuildLite} /> ) diff --git a/cli/src/components/review-screen.tsx b/cli/src/components/review-screen.tsx new file mode 100644 index 0000000000..98d8f7d160 --- /dev/null +++ b/cli/src/components/review-screen.tsx @@ -0,0 +1,114 @@ +import { useKeyboard } from '@opentui/react' +import React, { useCallback, useState } from 'react' + +import { buildReviewPrompt, REVIEW_BASE_PROMPT } from '../commands/prompt-builders' +import { useTheme } from '../hooks/use-theme' +import { BORDER_CHARS } from '../utils/ui-constants' + +import type { KeyEvent } from '@opentui/core' + +interface ReviewOption { + id: string + label: string +} + +const REVIEW_OPTIONS: ReviewOption[] = [ + { id: 'conversation', label: 'Changes this conversation' }, + { id: 'uncommitted', label: 'Uncommitted changes' }, + { id: 'branch', label: 'This branch vs main' }, + { id: 'custom', label: 'Custom...' }, +] + +// Re-export for backward compatibility +export { REVIEW_BASE_PROMPT } + +interface ReviewScreenProps { + onSelectOption: (reviewText: string) => void + onCustom: () => void + onCancel: () => void +} + +export const ReviewScreen: React.FC = ({ + onSelectOption, + onCustom, + onCancel, +}) => { + const theme = useTheme() + const [selectedIndex, setSelectedIndex] = useState(0) + + const handleSelect = useCallback( + (option: ReviewOption) => { + if (option.id === 'custom') { + onCustom() + return + } + + const scope = option.id as 'conversation' | 'uncommitted' | 'branch' + const reviewText = buildReviewPrompt(scope) + onSelectOption(reviewText) + }, + [onSelectOption, onCustom], + ) + + useKeyboard( + useCallback( + (key: KeyEvent) => { + if (key.name === 'up') { + setSelectedIndex((prev) => Math.max(0, prev - 1)) + return + } + if (key.name === 'down') { + setSelectedIndex((prev) => Math.min(REVIEW_OPTIONS.length - 1, prev + 1)) + return + } + if (key.name === 'return' || key.name === 'enter') { + const option = REVIEW_OPTIONS[selectedIndex] + if (option) { + handleSelect(option) + } + return + } + if (key.name === 'escape') { + onCancel() + return + } + }, + [selectedIndex, handleSelect, onCancel], + ), + ) + + return ( + + {REVIEW_OPTIONS.map((option, index) => { + const isSelected = index === selectedIndex + return ( + + {isSelected ? '❯ ' : ' '} + {option.label} + + ) + })} + + ↑↓ navigate · Enter select · Esc cancel + + + ) +} diff --git a/cli/src/components/selectable-list.tsx b/cli/src/components/selectable-list.tsx index 7c461ede36..e7a75d4763 100644 --- a/cli/src/components/selectable-list.tsx +++ b/cli/src/components/selectable-list.tsx @@ -40,6 +40,8 @@ export interface SelectableListProps { /** Optional max height - if not provided, list fills available space */ maxHeight?: number onSelect: (item: SelectableListItem, index: number) => void + actionLabel?: string + onAction?: (item: SelectableListItem, index: number) => void onFocusChange?: (index: number) => void emptyMessage?: string } @@ -53,7 +55,16 @@ export const SelectableList = forwardRef< SelectableListProps >( ( - { items, focusedIndex, maxHeight, onSelect, onFocusChange, emptyMessage = 'No items' }, + { + items, + focusedIndex, + maxHeight, + onSelect, + actionLabel, + onAction, + onFocusChange, + emptyMessage = 'No items', + }, ref, ) => { const theme = useTheme() @@ -141,14 +152,21 @@ export const SelectableList = forwardRef< const isHighlighted = isFocused || isHovered // Use subtle highlight that works in both light and dark themes - const backgroundColor = isHighlighted ? theme.surfaceHover : 'transparent' + const backgroundColor = isHighlighted + ? theme.surfaceHover + : 'transparent' const textColor = isHighlighted ? theme.foreground : theme.muted - const textAttributes = isHighlighted ? TextAttributes.BOLD : undefined return ( - + {actionLabel && onAction && ( + )} - + ) })} diff --git a/cli/src/components/session-ended-banner.tsx b/cli/src/components/session-ended-banner.tsx new file mode 100644 index 0000000000..b99ac28536 --- /dev/null +++ b/cli/src/components/session-ended-banner.tsx @@ -0,0 +1,189 @@ +import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' +import { TextAttributes } from '@opentui/core' +import { useKeyboard } from '@opentui/react' +import React, { useCallback, useState } from 'react' + +import { Button } from './button' +import { + refreshFreebuffSession, + returnToFreebuffLanding, +} from '../hooks/use-freebuff-session' +import { useTheme } from '../hooks/use-theme' +import { useFreebuffSessionStore } from '../state/freebuff-session-store' +import { formatSessionUnits } from '../utils/format-session-units' +import { BORDER_CHARS } from '../utils/ui-constants' + +import type { KeyEvent } from '@opentui/core' + +interface SessionEndedBannerProps { + /** True while an agent request is still streaming under the server-side + * grace window. Swaps the Enter-to-rejoin affordance for a "let it + * finish" hint so the user doesn't abort their in-flight work. */ + isStreaming: boolean +} + +/** + * Replaces the chat input when the freebuff session has ended. Captures + * Enter to start a new same-chat session. Esc returns to model selection + * once no in-flight work needs the global stream-interrupt handler. + */ +export const SessionEndedBanner: React.FC = ({ + isStreaming, +}) => { + const theme = useTheme() + const [pendingAction, setPendingAction] = useState< + 'waiting-room' | 'same-chat' | null + >(null) + + // All premium models share one daily pool; the server replicates the same + // snapshot under each premium model id, so the first entry has the right + // count. + const premiumQuota = useFreebuffSessionStore( + (s) => Object.values(getRateLimitsByModel(s.session) ?? {})[0] ?? null, + ) + const isQuotaExhausted = premiumQuota + ? premiumQuota.recentCount >= premiumQuota.limit + : false + const accessTier = useFreebuffSessionStore((s) => + s.session && 'accessTier' in s.session ? s.session.accessTier : 'full', + ) + const quotaLabel = + accessTier === 'limited' ? 'sessions' : 'premium sessions' + const bannerTitle = premiumQuota + ? `Session ended · ${formatSessionUnits(premiumQuota.recentCount)} of ${premiumQuota.limit} ${quotaLabel} used today` + : 'Session ended' + const landingButtonLabel = + accessTier === 'limited' ? 'Back to start' : 'Change model' + const landingPendingLabel = + accessTier === 'limited' + ? 'Opening start screen…' + : 'Opening model selection…' + + // While a request is still streaming, restart is disabled: it would + // unmount and abort the in-flight agent run. The promise is "we + // let the agent finish" — honoring that means Enter does nothing until + // the stream ends or the user hits Esc. + const canRestart = !isStreaming && pendingAction === null + const pickNewModel = useCallback(() => { + if (!canRestart) return + setPendingAction('waiting-room') + // Drop back to the landing picker (status: 'none') so the user picks a + // model and hits Enter again to commit, instead of being silently + // re-queued. app.tsx swaps us into on the + // transition, unmounting this banner — no need to clear the pending state on + // success. + returnToFreebuffLanding({ resetChat: true }).catch(() => + setPendingAction(null), + ) + }, [canRestart]) + + const startSameChatSession = useCallback(() => { + if (!canRestart) return + setPendingAction('same-chat') + // Re-POST with the currently selected model and keep the chat/run state + // intact so the next prompt continues the same conversation. + refreshFreebuffSession().catch(() => setPendingAction(null)) + }, [canRestart]) + + useKeyboard( + useCallback( + (key: KeyEvent) => { + if (!canRestart) return + if (key.name === 'return' || key.name === 'enter') { + key.preventDefault?.() + startSameChatSession() + return + } + if (key.name === 'escape') { + key.preventDefault?.() + pickNewModel() + } + }, + [startSameChatSession, pickNewModel, canRestart], + ), + ) + + return ( + + {isStreaming ? ( + + Agent is wrapping up. Rejoin the wait room after it's finished. + + ) : ( + + + + + + )} + + ) +} diff --git a/cli/src/components/status-bar.tsx b/cli/src/components/status-bar.tsx index 37977cc675..11e7f7875e 100644 --- a/cli/src/components/status-bar.tsx +++ b/cli/src/components/status-bar.tsx @@ -1,20 +1,79 @@ +import { getFreebuffModel } from '@codebuff/common/constants/freebuff-models' +import { TextAttributes } from '@opentui/core' import React, { useEffect, useState } from 'react' +import { Button } from './button' import { ScrollToBottomButton } from './scroll-to-bottom-button' import { ShimmerText } from './shimmer-text' + +import { useFreebuffSessionProgress } from '../hooks/use-freebuff-session-progress' import { useTheme } from '../hooks/use-theme' import { formatElapsedTime } from '../utils/format-elapsed-time' +import type { FreebuffSessionResponse } from '../types/freebuff-session' import type { StatusIndicatorState } from '../utils/status-indicator-state' +/** A small status-bar action button with hover-bold styling. */ +const StatusActionButton = ({ + children, + onClick, +}: { + children: React.ReactNode + onClick: () => void +}) => { + const theme = useTheme() + const [hovered, setHovered] = useState(false) + + return ( + + ) +} const SHIMMER_INTERVAL_MS = 160 +/** Show the "X:XX left" urgency readout under this many ms remaining. */ +const COUNTDOWN_VISIBLE_MS = 5 * 60_000 + +const formatCountdown = (ms: number): string => { + if (ms <= 0) return 'expiring…' + const totalSeconds = Math.ceil(ms / 1000) + const m = Math.floor(totalSeconds / 60) + const s = totalSeconds % 60 + return `${m}:${s.toString().padStart(2, '0')}` +} + +const formatSessionRemaining = (ms: number): string => { + if (ms <= 0) return 'expiring…' + if (ms < COUNTDOWN_VISIBLE_MS) return `${formatCountdown(ms)} left` + const totalMinutes = Math.ceil(ms / 60_000) + if (totalMinutes < 60) return `${totalMinutes}m left` + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + return minutes === 0 ? `${hours}h left` : `${hours}h ${minutes}m left` +} + interface StatusBarProps { timerStartTime: number | null isAtBottom: boolean scrollToLatest: () => void statusIndicatorState: StatusIndicatorState + onStop?: () => void + onEndSession?: () => void + freebuffSession: FreebuffSessionResponse | null } export const StatusBar = ({ @@ -22,6 +81,9 @@ export const StatusBar = ({ isAtBottom, scrollToLatest, statusIndicatorState, + onStop, + onEndSession, + freebuffSession, }: StatusBarProps) => { const theme = useTheme() const [elapsedSeconds, setElapsedSeconds] = useState(0) @@ -60,34 +122,32 @@ export const StatusBar = ({ return () => clearInterval(interval) }, [timerStartTime, shouldShowTimer, statusIndicatorState?.kind]) + const sessionProgress = useFreebuffSessionProgress(freebuffSession) + const renderStatusIndicator = () => { switch (statusIndicatorState.kind) { case 'ctrlC': return Press Ctrl-C again to exit - + case 'clipboard': // Use green color for feedback success messages - const isFeedbackSuccess = statusIndicatorState.message.includes('Feedback sent') + const isFeedbackSuccess = + statusIndicatorState.message.includes('Feedback sent') return ( {statusIndicatorState.message} ) - + case 'reconnected': return Reconnected - + case 'retrying': - return ( - - ) - + return + case 'connecting': return - + case 'waiting': return ( ) - + case 'streaming': return ( ) - + case 'paused': return null - + case 'idle': + if (sessionProgress !== null) { + const isUrgent = sessionProgress.remainingMs < COUNTDOWN_VISIBLE_MS + const modelName = + freebuffSession?.status === 'active' + ? getFreebuffModel(freebuffSession.model).displayName + : null + return ( + + {modelName ? `${modelName} · ` : ''} + {formatSessionRemaining(sessionProgress.remainingMs)} + + ) + } return null } } @@ -125,8 +198,11 @@ export const StatusBar = ({ const statusIndicatorContent = renderStatusIndicator() const elapsedTimeContent = renderElapsedTime() - // Only show gray background when there's status indicator or timer - const hasContent = statusIndicatorContent || elapsedTimeContent + // Show gray background when there's status indicator, timer, or when the + // freebuff session fill is visible (otherwise the fill would float over + // transparent space). + const hasContent = + statusIndicatorContent || elapsedTimeContent || sessionProgress !== null return ( + {sessionProgress !== null && ( + + )} {elapsedTimeContent} + {onStop && + (statusIndicatorState.kind === 'waiting' || + statusIndicatorState.kind === 'streaming') && ( + ■ Esc + )} + {onEndSession && + statusIndicatorState.kind === 'idle' && + freebuffSession?.status === 'active' && ( + + ✕ End session + + )} + {sessionProgress !== null && + sessionProgress.remainingMs < COUNTDOWN_VISIBLE_MS && + statusIndicatorState.kind !== 'idle' && ( + + + {formatCountdown(sessionProgress.remainingMs)} + + + )} ) diff --git a/cli/src/components/subscription-limit-banner.tsx b/cli/src/components/subscription-limit-banner.tsx new file mode 100644 index 0000000000..bc193090ae --- /dev/null +++ b/cli/src/components/subscription-limit-banner.tsx @@ -0,0 +1,206 @@ +import { SUBSCRIPTION_TIERS } from '@codebuff/common/constants/subscription-plans' +import { IS_FREEBUFF } from '../utils/constants' +import { safeOpen } from '../utils/open-url' +import React from 'react' + +import { Button } from './button' +import { ProgressBar } from './progress-bar' +import { useSubscriptionQuery } from '../hooks/use-subscription-query' +import { useTheme } from '../hooks/use-theme' +import { useUpdatePreference } from '../hooks/use-update-preference' +import { useUsageQuery } from '../hooks/use-usage-query' +import { WEBSITE_URL } from '../login/constants' +import { useChatStore } from '../state/chat-store' +import { formatResetTime } from '../utils/time-format' +import { BORDER_CHARS } from '../utils/ui-constants' + +export const SubscriptionLimitBanner = () => { + if (IS_FREEBUFF) return null + + const setInputMode = useChatStore((state) => state.setInputMode) + const theme = useTheme() + + const { data: subscriptionData } = useSubscriptionQuery({ + refetchInterval: 15 * 1000, + }) + + const { data: usageData } = useUsageQuery({ + enabled: true, + refetchInterval: 30 * 1000, + }) + + const rateLimit = subscriptionData?.hasSubscription ? subscriptionData.rateLimit : undefined + const remainingBalance = usageData?.remainingBalance ?? 0 + const hasAlaCarteCredits = remainingBalance > 0 + + // Determine if user can upgrade (not on highest tier) + const maxTier = Math.max(...Object.keys(SUBSCRIPTION_TIERS).map(Number)) + const currentTier = subscriptionData?.hasSubscription ? subscriptionData.subscription.tier : 0 + const canUpgrade = currentTier < maxTier + + const fallbackToALaCarte = subscriptionData?.fallbackToALaCarte ?? false + const updatePreference = useUpdatePreference() + + const handleToggleFallbackToALaCarte = () => { + updatePreference.mutate({ fallbackToALaCarte: !fallbackToALaCarte }) + } + + if (!subscriptionData || !rateLimit?.limited) { + return null + } + + const { reason, weeklyPercentUsed, weeklyResetsAt: weeklyResetsAtStr, blockResetsAt: blockResetsAtStr } = rateLimit + const isWeeklyLimit = reason === 'weekly_limit' + const isBlockExhausted = reason === 'block_exhausted' + const weeklyRemaining = 100 - weeklyPercentUsed + const weeklyResetsAt = weeklyResetsAtStr ? new Date(weeklyResetsAtStr) : null + const blockResetsAt = blockResetsAtStr ? new Date(blockResetsAtStr) : null + + const handleContinueWithCredits = () => { + setInputMode('default') + } + + const handleBuyCredits = () => { + safeOpen(WEBSITE_URL + '/usage') + } + + const handleUpgrade = () => { + safeOpen(WEBSITE_URL + '/subscribe') + } + + const borderColor = isWeeklyLimit ? theme.error : theme.warning + + return ( + + + {isWeeklyLimit ? ( + <> + + 🛑 Weekly limit reached + + + You've used all {rateLimit.weeklyLimit.toLocaleString()} credits for this week. + + {weeklyResetsAt && ( + + Weekly usage resets in {formatResetTime(weeklyResetsAt)} + + )} + + ) : isBlockExhausted ? ( + <> + + 5 hour limit reached + + {blockResetsAt && ( + + New session starts in {formatResetTime(blockResetsAt)} + + )} + + ) : ( + + Subscription limit reached + + )} + + + Weekly: + + {weeklyPercentUsed}% used + + + {hasAlaCarteCredits ? ( + + {fallbackToALaCarte ? ( + <> + + ✓ Credit spending enabled. You can continue using your credits. + + + + {canUpgrade ? ( + + ) : ( + + )} + + + + ) : ( + <> + + Credit spending is disabled. Enable it to continue. + + + + {canUpgrade ? ( + + ) : ( + + )} + + + You have {remainingBalance.toLocaleString()} credits available. + + + )} + + ) : ( + + No a-la-carte credits available. + {canUpgrade ? ( + + ) : ( + + )} + + )} + + + ) +} diff --git a/cli/src/components/terminal-command-display.tsx b/cli/src/components/terminal-command-display.tsx index 465a721946..1f72fe8e2c 100644 --- a/cli/src/components/terminal-command-display.tsx +++ b/cli/src/components/terminal-command-display.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { Button } from './button' import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' import { useTheme } from '../hooks/use-theme' +import { formatTimeout } from '../utils/format-timeout' import { getLastNVisualLines } from '../utils/text-layout' interface TerminalCommandDisplayProps { @@ -17,29 +18,40 @@ interface TerminalCommandDisplayProps { isRunning?: boolean /** Working directory where the command was run */ cwd?: string + /** Timeout in seconds for the command */ + timeoutSeconds?: number + /** Optional width override for wrapping calculations */ + availableWidth?: number } /** * Shared component for displaying terminal command with output. * Used in both the ghost message (pending bash) and message history. */ - export const TerminalCommandDisplay = ({ command, output, expandable = true, maxVisibleLines, isRunning = false, + timeoutSeconds, + availableWidth, }: TerminalCommandDisplayProps) => { const theme = useTheme() - const { contentMaxWidth } = useTerminalDimensions() - const padding = 5 + const { separatorWidth } = useTerminalDimensions() const [isExpanded, setIsExpanded] = useState(false) // Default max lines depends on whether expandable const defaultMaxLines = expandable ? 5 : 10 const maxLines = maxVisibleLines ?? defaultMaxLines + // Format timeout display - show when provided and not the default (30s) + const DEFAULT_TIMEOUT_SECONDS = 30 + const timeoutLabel = + timeoutSeconds !== undefined && timeoutSeconds !== DEFAULT_TIMEOUT_SECONDS + ? formatTimeout(timeoutSeconds) + : null + // Command header - shared between output and no-output cases const commandHeader = ( @@ -47,6 +59,11 @@ export const TerminalCommandDisplay = ({ {command} + {timeoutLabel && ( + + {' '}({timeoutLabel}) + + )} ) @@ -62,7 +79,7 @@ export const TerminalCommandDisplay = ({ } // With output - calculate visual lines - const width = Math.max(10, Math.min(contentMaxWidth - padding * 2, 120)) + const width = Math.max(10, availableWidth ?? separatorWidth) const allLines = output.split('\n') // Calculate total visual lines across all output lines diff --git a/cli/src/components/text-attachment-card.tsx b/cli/src/components/text-attachment-card.tsx index 1807fb9f7f..bc66448a68 100644 --- a/cli/src/components/text-attachment-card.tsx +++ b/cli/src/components/text-attachment-card.tsx @@ -1,7 +1,7 @@ import { AttachmentCard } from './attachment-card' import { useTheme } from '../hooks/use-theme' -import type { PendingTextAttachment } from '../state/chat-store' +import type { PendingTextAttachment } from '../types/store' const TEXT_CARD_WIDTH = 24 const MAX_PREVIEW_LINES = 2 diff --git a/cli/src/components/thinking.tsx b/cli/src/components/thinking.tsx index 75448c944d..6fbf28db50 100644 --- a/cli/src/components/thinking.tsx +++ b/cli/src/components/thinking.tsx @@ -6,11 +6,15 @@ import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' import { useTheme } from '../hooks/use-theme' import { getLastNVisualLines } from '../utils/text-layout' -const PREVIEW_LINE_COUNT = 3 +import type { ThinkingCollapseState } from '../types/chat' + +const PREVIEW_LINE_COUNT = 5 interface ThinkingProps { content: string - isCollapsed: boolean + thinkingCollapseState: ThinkingCollapseState + /** Whether the thinking has completed (streaming finished) */ + isThinkingComplete: boolean onToggle: () => void availableWidth?: number } @@ -18,15 +22,24 @@ interface ThinkingProps { export const Thinking = memo( ({ content, - isCollapsed, + thinkingCollapseState, + isThinkingComplete, onToggle, availableWidth, }: ThinkingProps): ReactNode => { const theme = useTheme() const { contentMaxWidth } = useTerminalDimensions() + // Special case: single **bold** string under 100 chars gets compact rendering + const singleBoldMatch = content.length < 100 ? content.trim().match(/^\*\*([^*]+)\*\*$/) : null + if (singleBoldMatch) { + return ( + null + ) + } + const width = Math.max(10, availableWidth ?? contentMaxWidth) - // Normalize content to single line for consistent preview + // Normalize content to single line for consistent preview (but preserve in expanded mode) const normalizedContent = content.replace(/\n+/g, ' ').trim() // Account for "..." prefix (3 chars) when calculating line widths const effectiveWidth = width - 3 @@ -35,36 +48,44 @@ export const Thinking = memo( effectiveWidth, PREVIEW_LINE_COUNT, ) + // In expanded mode, preserve original line breaks for proper markdown rendering + const expandedContent = content.replace(/\n\n+/g, '\n\n').trim() + + const showFull = thinkingCollapseState === 'expanded' + const showPreview = thinkingCollapseState === 'preview' && lines.length > 0 + + const toggleIndicator = + !isThinkingComplete ? '• ' + : showFull ? '▾ ' + : showPreview ? '• ' + : '▸ ' return ( + + ) +} + +export const RenderUIComponent = defineToolComponent({ + toolName: 'render_ui', + + render(toolBlock): ToolRenderConfig { + const widget = toolBlock.input?.widget + + if (!isRenderUIButtonWidget(widget)) { + return { content: null } + } + + return { + content: , + collapsedPreview: `${widget.text} -> ${widget.link}`, + } + }, +}) diff --git a/cli/src/components/tools/run-terminal-command.tsx b/cli/src/components/tools/run-terminal-command.tsx index f97d2fd5d1..51b0d2f0c3 100644 --- a/cli/src/components/tools/run-terminal-command.tsx +++ b/cli/src/components/tools/run-terminal-command.tsx @@ -3,6 +3,44 @@ import { TerminalCommandDisplay } from '../terminal-command-display' import type { ToolRenderConfig } from './types' +export interface ParsedTerminalOutput { + output: string | null + startingCwd?: string +} + +/** + * Parse terminal command output from JSON or raw string format. + * Exported for testing. + */ +export const parseTerminalOutput = (rawOutput: string | undefined): ParsedTerminalOutput => { + if (!rawOutput) { + return { output: null } + } + + try { + const parsed = JSON.parse(rawOutput) + // Handle array format [{ type: 'json', value: {...} }] + const value = Array.isArray(parsed) ? parsed[0]?.value : parsed + if (value) { + const startingCwd = value.startingCwd + // Handle error case + if (value.errorMessage) { + return { output: `Error: ${value.errorMessage}`, startingCwd } + } + // Combine stdout and stderr for display + // Use trimEnd() to preserve leading spaces (used for UI elements like trees/tables) + const stdout = value.stdout || '' + const stderr = value.stderr || '' + const output = (stdout + stderr).trimEnd() || null + return { output, startingCwd } + } + return { output: null } + } catch { + // If not JSON, use raw output (preserve leading spaces) + return { output: rawOutput.trimEnd() || null } + } +} + /** * UI component for run_terminal_command tool. * Displays the command in bold next to the bullet point, @@ -11,39 +49,14 @@ import type { ToolRenderConfig } from './types' export const RunTerminalCommandComponent = defineToolComponent({ toolName: 'run_terminal_command', - render(toolBlock): ToolRenderConfig { - // Extract command from input - const command = - toolBlock.input && typeof (toolBlock.input as any).command === 'string' - ? (toolBlock.input as any).command.trim() - : '' + render(toolBlock, _theme, options): ToolRenderConfig { + // Extract command and timeout from input + const input = toolBlock.input as { command?: string; timeout_seconds?: number } | undefined + const command = typeof input?.command === 'string' ? input.command.trim() : '' + const timeoutSeconds = typeof input?.timeout_seconds === 'number' ? input.timeout_seconds : undefined // Extract output and startingCwd from tool result - let output: string | null = null - let startingCwd: string | undefined - - if (toolBlock.output) { - try { - const parsed = JSON.parse(toolBlock.output) - // Handle array format [{ type: 'json', value: {...} }] - const value = Array.isArray(parsed) ? parsed[0]?.value : parsed - if (value) { - startingCwd = value.startingCwd - // Handle error case - if (value.errorMessage) { - output = `Error: ${value.errorMessage}` - } else { - // Combine stdout and stderr for display - const stdout = value.stdout || '' - const stderr = value.stderr || '' - output = (stdout + stderr).trim() || null - } - } - } catch { - // If not JSON, use raw output - output = toolBlock.output.trim() || null - } - } + const { output, startingCwd } = parseTerminalOutput(toolBlock.output) // Custom content component using shared TerminalCommandDisplay const content = ( @@ -53,6 +66,8 @@ export const RunTerminalCommandComponent = defineToolComponent({ expandable={true} maxVisibleLines={5} cwd={startingCwd} + timeoutSeconds={timeoutSeconds} + availableWidth={options.availableWidth} /> ) diff --git a/cli/src/components/tools/skill.tsx b/cli/src/components/tools/skill.tsx new file mode 100644 index 0000000000..5dcc67bc3e --- /dev/null +++ b/cli/src/components/tools/skill.tsx @@ -0,0 +1,29 @@ +import { SimpleToolCallItem } from './tool-call-item' +import { defineToolComponent } from './types' + +import type { ToolRenderConfig } from './types' + +/** + * UI component for skill tool. + * Displays the skill name being loaded in a compact format. + */ +export const SkillComponent = defineToolComponent({ + toolName: 'skill', + + render(toolBlock): ToolRenderConfig { + const input = toolBlock.input as any + + const skillName = + typeof input?.name === 'string' ? input.name.trim() : '' + + if (!skillName) { + return { content: null } + } + + return { + content: ( + + ), + } + }, +}) diff --git a/cli/src/components/tools/str-replace.tsx b/cli/src/components/tools/str-replace.tsx index 881152472e..ab1cc3823f 100644 --- a/cli/src/components/tools/str-replace.tsx +++ b/cli/src/components/tools/str-replace.tsx @@ -3,43 +3,15 @@ import { TextAttributes } from '@opentui/core' import { DiffViewer } from './diff-viewer' import { defineToolComponent } from './types' import { useTheme } from '../../hooks/use-theme' +import { + extractDiff, + extractFilePath, + isCreateFile, + shouldShowEditDiff, +} from '../../utils/implementor-helpers' import type { ToolRenderConfig } from './types' -function extractValueForKey(output: string, key: string): string | null { - if (!output) return null - const lines = output.split('\n') - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - const match = line.match(/^\s*([A-Za-z0-9_]+):\s*(.*)$/) - if (match && match[1] === key) { - const rest = match[2] - if (rest.trim().startsWith('|')) { - const baseIndent = lines[i + 1]?.match(/^\s*/)?.[0].length ?? 0 - const acc: string[] = [] - for (let j = i + 1; j < lines.length; j++) { - const l = lines[j] - const indent = l.match(/^\s*/)?.[0].length ?? 0 - if (l.trim().length === 0) { - acc.push('') - continue - } - if (indent < baseIndent) break - acc.push(l.slice(baseIndent)) - } - return acc.join('\n') - } else { - let val = rest.trim() - if (val.startsWith('"') && val.endsWith('"')) { - val = val.slice(1, -1) - } - return val - } - } - } - return null -} - interface EditHeaderProps { name: string filePath: string | null @@ -73,7 +45,7 @@ const EditBody = ({ name, filePath, diffText, isCreate }: EditBodyProps) => { return ( - {!isCreate && ( + {!isCreate && diffText.length > 0 && ( @@ -86,25 +58,17 @@ export const StrReplaceComponent = defineToolComponent({ toolName: 'str_replace', render(toolBlock): ToolRenderConfig { - const outputStr = - typeof toolBlock.output === 'string' ? toolBlock.output : '' - const diff = - extractValueForKey(outputStr, 'unifiedDiff') || - extractValueForKey(outputStr, 'patch') - const filePath = - extractValueForKey(outputStr, 'file') || - (typeof (toolBlock.input as any)?.path === 'string' - ? (toolBlock.input as any).path - : null) - const message = extractValueForKey(outputStr, 'message') - const isCreate = message === 'Created new file' + const diff = extractDiff(toolBlock) + const filePath = extractFilePath(toolBlock) + const isCreate = isCreateFile(toolBlock) + const showDiff = shouldShowEditDiff(toolBlock) return { content: ( ), diff --git a/cli/src/components/tools/suggest-followups.tsx b/cli/src/components/tools/suggest-followups.tsx index b0250de4b7..88fc060775 100644 --- a/cli/src/components/tools/suggest-followups.tsx +++ b/cli/src/components/tools/suggest-followups.tsx @@ -2,13 +2,15 @@ import { TextAttributes } from '@opentui/core' import { useCallback, useEffect, useState } from 'react' import { defineToolComponent } from './types' +import { useTerminalDimensions } from '../../hooks/use-terminal-dimensions' import { useTheme } from '../../hooks/use-theme' import { getLatestFollowupToolCallId, useChatStore } from '../../state/chat-store' +import { useFreebuffSessionStore } from '../../state/freebuff-session-store' +import { IS_FREEBUFF } from '../../utils/constants' import { Button } from '../button' import type { ToolRenderConfig } from './types' -import type { SuggestedFollowup } from '../../state/chat-store' -import { useTerminalDimensions } from '../../hooks/use-terminal-dimensions' +import type { SuggestedFollowup } from '../../types/store' const EMPTY_CLICKED_SET = new Set() const MIN_LABEL_COLUMN_WIDTH = 12 @@ -223,6 +225,9 @@ const SuggestFollowupsItem = ({ }: SuggestFollowupsItemProps) => { const theme = useTheme() const inputFocused = useChatStore((state) => state.inputFocused) + const isFreebuffSessionOver = useFreebuffSessionStore( + (state) => IS_FREEBUFF && state.session?.status === 'ended', + ) const setSuggestedFollowups = useChatStore( (state) => state.setSuggestedFollowups, ) @@ -305,7 +310,7 @@ const SuggestFollowupsItem = ({ isHovered={hoveredIndex === index} onSendFollowup={onSendFollowup} onHover={setHoveredIndex} - disabled={!inputFocused} + disabled={!inputFocused || isFreebuffSessionOver} labelColumnWidth={labelColumnWidth} /> ))} diff --git a/cli/src/components/tools/task-complete.tsx b/cli/src/components/tools/task-completed.tsx similarity index 83% rename from cli/src/components/tools/task-complete.tsx rename to cli/src/components/tools/task-completed.tsx index 8d980588ab..90acbdb1dc 100644 --- a/cli/src/components/tools/task-complete.tsx +++ b/cli/src/components/tools/task-completed.tsx @@ -4,7 +4,6 @@ import type { ToolRenderConfig } from './types' /** * UI component for task_completed tool. - * Displays a simple bullet point with "Task Complete" in bold. */ export const TaskCompleteComponent = defineToolComponent({ toolName: 'task_completed', diff --git a/cli/src/components/tools/tool-call-item.tsx b/cli/src/components/tools/tool-call-item.tsx index 0114a1f95b..c207bcb35e 100644 --- a/cli/src/components/tools/tool-call-item.tsx +++ b/cli/src/components/tools/tool-call-item.tsx @@ -33,8 +33,9 @@ const isTextRenderable = (value: ReactNode): boolean => { } if (React.isValidElement(value)) { + const elProps = value.props as Record if (value.type === React.Fragment) { - return isTextRenderable(value.props.children) + return isTextRenderable(elProps.children as ReactNode) } if (typeof value.type === 'string') { @@ -43,7 +44,7 @@ const isTextRenderable = (value: ReactNode): boolean => { value.type === 'strong' || value.type === 'em' ) { - return isTextRenderable(value.props.children) + return isTextRenderable(elProps.children as ReactNode) } return false @@ -239,11 +240,13 @@ export const ToolCallItem = ({ paddingRight: 0, paddingTop: 0, paddingBottom: 0, + width: '100%', }} > {collapsedPreviewText} diff --git a/cli/src/components/tools/web-search.tsx b/cli/src/components/tools/web-search.tsx new file mode 100644 index 0000000000..37477220cc --- /dev/null +++ b/cli/src/components/tools/web-search.tsx @@ -0,0 +1,33 @@ +import { SimpleToolCallItem } from './tool-call-item' +import { defineToolComponent } from './types' + +import type { ChatTheme } from '../../types/theme-system' +import type { ToolRenderConfig } from './types' + +/** + * UI component for web_search tool. + * Displays the search query in a compact format. + */ +export const WebSearchComponent = defineToolComponent({ + toolName: 'web_search', + + render(toolBlock, theme): ToolRenderConfig { + const input = toolBlock.input as { query?: string } | undefined + + const query = typeof input?.query === 'string' ? input.query.trim() : '' + + if (!query) { + return { content: null } + } + + return { + content: ( + + ), + } + }, +}) diff --git a/cli/src/components/tools/write-todos.tsx b/cli/src/components/tools/write-todos.tsx index 74b00303cf..4f1fffc487 100644 --- a/cli/src/components/tools/write-todos.tsx +++ b/cli/src/components/tools/write-todos.tsx @@ -41,7 +41,7 @@ const WriteTodosItem = ({ todos }: WriteTodosItemProps) => { {todo.completed ? ( <> - + { ) : ( <> - + {todo.task} )} diff --git a/cli/src/components/top-banner.tsx b/cli/src/components/top-banner.tsx index 76883f8594..b33201d549 100644 --- a/cli/src/components/top-banner.tsx +++ b/cli/src/components/top-banner.tsx @@ -3,12 +3,14 @@ import React from 'react' import { Button } from './button' import { TerminalLink } from './terminal-link' import { useTheme } from '../hooks/use-theme' -import { useChatStore, type TopBannerType } from '../state/chat-store' +import { useChatStore } from '../state/chat-store' +import { IS_FREEBUFF } from '../utils/constants' +import type { TopBannerType } from '../types/store' import { formatCwd } from '../utils/path-helpers' import { BORDER_CHARS } from '../utils/ui-constants' -import type { ThemeColorKey, InputMode } from '../utils/input-modes' import type { ChatTheme } from '../types/theme-system' +import type { ThemeColorKey, InputMode } from '../utils/input-modes' type BannerContentParams = { gitRoot?: string | null @@ -42,13 +44,8 @@ const TOP_BANNER_REGISTRY: Record, BannerConfig> = { borderColorKey: 'warning', textColorKey: 'foreground', relatedInputMode: 'homeDir', - content: ( - <> - You are currently in your home directory. -
- Select a project folder to get started, or choose "Start here". - - ), + content: + 'You are currently in your home directory.\nSelect a project folder to get started, or choose "Start here".', }, gitRoot: { borderColorKey: 'warning', @@ -59,7 +56,7 @@ const TOP_BANNER_REGISTRY: Record, BannerConfig> = { return ( <> - You started Codebuff in a subdirectory of a git repo. + You started {IS_FREEBUFF ? 'Freebuff' : 'Codebuff'} in a subdirectory of a git repo. {gitRoot && onSwitchToGitRoot ? ( { const isToday = resetDate.toDateString() === today.toDateString() return isToday ? resetDate.toLocaleString('en-US', { - hour: 'numeric', - minute: '2-digit', - }) + hour: 'numeric', + minute: '2-digit', + }) : resetDate.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }) + month: 'short', + day: 'numeric', + }) } export const UsageBanner = ({ showTime }: { showTime: number }) => { + if (IS_FREEBUFF) return null + const sessionCreditsUsed = useChatStore((state) => state.sessionCreditsUsed) const setInputMode = useChatStore((state) => state.setInputMode) - // Check if Claude OAuth is connected - const isClaudeConnected = isClaudeOAuthValid() + // Check if ChatGPT OAuth is connected + const isChatGptConnected = CHATGPT_OAUTH_ENABLED && isChatGptOAuthValid() - // Fetch Claude quota data if connected - const { data: claudeQuota, isLoading: isClaudeLoading } = useClaudeQuotaQuery({ - enabled: isClaudeConnected, - refetchInterval: 30 * 1000, // Refresh every 30 seconds when banner is open + // Fetch subscription data + const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionQuery({ + refetchInterval: 30 * 1000, }) const { @@ -96,84 +101,158 @@ export const UsageBanner = ({ showTime }: { showTime: number }) => { } const colorLevel = getBannerColorLevel(activeData.remainingBalance) - const adCredits = activeData.balanceBreakdown?.ad const renewalDate = activeData.next_quota_reset ? formatRenewalDate(activeData.next_quota_reset) : null + const activeSubscription = subscriptionData?.hasSubscription ? subscriptionData : null + const { rateLimit, subscription: subscriptionInfo, displayName } = activeSubscription ?? {} + return ( setInputMode('default')} > + {activeSubscription && ( + + )} + {/* Codebuff credits section - structured layout */} - {/* Claude subscription section - only show if connected */} - {isClaudeConnected && ( + {isChatGptConnected && ( - Claude subscription - {isClaudeLoading ? ( - Loading quota... - ) : claudeQuota ? ( - - - 5-hour: - - {claudeQuota.fiveHourResetsAt && ( - - (resets in {formatResetTime(claudeQuota.fiveHourResetsAt)}) - - )} - - {/* Only show 7-day bar if the user has a 7-day limit */} - {claudeQuota.sevenDayResetsAt && ( - - 7-day: - - - (resets in {formatResetTime(claudeQuota.sevenDayResetsAt)}) - - - )} - - ) : ( - Unable to fetch quota - )} + ChatGPT subscription + + Connected for supported OpenAI streaming models + )} ) } + +interface SubscriptionUsageSectionProps { + displayName?: string + subscriptionInfo?: { tier: number } + rateLimit?: { + blockLimit?: number + blockUsed?: number + blockResetsAt?: string + weeklyPercentUsed: number + weeklyResetsAt: string + } + isLoading: boolean + fallbackToALaCarte: boolean +} + +const SubscriptionUsageSection: React.FC = ({ + displayName, + subscriptionInfo, + rateLimit, + isLoading, + fallbackToALaCarte, +}) => { + const theme = useTheme() + const updatePreference = useUpdatePreference() + + const handleToggleFallbackToALaCarte = () => { + updatePreference.mutate({ fallbackToALaCarte: !fallbackToALaCarte }) + } + + const blockPercent = useMemo(() => { + if (rateLimit?.blockLimit == null || rateLimit.blockUsed == null) return 100 + return Math.max(0, 100 - Math.round((rateLimit.blockUsed / rateLimit.blockLimit) * 100)) + }, [rateLimit?.blockLimit, rateLimit?.blockUsed]) + + const weeklyPercent = rateLimit ? 100 - rateLimit.weeklyPercentUsed : 100 + + return ( + + + + 💪 {displayName ?? 'Strong'} subscription + + {subscriptionInfo?.tier && ( + ${subscriptionInfo.tier}/mo + )} + + {isLoading ? ( + Loading subscription data... + ) : rateLimit ? ( + + + {`5-hour limit ${`${blockPercent}%`.padStart(4)} `} + + + {rateLimit.blockResetsAt + ? ` resets in ${formatResetTime(new Date(rateLimit.blockResetsAt))}` + : ''} + + + + {`Weekly limit ${`${weeklyPercent}%`.padStart(4)} `} + + + {` resets in ${formatResetTimeLong(rateLimit.weeklyResetsAt)}`} + + + + ) : null} + + + Credit spending: + + {fallbackToALaCarte ? 'enabled' : 'disabled'} + + + + + {fallbackToALaCarte + ? 'Your credits will be used when subscription limits are reached.' + : 'Credits will NOT be spent when subscription limits are reached. Enable to use credits.'} + + + + ) +} diff --git a/cli/src/components/user-error-banner.tsx b/cli/src/components/user-error-banner.tsx new file mode 100644 index 0000000000..c01bcb00c3 --- /dev/null +++ b/cli/src/components/user-error-banner.tsx @@ -0,0 +1,56 @@ +import React from 'react' + +import { useTheme } from '../hooks/use-theme' +import { BORDER_CHARS } from '../utils/ui-constants' + +interface UserErrorBannerProps { + error: string + title?: string +} + +/** Displays runtime errors in the UI (not sent to LLM). */ +export const UserErrorBanner = React.memo(function UserErrorBanner({ + error, + title, +}: UserErrorBannerProps) { + const theme = useTheme() + + // Handle empty and whitespace-only errors + const trimmedError = error.trim() + if (!trimmedError) { + return null + } + + return ( + + + + {title ?? 'Error'} + + + {error} + + + + ) +}) diff --git a/cli/src/components/validation-error-popover.tsx b/cli/src/components/validation-error-popover.tsx index 9c2a3bd0d4..49ecb5756b 100644 --- a/cli/src/components/validation-error-popover.tsx +++ b/cli/src/components/validation-error-popover.tsx @@ -10,12 +10,13 @@ import { formatValidationError } from '../utils/validation-error-formatting' import { NETWORK_ERROR_ID } from '../utils/validation-error-helpers' import type { LocalAgentInfo } from '../utils/local-agent-registry' +import type { FeedbackCategory } from '@codebuff/common/constants/feedback' interface ValidationErrorPopoverProps { errors: Array<{ id: string; message: string }> onOpenFeedback?: (options: { - category: string + category: FeedbackCategory footerMessage: string errors: Array<{ id: string; message: string }> }) => void @@ -77,9 +78,10 @@ export const ValidationErrorPopover: React.FC = ({
- {errors.slice(0, 3).map((error) => { - const agentId = error.id.replace(/_\d+$/, '') - const isNetworkError = error.id === NETWORK_ERROR_ID + {errors.slice(0, 3).map((error, index) => { + const errorId = error.id ?? '' + const agentId = errorId.replace(/_\d+$/, '') + const isNetworkError = errorId === NETWORK_ERROR_ID const agentInfo = loadedAgentsData?.agents.find( (a) => a.id === agentId, ) as LocalAgentInfo | undefined @@ -91,7 +93,7 @@ export const ValidationErrorPopover: React.FC = ({ if (isNetworkError) { return ( @@ -104,7 +106,7 @@ export const ValidationErrorPopover: React.FC = ({ if (agentInfo?.filePath) { return ( @@ -131,11 +133,11 @@ export const ValidationErrorPopover: React.FC = ({ return ( - {`• ${agentId}`} + {`• ${agentId || 'Unknown'}`} { + if (!Number.isFinite(ms) || ms <= 0) return 'any moment now' + const totalSeconds = Math.round(ms / 1000) + if (totalSeconds < 60) return `~${totalSeconds}s` + const minutes = Math.round(totalSeconds / 60) + if (minutes < 60) return `~${minutes} min` + const hours = Math.floor(minutes / 60) + const rem = minutes % 60 + return rem === 0 ? `~${hours}h` : `~${hours}h ${rem}m` +} + +const formatElapsed = (ms: number): string => { + if (!Number.isFinite(ms) || ms < 0) return '0s' + const totalSeconds = Math.floor(ms / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + if (minutes === 0) return `${seconds}s` + return `${minutes}m ${seconds.toString().padStart(2, '0')}s` +} + +/** "in ~3h 20m" / "in ~45 min" / "in under a minute". Used on the + * rate-limited screen so users know when they can try again. */ +const formatRetryAfter = (ms: number): string => { + if (!Number.isFinite(ms) || ms <= 0) return 'any moment now' + const minutes = Math.round(ms / 60_000) + if (minutes < 1) return 'under a minute' + if (minutes < 60) return `${minutes} min` + const hours = Math.floor(minutes / 60) + const rem = minutes % 60 + return rem === 0 ? `${hours}h` : `${hours}h ${rem}m` +} + +const PRIVACY_SIGNAL_LABELS: Partial> = +{ + anonymous: 'anonymized network', + proxy: 'proxy', + relay: 'relay', + res_proxy: 'residential proxy', + tor: 'Tor', + vpn: 'VPN', + hosting: 'hosting network', + service: 'privacy service', +} + +const formatPrivacySignalList = ( + signals: FreebuffIpPrivacySignal[] | undefined, +): string => { + const labels = Array.from( + new Set( + signals + ?.map((signal) => PRIVACY_SIGNAL_LABELS[signal]) + .filter((label): label is string => Boolean(label)) ?? [], + ), + ) + + if (labels.length === 0) { + return 'VPN, Tor, proxy, relay, or anonymized network' + } + if (labels.length === 1) return labels[0] + if (labels.length === 2) return `${labels[0]} or ${labels[1]}` + return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}` +} + +const getLimitedModeReason = ( + session: FreebuffSessionResponse | null, +): string | null => { + if (!session || !('countryBlockReason' in session)) { + return 'reduced free model access' + } + + const countryCode = + 'countryCode' in session && + session.countryCode && + session.countryCode !== 'UNKNOWN' + ? session.countryCode + : null + + switch (session.countryBlockReason) { + case 'anonymous_network': + return `${formatPrivacySignalList( + session.ipPrivacySignals ?? undefined, + )} detected` + case 'country_not_allowed': + return `based on detected country${countryCode ? `: ${countryCode}` : ''}` + case 'anonymized_or_unknown_country': + case 'missing_client_ip': + case 'unresolved_client_ip': + return 'location could not be verified' + case 'ip_privacy_lookup_failed': + return 'network check could not finish' + default: + return 'reduced free model access' + } +} + +const TakeoverPrompt: React.FC = () => { + const theme = useTheme() + const [pending, setPending] = useState(false) + const [focusedIndex, setFocusedIndex] = useState(0) // 0 = Take over, 1 = Exit + + const handleTakeover = useCallback(() => { + if (pending) return + setPending(true) + takeOverFreebuffSession().finally(() => setPending(false)) + }, [pending]) + + useKeyboard( + useCallback( + (key: KeyEvent) => { + const name = key.name ?? '' + const isConfirm = name === 'return' || name === 'enter' + const isExit = name === 'escape' || name === 'esc' + const isTab = name === 'tab' + const isShiftTab = key.shift === true && isTab + const isRight = name === 'right' + const isLeft = name === 'left' + + if (isExit) { + key.preventDefault?.() + exitFreebuffCleanly() + return + } + + if (isConfirm) { + key.preventDefault?.() + if (focusedIndex === 0) { + handleTakeover() + } else { + exitFreebuffCleanly() + } + return + } + + if (isRight || isTab) { + key.preventDefault?.() + setFocusedIndex((prev) => (prev + 1) % 2) + return + } + + if (isLeft || isShiftTab) { + key.preventDefault?.() + setFocusedIndex((prev) => (prev - 1 + 2) % 2) + return + } + }, + [focusedIndex, handleTakeover], + ), + ) + + const isTakeoverFocused = focusedIndex === 0 + const isExitFocused = focusedIndex === 1 + + return ( + + + Freebuff is already running + + + + Only one freebuff instance is allowed at a time. + + + + + + + + ) +} + +export const WaitingRoomScreen: React.FC = ({ + session, + error, +}) => { + const theme = useTheme() + const renderer = useRenderer() + const { terminalWidth, terminalHeight, contentMaxWidth } = + useTerminalDimensions() + + // Progressive disclosure as the terminal gets shorter. The picker is the + // only thing the user must be able to reach, so chrome is shed first: + // tall (>=26): full ASCII logo + roomy spacing, content anchored low + // medium (>=18): one-line text logo, tightened spacing, content up top + // short (<18) : no logo at all + // tiny (<15) : also drop the ad banner + // Section headers always show — the picker scrolls within whatever rows + // remain (see selectorMaxHeight below), so there's no need to hide them. + const logoMode: 'full' | 'text' | 'none' = + terminalHeight >= 26 ? 'full' : terminalHeight >= 19 ? 'text' : 'none' + const compact = terminalHeight < 22 + const showAds = terminalHeight >= 16 + const textMarginBottom = compact ? 0 : 1 + const logoLines = logoMode === 'full' ? 6 : logoMode === 'text' ? 1 : 0 + + const [sheenPosition, setSheenPosition] = useState(0) + const blockColor = getLogoBlockColor(theme.name) + const accentColor = getLogoAccentColor(theme.name) + const { applySheenToChar } = useSheenAnimation({ + logoColor: theme.foreground, + accentColor, + blockColor, + terminalWidth: renderer?.width ?? terminalWidth, + sheenPosition, + setSheenPosition, + }) + const { component: logoComponent } = useLogo({ + availableWidth: contentMaxWidth, + accentColor, + blockColor, + applySheenToChar, + // 'text' forces the one-line variant; 'none' is handled by not rendering. + maxHeight: logoMode === 'full' ? undefined : 1, + }) + + // Always enable ads in the waiting room — this is where monetization lives. + // forceStart bypasses the "wait for first user message" gate inside the hook, + // which would otherwise block ads here since no conversation exists yet. + // Try Gravity first, then fall back to ZeroClick when Gravity doesn't fill. + const { ads, recordClick, recordImpression } = useGravityAd({ + enabled: true, + forceStart: true, + provider: 'gravity', + fallbackProvider: 'zeroclick', + surface: 'waiting_room', + }) + + useFreebuffCtrlCExit() + + const [exitHover, setExitHover] = useState(false) + + const isQueued = session?.status === 'queued' + const accessTier = + session && 'accessTier' in session ? session.accessTier : 'full' + const limitedModeReason = + accessTier === 'limited' ? getLimitedModeReason(session) : null + // 'none' = user hasn't joined any queue yet. We're in the pre-chat landing + // state: show the picker with live N-in-line hints and a prompt. Picking a + // model triggers joinFreebuffQueue, which POSTs and transitions us to + // 'queued' (waiting room) or straight to 'active' (chat) if no wait. + const isLanding = session?.status === 'none' + // Elapsed-in-queue timer. Starts from `queuedAt` so it keeps ticking even if + // the user wanders away and comes back. On the landing picker we tick once a + // minute so the premium reset countdown stays fresh. + const queuedAtMs = useMemo(() => { + if (session?.status === 'queued') return Date.parse(session.queuedAt) + return null + }, [session]) + const now = useNow(isQueued ? 1000 : 60_000, isQueued || isLanding) + const elapsedMs = queuedAtMs ? now - queuedAtMs : 0 + + // Premium quota counter for the title line. All premium models share one + // pool; the server replicates the same snapshot under each premium model + // id, so any entry has the right count. Renders amber when exhausted so + // the limit reads as "you've hit it" rather than just another count. + const rateLimitsByModel = getRateLimitsByModel(session) + const premiumRateLimit = rateLimitsByModel + ? Object.values(rateLimitsByModel)[0] + : undefined + const sharedPremiumUsed = premiumRateLimit?.recentCount ?? 0 + const isPremiumExhausted = + sharedPremiumUsed >= + (accessTier === 'limited' + ? FREEBUFF_LIMITED_SESSION_LIMIT + : FREEBUFF_PREMIUM_SESSION_LIMIT) + const premiumUsedColor = isPremiumExhausted ? theme.secondary : theme.muted + // Pad the used count so the title's centered container doesn't shift width + // as the count ticks from "0" → "1.3" → "2" while loading. + const sessionLimit = + accessTier === 'limited' + ? FREEBUFF_LIMITED_SESSION_LIMIT + : FREEBUFF_PREMIUM_SESSION_LIMIT + // Limited-tier users don't see any premium models, so calling these "limited + // sessions" leaks the tier name without informing the user — just "sessions" + // reads naturally next to the count and reset countdown. + const sessionLabel = + accessTier === 'limited' ? 'sessions' : 'premium sessions' + const sessionUnitWidth = String(sessionLimit).length + 2 + const formattedSharedPremiumUsed = + formatSessionUnits(sharedPremiumUsed).padStart(sessionUnitWidth) + const premiumResetAt = getFreebuffPremiumResetAt({ + rateLimitsByModel, + nowMs: now, + }) + const premiumResetAtMs = premiumResetAt.getTime() + const premiumResetCountdown = formatFreebuffPremiumResetCountdown( + premiumResetAt, + now, + ) + + // Rows the picker may occupy = terminal height minus the fixed chrome + // around it. Each term mirrors the real layout exactly (no padded + // estimate, no blanket safety row) so the scrollbox fills the available + // space with no dead band below it: + // - top bar: paddingTop 1 + the ✕ row = 2 + // - ad banner: CHOICE_AD_BANNER_HEIGHT, only when shown + // - main box: its paddingTop (text-logo tier only) + paddingBottom 1 + // - logo block: lines + marginBottom 1 (always, when shown) + gap (full) + // - the prompt/counter (landing) or the position panel (queued) + // Line wrapping is derived from the actual strings vs contentMaxWidth, so + // a wrapped counter is accounted for precisely instead of guessed at. + const wrappedRows = (text: string) => + Math.max(1, Math.ceil(text.length / contentMaxWidth)) + const counterText = + `${formattedSharedPremiumUsed} of ${sessionLimit} ${sessionLabel} used, ` + + `resets in ${premiumResetCountdown}` + const logoBlockRows = + logoMode === 'none' + ? 0 + : logoLines + 1 /* marginBottom */ + (logoMode === 'full' ? 1 : 0) + const mainPaddingRows = (logoMode === 'text' ? 1 : 0) + 1 + const adRows = showAds ? CHOICE_AD_BANNER_HEIGHT : 0 + const reservedChrome = 2 + adRows + mainPaddingRows + logoBlockRows + const landingTextRows = + wrappedRows('Pick a model to start') + + textMarginBottom + + wrappedRows(counterText) + + textMarginBottom + const queuedTextRows = + wrappedRows("You're in the waiting room") + + 1 /* marginBottom */ + + 4 /* position panel */ + const selectorMaxHeight = Math.max( + 3, + terminalHeight - + reservedChrome - + (isQueued ? queuedTextRows : landingTextRows), + ) + // The limited-tier panel owns its own title/counter, so the only chrome + // around it is the shared frame (no extra prompt rows to subtract). + const limitedPanelMaxHeight = Math.max(3, terminalHeight - reservedChrome) + + useEffect(() => { + if (!isLanding || !premiumRateLimit) return + + const delayMs = Math.max(0, premiumResetAtMs - Date.now() + 1_000) + const timer = setTimeout(() => { + refreshFreebuffLandingMetadata().catch(() => { }) + }, delayMs) + + return () => clearTimeout(timer) + }, [isLanding, premiumRateLimit, premiumResetAtMs]) + + return ( + + {/* Top-right exit affordance so mouse users have a clear way out even + when they don't know Ctrl+C works. width: '100%' is required for + justifyContent to actually push the X to the right. */} + + + {limitedModeReason && ( + + + Limited mode + + · {limitedModeReason} + + )} + + + + + + + {/* Reserve the ad banner slot before the async ad fetch resolves so the + waiting-room content does not jump when the banner fills. On very + short terminals the banner is dropped entirely to give the picker + back its 5 rows. */} + {showAds && ( + + {ads ? ( + + ) : ( + + {'─'.repeat(terminalWidth)} + + )} + + )} + + ) +} diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 3876a97fc7..14d71abecd 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -1,4 +1,9 @@ -import { AGENT_MODES } from '../utils/constants' +import { CHATGPT_OAUTH_ENABLED } from '@codebuff/common/constants/chatgpt-oauth' +import { AGENT_MODES, IS_FREEBUFF } from '../utils/constants' +import { getChatGptOAuthStatus } from '../utils/chatgpt-oauth' + +import type { SkillsMap } from '@codebuff/common/types/skill' + export interface SlashCommand { id: string @@ -10,31 +15,68 @@ export interface SlashCommand { * input matches the command id exactly (no arguments). */ implicitCommand?: boolean + /** + * If set, selecting this command inserts this text into the input field + * instead of executing a command. Useful for agent shortcuts. + */ + insertText?: string } -// Generate mode commands from the AGENT_MODES constant -const MODE_COMMANDS: SlashCommand[] = AGENT_MODES.map((mode) => ({ - id: `mode:${mode.toLowerCase()}`, - label: `mode:${mode.toLowerCase()}`, - description: `Switch to ${mode} mode`, -})) +// Generate mode commands from the AGENT_MODES constant (excluded in Freebuff) +const MODE_COMMANDS: SlashCommand[] = IS_FREEBUFF + ? [] + : AGENT_MODES.map((mode) => ({ + id: `mode:${mode.toLowerCase()}`, + label: `mode:${mode.toLowerCase()}`, + description: `Switch to ${mode} mode`, + aliases: [`model:${mode.toLowerCase()}`], + })) + +const FREEBUFF_REMOVED_COMMAND_IDS = new Set([ + 'ads:enable', + 'ads:disable', + 'usage', + 'subscribe', + 'agent:gpt-5', + 'image', + 'publish', + 'init', +]) -export const SLASH_COMMANDS: SlashCommand[] = [ +const FREEBUFF_ONLY_COMMAND_IDS = new Set([ + 'connect', + 'plan', + 'end-session', +]) + +const ALL_SLASH_COMMANDS: SlashCommand[] = [ { - id: 'connect:claude', - label: 'connect:claude', - description: 'Connect your Claude Pro/Max subscription', - aliases: ['claude'], + id: 'help', + label: 'help', + description: 'Display keyboard shortcuts and tips', + aliases: ['h', '?'], + implicitCommand: true, }, + ...(CHATGPT_OAUTH_ENABLED + ? [ + { + id: 'connect', + label: 'connect', + description: 'Connect your ChatGPT account', + aliases: ['connect:chatgpt', 'chatgpt'], + }, + ] + : []), + { id: 'ads:enable', label: 'ads:enable', - description: 'Enable contextual ads and earn credits', + description: 'Enable contextual ads', }, { id: 'ads:disable', label: 'ads:disable', - description: 'Disable contextual ads and stop earning credits', + description: 'Disable contextual ads', }, { id: 'init', @@ -59,14 +101,30 @@ export const SLASH_COMMANDS: SlashCommand[] = [ aliases: ['credits'], }, { - id: 'buy-credits', - label: 'buy-credits', - description: 'Open the usage page to buy credits', + id: 'subscribe', + label: 'subscribe', + description: 'Subscribe to get more usage', + aliases: ['strong', 'sub', 'buy-credits'], + }, + { + id: 'interview', + label: 'interview', + description: 'AI asks a series of questions to flesh out request into a spec', + }, + { + id: 'plan', + label: 'plan', + description: 'Create a plan with GPT 5.4', + }, + { + id: 'review', + label: 'review', + description: 'Review code changes with GPT 5.4', }, { id: 'new', label: 'new', - description: 'Start a fresh conversation session', + description: 'Clear the conversation history and start a new chat', aliases: ['n', 'clear', 'c', 'reset'], implicitCommand: true, }, @@ -76,10 +134,22 @@ export const SLASH_COMMANDS: SlashCommand[] = [ description: 'Browse and resume past conversations', aliases: ['chats'], }, + { + id: 'agent:gpt-5', + label: 'agent:gpt-5', + description: 'Spawn the GPT-5 agent to help solve complex problems', + insertText: '@GPT-5 Agent ', + }, + // { + // id: 'agent:opus', + // label: 'agent:opus', + // description: 'Spawn the Opus agent to help solve any problem', + // insertText: '@Opus Agent ', + // }, { id: 'feedback', label: 'feedback', - description: 'Share general feedback about Codebuff', + description: IS_FREEBUFF ? 'Share general feedback about Freebuff' : 'Share general feedback about Codebuff', }, { id: 'bash', @@ -93,24 +163,22 @@ export const SLASH_COMMANDS: SlashCommand[] = [ description: 'Attach an image file (or Ctrl+V to paste from clipboard)', aliases: ['img', 'attach'], }, - { - id: 'help', - label: 'help', - description: 'Display keyboard shortcuts and tips', - aliases: ['h', '?'], - implicitCommand: true, - }, ...MODE_COMMANDS, + // { + // id: 'publish', + // label: 'publish', + // description: 'Publish agents to the agent store', + // }, { - id: 'referral', - label: 'referral', - description: 'Redeem a referral code for bonus credits', - aliases: ['redeem'], + id: 'theme:toggle', + label: 'theme:toggle', + description: 'Toggle between light and dark mode', }, { - id: 'publish', - label: 'publish', - description: 'Publish agents to the agent store', + id: 'end-session', + label: 'end-session', + description: 'End your free session (lets you switch model)', + aliases: ['model'], }, { id: 'logout', @@ -128,8 +196,51 @@ export const SLASH_COMMANDS: SlashCommand[] = [ }, ] +export const SLASH_COMMANDS = IS_FREEBUFF + ? ALL_SLASH_COMMANDS.filter( + (cmd) => !FREEBUFF_REMOVED_COMMAND_IDS.has(cmd.id), + ) + : ALL_SLASH_COMMANDS.filter( + (cmd) => !FREEBUFF_ONLY_COMMAND_IDS.has(cmd.id), + ) + export const SLASHLESS_COMMAND_IDS = new Set( SLASH_COMMANDS.filter((cmd) => cmd.implicitCommand).map((cmd) => cmd.id.toLowerCase(), ), ) + +/** Maximum description length for skill commands in the slash menu */ +const SKILL_MENU_DESCRIPTION_MAX_LENGTH = 50 + +function truncateDescription(description: string): string { + if (description.length <= SKILL_MENU_DESCRIPTION_MAX_LENGTH) { + return description + } + return description.slice(0, SKILL_MENU_DESCRIPTION_MAX_LENGTH - 1) + '…' +} + +/** + * Returns SLASH_COMMANDS merged with skill commands. + * Skills become slash commands that users can invoke directly. + */ +export function getSlashCommandsWithSkills(skills: SkillsMap): SlashCommand[] { + const skillCommands: SlashCommand[] = Object.values(skills).map((skill) => ({ + id: `skill:${skill.name}`, + label: `skill:${skill.name}`, + description: truncateDescription(skill.description), + })) + + let commands = [...SLASH_COMMANDS, ...skillCommands] + + if (IS_FREEBUFF && !getChatGptOAuthStatus().connected) { + commands = commands.map((cmd) => { + if (cmd.id === 'review' || cmd.id === 'plan') { + return { ...cmd, description: 'Connect required. ' + cmd.description } + } + return cmd + }) + } + + return commands +} diff --git a/cli/src/hooks/__tests__/use-activity-query.test.ts b/cli/src/hooks/__tests__/use-activity-query.test.ts index 79ec42ef6a..ad5946dbfa 100644 --- a/cli/src/hooks/__tests__/use-activity-query.test.ts +++ b/cli/src/hooks/__tests__/use-activity-query.test.ts @@ -7,6 +7,8 @@ import { setActivityQueryData, resetActivityQueryCache, isEntryStale, + setErrorOnlyCacheEntry, + _retryTestHelpers, } from '../use-activity-query' describe('use-activity-query utilities', () => { @@ -559,120 +561,6 @@ describe('refetch on activity behavior', () => { }) }) -/** - * Tests verifying the exact scenarios that could cause the - * Claude subscription percent to not update in the bottom bar. - */ -describe('Claude subscription update scenarios', () => { - let originalDateNow: typeof Date.now - let mockNow: number - - beforeEach(() => { - resetActivityQueryCache() - originalDateNow = Date.now - mockNow = 1000000 - Date.now = () => mockNow - }) - - afterEach(() => { - Date.now = originalDateNow - }) - - test('Claude quota data updates should be reflected in cache', () => { - const claudeQuotaKey = ['claude-quota', 'current'] - - // Initial quota data - const initialQuota = { - fiveHourRemaining: 80, - fiveHourResetsAt: new Date('2024-02-01T12:00:00Z'), - sevenDayRemaining: 90, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaKey, initialQuota) - - const cached1 = getActivityQueryData(claudeQuotaKey) - expect(cached1?.fiveHourRemaining).toBe(80) - - // Simulate quota being used - const updatedQuota = { - fiveHourRemaining: 60, - fiveHourResetsAt: new Date('2024-02-01T12:00:00Z'), - sevenDayRemaining: 85, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaKey, updatedQuota) - - const cached2 = getActivityQueryData(claudeQuotaKey) - expect(cached2?.fiveHourRemaining).toBe(60) - expect(cached2?.sevenDayRemaining).toBe(85) - }) - - test('polling should update Claude quota when data is stale', () => { - const claudeQuotaKey = ['claude-quota', 'current'] - const staleTime = 30000 // 30 seconds (matches useClaudeQuotaQuery) - const refetchInterval = 60000 // 60 seconds - - // Set initial data - const initialQuota = { fiveHourRemaining: 100, sevenDayRemaining: 100 } - setActivityQueryData(claudeQuotaKey, initialQuota) - - // Time passes beyond staleTime - mockNow += 35000 // 35 seconds - - // Data is now stale, polling tick should trigger refetch - // In real code: if (isEntryStale(serializedKey, staleTime)) void doFetch() - - // Simulate what refetch would do - const newQuota = { fiveHourRemaining: 75, sevenDayRemaining: 95 } - setActivityQueryData(claudeQuotaKey, newQuota) - - // Verify the update is reflected - const cached = getActivityQueryData(claudeQuotaKey) - expect(cached?.fiveHourRemaining).toBe(75) - }) - - test('multiple rapid updates should always reflect latest value', () => { - const claudeQuotaKey = ['claude-quota', 'current'] - - // Simulate rapid API responses (e.g., user making multiple requests) - for (let remaining = 100; remaining >= 0; remaining -= 10) { - setActivityQueryData(claudeQuotaKey, { fiveHourRemaining: remaining }) - } - - // Should have the final value - const cached = getActivityQueryData<{ fiveHourRemaining: number }>(claudeQuotaKey) - expect(cached?.fiveHourRemaining).toBe(0) - }) - - test('cache reset should clear Claude quota data', () => { - const claudeQuotaKey = ['claude-quota', 'current'] - - setActivityQueryData(claudeQuotaKey, { fiveHourRemaining: 50 }) - expect(getActivityQueryData(claudeQuotaKey)).toBeDefined() - - resetActivityQueryCache() - - expect(getActivityQueryData(claudeQuotaKey)).toBeUndefined() - }) - - test('invalidation should mark Claude quota for refetch without losing data', () => { - const claudeQuotaKey = ['claude-quota', 'current'] - - const quota = { fiveHourRemaining: 50, sevenDayRemaining: 80 } - setActivityQueryData(claudeQuotaKey, quota) - - // Invalidate - marks as stale but preserves data - invalidateActivityQuery(claudeQuotaKey) - - // Data should still be accessible for display while refetch happens - const cached = getActivityQueryData(claudeQuotaKey) - expect(cached?.fiveHourRemaining).toBe(50) - expect(cached?.sevenDayRemaining).toBe(80) - }) -}) - /** * Tests for edge cases and error scenarios in the caching system. */ @@ -765,3 +653,348 @@ describe('cache edge cases and error handling', () => { expect(getActivityQueryData(testKey)).toBe('second') }) }) + +/** + * Tests for error-only cache entries and persistent error scenarios. + * This test suite was added to debug and fix an issue where fetchSubscriptionData + * was being called every second when the endpoint returned errors. + */ +describe('error-only entries and persistent error handling', () => { + let originalDateNow: typeof Date.now + let mockNow: number + + beforeEach(() => { + resetActivityQueryCache() + originalDateNow = Date.now + mockNow = 1000000 + Date.now = () => mockNow + }) + + afterEach(() => { + Date.now = originalDateNow + }) + + test('setErrorOnlyCacheEntry creates entry with no data and error', () => { + const testKey = ['error-entry-test'] + const error = new Error('Network error') + + setErrorOnlyCacheEntry(testKey, error) + + // Data should be undefined (error-only entry) + expect(getActivityQueryData(testKey)).toBeUndefined() + }) + + test('error-only entry with recent errorUpdatedAt should NOT be stale', () => { + // This test verifies the fix for the infinite refetch loop bug. + // + // Scenario: + // 1. Fetch fails with no prior data + // 2. Error is stored with errorUpdatedAt = now + // 3. Polling tick fires + // 4. isEntryStale should return FALSE if errorUpdatedAt is recent + // 5. This prevents immediate refetch loop + + const testKey = ['error-only-fresh-test'] + const serializedKey = JSON.stringify(testKey) + const staleTime = 30000 // 30 seconds + const error = new Error('API error') + + // Create error-only entry at current time (mockNow = 1000000) + setErrorOnlyCacheEntry(testKey, error, mockNow) + + // Entry has errorUpdatedAt = 1000000, current time = 1000000 + // Time since error: 0ms, staleTime: 30000ms + // Should NOT be stale because error is recent + expect(isEntryStale(serializedKey, staleTime)).toBe(false) + }) + + test('error-only entry becomes stale after staleTime passes', () => { + const testKey = ['error-stale-after-time-test'] + const serializedKey = JSON.stringify(testKey) + const staleTime = 30000 // 30 seconds + const error = new Error('API error') + + // Create error-only entry at current time + setErrorOnlyCacheEntry(testKey, error, mockNow) + + // Initially not stale + expect(isEntryStale(serializedKey, staleTime)).toBe(false) + + // Advance time by 25 seconds - still fresh + mockNow += 25000 + expect(isEntryStale(serializedKey, staleTime)).toBe(false) + + // Advance time past staleTime (now 35 seconds since error) + mockNow += 10000 + expect(isEntryStale(serializedKey, staleTime)).toBe(true) + }) + + test('simulates subscription query polling with persistent errors', () => { + // This test simulates the exact bug scenario: + // - useSubscriptionQuery with staleTime=30s, refetchInterval=60s + // - Endpoint returns errors + // - Without fix: isEntryStale returns true immediately, causing rapid refetches + // - With fix: isEntryStale uses errorUpdatedAt, preventing rapid refetches + + const subscriptionKey = ['subscription', 'current'] + const serializedKey = JSON.stringify(subscriptionKey) + const staleTime = 30000 // 30 seconds (matches useSubscriptionQuery) + const refetchInterval = 60000 // 60 seconds + const error = new Error('Failed to fetch subscription: 500') + + // Simulate first fetch failure at t=0 + setErrorOnlyCacheEntry(subscriptionKey, error, mockNow) + + // Immediately after error, entry should NOT be stale + // This is the critical fix - prevents immediate refetch loop + expect(isEntryStale(serializedKey, staleTime)).toBe(false) + + // Simulate polling interval at t=1s (as reported in bug) + mockNow += 1000 + // Entry should still NOT be stale (only 1s since error, staleTime is 30s) + expect(isEntryStale(serializedKey, staleTime)).toBe(false) + + // Simulate many 1-second intervals - none should trigger refetch until staleTime + for (let i = 0; i < 28; i++) { + mockNow += 1000 + expect(isEntryStale(serializedKey, staleTime)).toBe(false) + } + + // Now at t=29s - should still be fresh (29s is not > 30s) + expect(isEntryStale(serializedKey, staleTime)).toBe(false) + + // At t=30s - should still be fresh (30s is not > 30s, need strictly greater) + mockNow += 1000 + expect(isEntryStale(serializedKey, staleTime)).toBe(false) + + // At t=31s - now stale, refetch should be allowed (31s > 30s) + mockNow += 1000 + expect(isEntryStale(serializedKey, staleTime)).toBe(true) + }) + + test('staleTime of 0 means always stale even for error-only entries', () => { + const testKey = ['zero-stale-error-test'] + const serializedKey = JSON.stringify(testKey) + const error = new Error('Some error') + + setErrorOnlyCacheEntry(testKey, error, mockNow) + + // With staleTime=0, entry is always considered stale + expect(isEntryStale(serializedKey, 0)).toBe(true) + }) + + test('error-only entry with null errorUpdatedAt is stale', () => { + // Edge case: if somehow errorUpdatedAt is null, entry should be stale + // This shouldn't happen in practice but tests defensive coding + const testKey = ['null-error-time-test'] + const serializedKey = JSON.stringify(testKey) + const staleTime = 30000 + + // Create entry without errorUpdatedAt (using undefined which gets stored as null) + // Note: setErrorOnlyCacheEntry always sets errorUpdatedAt, so we test via regular data + // and then invalidate it + + // Non-existent key is stale + expect(isEntryStale(serializedKey, staleTime)).toBe(true) + }) + + test('successful data takes precedence over errorUpdatedAt for staleness', () => { + const testKey = ['data-precedence-test'] + const serializedKey = JSON.stringify(testKey) + const staleTime = 30000 + + // First, set an error-only entry + setErrorOnlyCacheEntry(testKey, new Error('Initial error'), mockNow) + expect(isEntryStale(serializedKey, staleTime)).toBe(false) // Fresh error + + // Now set successful data (this is what happens on successful retry) + setActivityQueryData(testKey, { subscription: 'active' }) + + // Staleness should now be based on dataUpdatedAt, not errorUpdatedAt + expect(isEntryStale(serializedKey, staleTime)).toBe(false) // Fresh data + + // Advance time past staleTime + mockNow += 35000 + expect(isEntryStale(serializedKey, staleTime)).toBe(true) // Stale based on dataUpdatedAt + }) +}) + +/** + * Tests for the retry infinite loop bug. + * + * BUG: When useSubscriptionQuery fetched /api/user/subscription and got a 401, + * it would retry every ~1 second infinitely instead of respecting retry:1. + * + * ROOT CAUSE: In doFetch's catch block, when scheduling a retry: + * 1. retryCounts.set(key, next) // Sets count to 1 + * 2. clearRetryState(key) // Deletes retryCounts → count back to 0! + * 3. setTimeout to retry in 1s + * When the retry fires, currentRetries reads as 0 again → thinks it still has + * retries left → schedules another retry → infinite loop. + * + * FIX: Split clearRetryState into clearRetryTimeout (only clears timeout) + * and clearRetryState (clears both). The retry scheduling block now uses + * clearRetryTimeout so the retry count is preserved. + */ +describe('retry infinite loop bug fix (subscription 401 scenario)', () => { + beforeEach(() => { + resetActivityQueryCache() + }) + + test('retry count is preserved after scheduling a retry', () => { + const queryKey = ['subscription', 'current'] + const maxRetries = 1 + + // Simulate a mounted component (refCount > 0) + _retryTestHelpers.setRefCount(queryKey, 1) + + // Initially, no retries have been attempted + expect(_retryTestHelpers.getRetryCount(queryKey)).toBe(0) + + // First fetch fails → should schedule a retry + const result1 = _retryTestHelpers.simulateFailedFetch(queryKey, maxRetries) + expect(result1.retryScheduled).toBe(true) + expect(result1.retryCount).toBe(1) + + // CRITICAL: Retry count must be preserved (not reset to 0) + expect(_retryTestHelpers.getRetryCount(queryKey)).toBe(1) + }) + + test('retries are exhausted after maxRetries attempts', () => { + const queryKey = ['subscription', 'current'] + const maxRetries = 1 + + _retryTestHelpers.setRefCount(queryKey, 1) + + // First fetch fails → retry scheduled (count becomes 1) + const result1 = _retryTestHelpers.simulateFailedFetch(queryKey, maxRetries) + expect(result1.retryScheduled).toBe(true) + expect(result1.retryCount).toBe(1) + + // Retry fires, also fails → retries exhausted (count = 1, not < maxRetries=1) + const result2 = _retryTestHelpers.simulateFailedFetch(queryKey, maxRetries) + expect(result2.retryScheduled).toBe(false) + expect(result2.retryCount).toBe(0) // Reset after exhaustion + }) + + test('simulates full subscription 401 scenario: fetch + 1 retry + stop', () => { + // This reproduces the exact bug scenario: + // useSubscriptionQuery with retry:1 hitting a 401 on /api/user/subscription + const queryKey = ['subscription', 'current'] + const maxRetries = 1 + + // Component is mounted + _retryTestHelpers.setRefCount(queryKey, 1) + + // === Fetch #1: Initial fetch fails with 401 === + const fetch1 = _retryTestHelpers.simulateFailedFetch(queryKey, maxRetries) + expect(fetch1.retryScheduled).toBe(true) + expect(fetch1.retryCount).toBe(1) + + // === Fetch #2: Retry fires after 1s, also fails with 401 === + const fetch2 = _retryTestHelpers.simulateFailedFetch(queryKey, maxRetries) + expect(fetch2.retryScheduled).toBe(false) // Retries exhausted! + expect(fetch2.retryCount).toBe(0) + + // === Fetch #3: If the bug existed, this would schedule ANOTHER retry === + // With the fix, the error is stored and no more retries are scheduled. + // A third call should also exhaust immediately since count was reset to 0 + // BUT there's no retry scheduled, so this would only happen from polling. + const fetch3 = _retryTestHelpers.simulateFailedFetch(queryKey, maxRetries) + // Even if polling triggers another fetch, retry:1 means ONE retry per fetch cycle + expect(fetch3.retryScheduled).toBe(true) // New fetch cycle starts fresh + expect(fetch3.retryCount).toBe(1) + + // The retry for fetch3 fires and fails + const fetch4 = _retryTestHelpers.simulateFailedFetch(queryKey, maxRetries) + expect(fetch4.retryScheduled).toBe(false) // Exhausted again + }) + + test('demonstrates the old bug: clearRetryState would reset count causing infinite loop', () => { + // This test documents the OLD buggy behavior. + // The old code called clearRetryState (which deletes retryCounts) right after + // setting the retry count, effectively resetting it to 0 every time. + const queryKey = ['subscription', 'current'] + + _retryTestHelpers.setRefCount(queryKey, 1) + + // Step 1: Simulate first fetch failure setting retry count to 1 + _retryTestHelpers.setRetryCount(queryKey, 1) + expect(_retryTestHelpers.getRetryCount(queryKey)).toBe(1) + + // Step 2: OLD CODE would call clearRetryState here, which resets count to 0: + // clearRetryState(key) → retryCounts.delete(key) → count = 0 + // Simulate the old bug by manually resetting: + _retryTestHelpers.setRetryCount(queryKey, 0) + expect(_retryTestHelpers.getRetryCount(queryKey)).toBe(0) + + // Step 3: When the retry fires after 1s, it reads count as 0 + // 0 < maxRetries(1) → true → schedules ANOTHER retry (should have been exhausted!) + const result = _retryTestHelpers.simulateFailedFetch(queryKey, 1) + expect(result.retryScheduled).toBe(true) // BUG: should have been false! + expect(result.retryCount).toBe(1) // Count set to 1 again... + + // And the cycle repeats: count gets reset → retry fires → count is 0 → retry... + // With the fix (clearRetryTimeout instead of clearRetryState), count stays at 1 + // so the next attempt correctly sees 1 >= maxRetries(1) → exhausted. + }) + + test('retry count resets to 0 when retries are exhausted', () => { + const queryKey = ['retry-reset-test'] + const maxRetries = 2 + + _retryTestHelpers.setRefCount(queryKey, 1) + + // First fail → retry scheduled, count=1 + const r1 = _retryTestHelpers.simulateFailedFetch(queryKey, maxRetries) + expect(r1).toEqual({ retryScheduled: true, retryCount: 1 }) + + // Second fail → retry scheduled, count=2 + const r2 = _retryTestHelpers.simulateFailedFetch(queryKey, maxRetries) + expect(r2).toEqual({ retryScheduled: true, retryCount: 2 }) + + // Third fail → retries exhausted, count reset to 0 + const r3 = _retryTestHelpers.simulateFailedFetch(queryKey, maxRetries) + expect(r3).toEqual({ retryScheduled: false, retryCount: 0 }) + }) + + test('no retries when retry is 0 or false', () => { + const queryKey = ['no-retry-test'] + _retryTestHelpers.setRefCount(queryKey, 1) + + // retry: 0 (equivalent to retry: false) + const result = _retryTestHelpers.simulateFailedFetch(queryKey, 0) + expect(result.retryScheduled).toBe(false) + expect(result.retryCount).toBe(0) + }) + + test('no retries when component is unmounted (refCount=0)', () => { + const queryKey = ['unmounted-test'] + // Don't set refCount (defaults to 0 = no mounted components) + + const result = _retryTestHelpers.simulateFailedFetch(queryKey, 1) + expect(result.retryScheduled).toBe(false) + }) + + test('error-only entry is created after retries exhausted', () => { + const queryKey = ['error-entry-after-retry'] + _retryTestHelpers.setRefCount(queryKey, 1) + + // First fail → retry + _retryTestHelpers.simulateFailedFetch(queryKey, 1) + + // No cache entry yet during retry phase + expect(getActivityQueryData(queryKey)).toBeUndefined() + + // Second fail → exhausted, error entry created + _retryTestHelpers.simulateFailedFetch(queryKey, 1) + + // Error entry should exist (data is undefined but entry exists) + // The entry has error set, which we can verify via isEntryStale behavior + const serializedKey = JSON.stringify(queryKey) + // Entry exists (not stale due to "no entry" - stale due to other reasons) + // Since we just set errorUpdatedAt = Date.now(), it should not be stale + // for a reasonable staleTime + expect(isEntryStale(serializedKey, 30000)).toBe(false) + }) +}) diff --git a/cli/src/hooks/__tests__/use-ask-user-bridge.test.ts b/cli/src/hooks/__tests__/use-ask-user-bridge.test.ts new file mode 100644 index 0000000000..5852f89670 --- /dev/null +++ b/cli/src/hooks/__tests__/use-ask-user-bridge.test.ts @@ -0,0 +1,176 @@ +import { AskUserBridge } from '@codebuff/common/utils/ask-user-bridge' +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test' + + +import { useChatStore } from '../../state/chat-store' + +describe('useAskUserBridge', () => { + const submitAnswers = ( + answers: Array<{ + questionIndex: number + selectedOption?: string + selectedOptions?: string[] + otherText?: string + }> + ) => { + AskUserBridge.submit({ answers }) + } + + const skip = () => { + AskUserBridge.submit({ skipped: true }) + } + + let submitSpy: ReturnType + + beforeEach(() => { + // Mock AskUserBridge.submit to track calls + submitSpy = spyOn(AskUserBridge, 'submit') + + // Reset the chat store to a known state with some input + useChatStore.setState({ + inputValue: 'user input that should be preserved', + cursorPosition: 35, + lastEditDueToNav: false, + askUserState: null, + }) + }) + + afterEach(() => { + submitSpy.mockRestore() + }) + + describe('submitAnswers', () => { + test('calls AskUserBridge.submit with the provided answers', () => { + const answers = [ + { questionIndex: 0, selectedOption: 'Option A' }, + { questionIndex: 1, selectedOptions: ['Option B', 'Option C'] }, + ] + + submitAnswers(answers) + + expect(submitSpy).toHaveBeenCalledTimes(1) + expect(submitSpy).toHaveBeenCalledWith({ answers }) + }) + + test('does NOT modify the input value in the store', () => { + const originalInputValue = useChatStore.getState().inputValue + const originalCursorPosition = useChatStore.getState().cursorPosition + + submitAnswers([{ questionIndex: 0, selectedOption: 'Test' }]) + + // Verify input value was NOT changed + const currentState = useChatStore.getState() + expect(currentState.inputValue).toBe(originalInputValue) + expect(currentState.cursorPosition).toBe(originalCursorPosition) + }) + + test('preserves input value with empty answers array', () => { + const originalInputValue = useChatStore.getState().inputValue + + submitAnswers([]) + + expect(useChatStore.getState().inputValue).toBe(originalInputValue) + expect(submitSpy).toHaveBeenCalledWith({ answers: [] }) + }) + + test('preserves input value with multiple question answers', () => { + const originalInputValue = useChatStore.getState().inputValue + + const answers = [ + { questionIndex: 0, selectedOption: 'First answer' }, + { questionIndex: 1, selectedOptions: ['Multi 1', 'Multi 2'] }, + { questionIndex: 2, otherText: 'Custom text input' }, + ] + + submitAnswers(answers) + + expect(useChatStore.getState().inputValue).toBe(originalInputValue) + }) + }) + + describe('skip', () => { + test('calls AskUserBridge.submit with skipped: true', () => { + skip() + + expect(submitSpy).toHaveBeenCalledTimes(1) + expect(submitSpy).toHaveBeenCalledWith({ skipped: true }) + }) + + test('does NOT modify the input value in the store', () => { + const originalInputValue = useChatStore.getState().inputValue + const originalCursorPosition = useChatStore.getState().cursorPosition + + skip() + + // Verify input value was NOT changed + const currentState = useChatStore.getState() + expect(currentState.inputValue).toBe(originalInputValue) + expect(currentState.cursorPosition).toBe(originalCursorPosition) + }) + }) + + describe('input preservation regression tests', () => { + test('input with special characters is preserved after submitAnswers', () => { + useChatStore.setState({ + inputValue: 'Input with "quotes" and `backticks` and @mentions', + cursorPosition: 48, + }) + + const originalInputValue = useChatStore.getState().inputValue + + submitAnswers([{ questionIndex: 0, selectedOption: 'Test' }]) + + expect(useChatStore.getState().inputValue).toBe(originalInputValue) + }) + + test('input with special characters is preserved after skip', () => { + useChatStore.setState({ + inputValue: "Don't lose this apostrophe or @file-picker mention", + cursorPosition: 51, + }) + + const originalInputValue = useChatStore.getState().inputValue + + skip() + + expect(useChatStore.getState().inputValue).toBe(originalInputValue) + }) + + test('multiline input is preserved after submitAnswers', () => { + useChatStore.setState({ + inputValue: 'Line 1\nLine 2\nLine 3', + cursorPosition: 20, + }) + + const originalInputValue = useChatStore.getState().inputValue + + submitAnswers([{ questionIndex: 0, selectedOption: 'Test' }]) + + expect(useChatStore.getState().inputValue).toBe(originalInputValue) + }) + + test('empty input remains empty after submitAnswers', () => { + useChatStore.setState({ + inputValue: '', + cursorPosition: 0, + }) + + submitAnswers([{ questionIndex: 0, selectedOption: 'Test' }]) + + expect(useChatStore.getState().inputValue).toBe('') + expect(useChatStore.getState().cursorPosition).toBe(0) + }) + + test('empty input remains empty after skip', () => { + useChatStore.setState({ + inputValue: '', + cursorPosition: 0, + }) + + skip() + + expect(useChatStore.getState().inputValue).toBe('') + expect(useChatStore.getState().cursorPosition).toBe(0) + }) + }) +}) diff --git a/cli/src/hooks/__tests__/use-claude-quota-query.test.ts b/cli/src/hooks/__tests__/use-claude-quota-query.test.ts deleted file mode 100644 index ade5f1356b..0000000000 --- a/cli/src/hooks/__tests__/use-claude-quota-query.test.ts +++ /dev/null @@ -1,780 +0,0 @@ -import { - describe, - test, - expect, - beforeEach, - afterEach, - mock, -} from 'bun:test' - -import { - fetchClaudeQuota, - claudeQuotaQueryKeys, - type ClaudeQuotaResponse, - type ClaudeQuotaData, -} from '../use-claude-quota-query' -import { - resetActivityQueryCache, - getActivityQueryData, - setActivityQueryData, - invalidateActivityQuery, - isEntryStale, -} from '../use-activity-query' - -import type { Logger } from '@codebuff/common/types/contracts/logger' - -/** - * Tests for the Claude quota query hook and related functionality. - * These tests verify that Claude subscription data is properly - * fetched, cached, and updated for display in the bottom status bar. - */ - -describe('claudeQuotaQueryKeys', () => { - test('all returns base query key', () => { - expect(claudeQuotaQueryKeys.all).toEqual(['claude-quota']) - }) - - test('current returns extended query key', () => { - expect(claudeQuotaQueryKeys.current()).toEqual(['claude-quota', 'current']) - }) - - test('current returns new array instance each call', () => { - const first = claudeQuotaQueryKeys.current() - const second = claudeQuotaQueryKeys.current() - expect(first).not.toBe(second) - expect(first).toEqual(second) - }) -}) - -describe('fetchClaudeQuota', () => { - const originalFetch = globalThis.fetch - let mockLogger: Logger - - beforeEach(() => { - mockLogger = { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), - } - }) - - afterEach(() => { - globalThis.fetch = originalFetch - mock.restore() - }) - - test('should fetch and parse quota data successfully', async () => { - const mockResponse: ClaudeQuotaResponse = { - five_hour: { - utilization: 20, - resets_at: '2024-02-01T12:00:00Z', - }, - seven_day: { - utilization: 10, - resets_at: '2024-02-07T00:00:00Z', - }, - seven_day_oauth_apps: null, - seven_day_opus: null, - } - - globalThis.fetch = mock(async () => - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch - - const result = await fetchClaudeQuota('test-access-token', mockLogger) - - expect(result.fiveHourRemaining).toBe(80) // 100 - 20 - expect(result.sevenDayRemaining).toBe(90) // 100 - 10 - expect(result.fiveHourResetsAt).toEqual(new Date('2024-02-01T12:00:00Z')) - expect(result.sevenDayResetsAt).toEqual(new Date('2024-02-07T00:00:00Z')) - }) - - test('should handle 100% utilization correctly', async () => { - const mockResponse: ClaudeQuotaResponse = { - five_hour: { - utilization: 100, - resets_at: '2024-02-01T12:00:00Z', - }, - seven_day: { - utilization: 100, - resets_at: '2024-02-07T00:00:00Z', - }, - seven_day_oauth_apps: null, - seven_day_opus: null, - } - - globalThis.fetch = mock(async () => - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch - - const result = await fetchClaudeQuota('test-token', mockLogger) - - expect(result.fiveHourRemaining).toBe(0) - expect(result.sevenDayRemaining).toBe(0) - }) - - test('should handle over 100% utilization by clamping to 0', async () => { - const mockResponse: ClaudeQuotaResponse = { - five_hour: { - utilization: 150, // Over 100% - resets_at: '2024-02-01T12:00:00Z', - }, - seven_day: { - utilization: 200, - resets_at: '2024-02-07T00:00:00Z', - }, - seven_day_oauth_apps: null, - seven_day_opus: null, - } - - globalThis.fetch = mock(async () => - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch - - const result = await fetchClaudeQuota('test-token', mockLogger) - - expect(result.fiveHourRemaining).toBe(0) // Math.max(0, 100-150) = 0 - expect(result.sevenDayRemaining).toBe(0) - }) - - test('should handle null five_hour window', async () => { - const mockResponse: ClaudeQuotaResponse = { - five_hour: null, - seven_day: { - utilization: 30, - resets_at: '2024-02-07T00:00:00Z', - }, - seven_day_oauth_apps: null, - seven_day_opus: null, - } - - globalThis.fetch = mock(async () => - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch - - const result = await fetchClaudeQuota('test-token', mockLogger) - - expect(result.fiveHourRemaining).toBe(100) // Default when null - expect(result.fiveHourResetsAt).toBeNull() - expect(result.sevenDayRemaining).toBe(70) - }) - - test('should handle null seven_day window', async () => { - const mockResponse: ClaudeQuotaResponse = { - five_hour: { - utilization: 50, - resets_at: '2024-02-01T12:00:00Z', - }, - seven_day: null, - seven_day_oauth_apps: null, - seven_day_opus: null, - } - - globalThis.fetch = mock(async () => - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch - - const result = await fetchClaudeQuota('test-token', mockLogger) - - expect(result.fiveHourRemaining).toBe(50) - expect(result.sevenDayRemaining).toBe(100) // Default when null - expect(result.sevenDayResetsAt).toBeNull() - }) - - test('should handle both windows being null', async () => { - const mockResponse: ClaudeQuotaResponse = { - five_hour: null, - seven_day: null, - seven_day_oauth_apps: null, - seven_day_opus: null, - } - - globalThis.fetch = mock(async () => - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch - - const result = await fetchClaudeQuota('test-token', mockLogger) - - expect(result.fiveHourRemaining).toBe(100) - expect(result.fiveHourResetsAt).toBeNull() - expect(result.sevenDayRemaining).toBe(100) - expect(result.sevenDayResetsAt).toBeNull() - }) - - test('should handle null reset times', async () => { - const mockResponse: ClaudeQuotaResponse = { - five_hour: { - utilization: 25, - resets_at: null, - }, - seven_day: { - utilization: 15, - resets_at: null, - }, - seven_day_oauth_apps: null, - seven_day_opus: null, - } - - globalThis.fetch = mock(async () => - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch - - const result = await fetchClaudeQuota('test-token', mockLogger) - - expect(result.fiveHourRemaining).toBe(75) - expect(result.fiveHourResetsAt).toBeNull() - expect(result.sevenDayRemaining).toBe(85) - expect(result.sevenDayResetsAt).toBeNull() - }) - - test('should throw error on 401 unauthorized', async () => { - globalThis.fetch = mock(async () => - new Response('Unauthorized', { status: 401 }), - ) as unknown as typeof fetch - - await expect( - fetchClaudeQuota('invalid-token', mockLogger), - ).rejects.toThrow('Failed to fetch Claude quota: 401') - }) - - test('should throw error on 403 forbidden', async () => { - globalThis.fetch = mock(async () => - new Response('Forbidden', { status: 403 }), - ) as unknown as typeof fetch - - await expect( - fetchClaudeQuota('test-token', mockLogger), - ).rejects.toThrow('Failed to fetch Claude quota: 403') - }) - - test('should throw error on 500 server error', async () => { - globalThis.fetch = mock(async () => - new Response('Server Error', { status: 500 }), - ) as unknown as typeof fetch - - await expect( - fetchClaudeQuota('test-token', mockLogger), - ).rejects.toThrow('Failed to fetch Claude quota: 500') - }) - - test('should log debug message on failed request', async () => { - const debugSpy = mock(() => {}) - const testLogger: Logger = { - ...mockLogger, - debug: debugSpy, - } - - globalThis.fetch = mock(async () => - new Response('Error', { status: 429 }), - ) as unknown as typeof fetch - - await expect( - fetchClaudeQuota('test-token', testLogger), - ).rejects.toThrow() - - expect(debugSpy).toHaveBeenCalledWith( - { status: 429 }, - 'Failed to fetch Claude quota data', - ) - }) - - test('should send correct headers', async () => { - let capturedHeaders: HeadersInit | undefined - - globalThis.fetch = mock(async (url: string, init?: RequestInit) => { - capturedHeaders = init?.headers - return new Response( - JSON.stringify({ - five_hour: null, - seven_day: null, - seven_day_oauth_apps: null, - seven_day_opus: null, - }), - { status: 200 }, - ) - }) as unknown as typeof fetch - - await fetchClaudeQuota('test-access-token', mockLogger) - - const headers = capturedHeaders as Record - expect(headers['Authorization']).toBe('Bearer test-access-token') - expect(headers['Content-Type']).toBe('application/json') - expect(headers['anthropic-version']).toBe('2023-06-01') - expect(headers['anthropic-beta']).toBe('oauth-2025-04-20,claude-code-20250219') - }) - - test('should call correct API endpoint', async () => { - let capturedUrl: string | undefined - - globalThis.fetch = mock(async (url: string) => { - capturedUrl = url - return new Response( - JSON.stringify({ - five_hour: null, - seven_day: null, - seven_day_oauth_apps: null, - seven_day_opus: null, - }), - { status: 200 }, - ) - }) as unknown as typeof fetch - - await fetchClaudeQuota('test-token', mockLogger) - - expect(capturedUrl).toBe('https://api.anthropic.com/api/oauth/usage') - }) -}) - -/** - * Tests for Claude quota cache behavior. - * These tests verify that quota data is properly cached and updated - * using the activity query cache system. - */ -describe('Claude quota cache behavior', () => { - beforeEach(() => { - resetActivityQueryCache() - }) - - afterEach(() => { - mock.restore() - }) - - test('should store and retrieve Claude quota data from cache', () => { - const mockQuota: ClaudeQuotaData = { - fiveHourRemaining: 75, - fiveHourResetsAt: new Date('2024-02-01T12:00:00Z'), - sevenDayRemaining: 85, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaQueryKeys.current(), mockQuota) - - const cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - expect(cached?.fiveHourRemaining).toBe(75) - expect(cached?.sevenDayRemaining).toBe(85) - }) - - test('should update cache when new quota data is fetched', () => { - const initialQuota: ClaudeQuotaData = { - fiveHourRemaining: 100, - fiveHourResetsAt: new Date('2024-02-01T12:00:00Z'), - sevenDayRemaining: 100, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaQueryKeys.current(), initialQuota) - expect( - getActivityQueryData(claudeQuotaQueryKeys.current())?.fiveHourRemaining, - ).toBe(100) - - // Simulate usage depleting quota - const updatedQuota: ClaudeQuotaData = { - fiveHourRemaining: 50, - fiveHourResetsAt: new Date('2024-02-01T12:00:00Z'), - sevenDayRemaining: 90, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaQueryKeys.current(), updatedQuota) - expect( - getActivityQueryData(claudeQuotaQueryKeys.current())?.fiveHourRemaining, - ).toBe(50) - }) - - test('should preserve quota data after invalidation', () => { - const mockQuota: ClaudeQuotaData = { - fiveHourRemaining: 60, - fiveHourResetsAt: new Date('2024-02-01T12:00:00Z'), - sevenDayRemaining: 70, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaQueryKeys.current(), mockQuota) - invalidateActivityQuery(claudeQuotaQueryKeys.current()) - - // Data should still be accessible for display while refetch happens - const cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - expect(cached?.fiveHourRemaining).toBe(60) - expect(cached?.sevenDayRemaining).toBe(70) - }) - - test('should handle quota exhaustion (0% remaining)', () => { - const exhaustedQuota: ClaudeQuotaData = { - fiveHourRemaining: 0, - fiveHourResetsAt: new Date('2024-02-01T14:00:00Z'), - sevenDayRemaining: 5, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaQueryKeys.current(), exhaustedQuota) - - const cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - expect(cached?.fiveHourRemaining).toBe(0) - expect(cached?.sevenDayRemaining).toBe(5) - }) - - test('reset cache should clear Claude quota data', () => { - const mockQuota: ClaudeQuotaData = { - fiveHourRemaining: 50, - fiveHourResetsAt: null, - sevenDayRemaining: 50, - sevenDayResetsAt: null, - } - - setActivityQueryData(claudeQuotaQueryKeys.current(), mockQuota) - expect(getActivityQueryData(claudeQuotaQueryKeys.current())).toBeDefined() - - resetActivityQueryCache() - - expect(getActivityQueryData(claudeQuotaQueryKeys.current())).toBeUndefined() - }) -}) - -/** - * Tests simulating the bottom status line display scenarios. - * These verify the data flow from cache to UI display. - */ -describe('Bottom status line display scenarios', () => { - beforeEach(() => { - resetActivityQueryCache() - }) - - test('should compute minimum of 5-hour and 7-day for display', () => { - const quota: ClaudeQuotaData = { - fiveHourRemaining: 30, // More restrictive - fiveHourResetsAt: new Date('2024-02-01T14:00:00Z'), - sevenDayRemaining: 80, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaQueryKeys.current(), quota) - const cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - - // The BottomStatusLine component uses Math.min(fiveHour, sevenDay) - const displayRemaining = Math.min( - cached!.fiveHourRemaining, - cached!.sevenDayRemaining, - ) - expect(displayRemaining).toBe(30) - }) - - test('should handle 7-day being more restrictive than 5-hour', () => { - const quota: ClaudeQuotaData = { - fiveHourRemaining: 90, - fiveHourResetsAt: new Date('2024-02-01T14:00:00Z'), - sevenDayRemaining: 10, // More restrictive - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaQueryKeys.current(), quota) - const cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - - const displayRemaining = Math.min( - cached!.fiveHourRemaining, - cached!.sevenDayRemaining, - ) - expect(displayRemaining).toBe(10) - }) - - test('should detect exhausted quota (0%)', () => { - const quota: ClaudeQuotaData = { - fiveHourRemaining: 0, - fiveHourResetsAt: new Date('2024-02-01T14:00:00Z'), - sevenDayRemaining: 50, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaQueryKeys.current(), quota) - const cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - - const displayRemaining = Math.min( - cached!.fiveHourRemaining, - cached!.sevenDayRemaining, - ) - const isExhausted = displayRemaining <= 0 - - expect(isExhausted).toBe(true) - }) - - test('should update display value when quota changes', () => { - // Initial state: plenty of quota - const initialQuota: ClaudeQuotaData = { - fiveHourRemaining: 80, - fiveHourResetsAt: new Date('2024-02-01T14:00:00Z'), - sevenDayRemaining: 90, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - setActivityQueryData(claudeQuotaQueryKeys.current(), initialQuota) - - let cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - let displayRemaining = Math.min( - cached!.fiveHourRemaining, - cached!.sevenDayRemaining, - ) - expect(displayRemaining).toBe(80) - - // After usage: depleted quota - const depletedQuota: ClaudeQuotaData = { - fiveHourRemaining: 20, - fiveHourResetsAt: new Date('2024-02-01T14:00:00Z'), - sevenDayRemaining: 85, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - setActivityQueryData(claudeQuotaQueryKeys.current(), depletedQuota) - - cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - displayRemaining = Math.min( - cached!.fiveHourRemaining, - cached!.sevenDayRemaining, - ) - expect(displayRemaining).toBe(20) - }) - - test('should select correct reset time based on limiting quota', () => { - // 5-hour is limiting - const quota: ClaudeQuotaData = { - fiveHourRemaining: 10, - fiveHourResetsAt: new Date('2024-02-01T14:00:00Z'), - sevenDayRemaining: 80, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - - setActivityQueryData(claudeQuotaQueryKeys.current(), quota) - const cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - - // BottomStatusLine logic for selecting reset time - const resetTime = cached!.fiveHourRemaining <= cached!.sevenDayRemaining - ? cached!.fiveHourResetsAt - : cached!.sevenDayResetsAt - - expect(resetTime).toEqual(new Date('2024-02-01T14:00:00Z')) - }) -}) - -/** - * Tests for polling behavior and cache freshness. - * These verify that the quota data is refreshed at appropriate intervals. - */ -describe('Polling and cache freshness', () => { - let originalDateNow: typeof Date.now - let mockNow: number - - beforeEach(() => { - resetActivityQueryCache() - originalDateNow = Date.now - mockNow = 1000000 - Date.now = () => mockNow - }) - - afterEach(() => { - Date.now = originalDateNow - }) - - test('data should become stale after staleTime (30s)', () => { - const staleTime = 30000 // 30 seconds - const serializedKey = JSON.stringify(claudeQuotaQueryKeys.current()) - - // Set quota data at t=0 - const quota: ClaudeQuotaData = { - fiveHourRemaining: 50, - fiveHourResetsAt: null, - sevenDayRemaining: 60, - sevenDayResetsAt: null, - } - setActivityQueryData(claudeQuotaQueryKeys.current(), quota) - - // At this point, dataUpdatedAt = mockNow (1000000) - expect(getActivityQueryData(claudeQuotaQueryKeys.current())).toBeDefined() - expect(isEntryStale(serializedKey, staleTime)).toBe(false) - - // Advance time by 35 seconds (past staleTime) - mockNow += 35000 - - // Data is stale but still accessible - expect(isEntryStale(serializedKey, staleTime)).toBe(true) - const cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - expect(cached?.fiveHourRemaining).toBe(50) - - // In the actual hook, this would trigger a refetch on the next interval tick - }) - - test('refreshed data should reset staleness', () => { - const staleTime = 30000 - const serializedKey = JSON.stringify(claudeQuotaQueryKeys.current()) - - // Set initial data - setActivityQueryData(claudeQuotaQueryKeys.current(), { fiveHourRemaining: 100 }) - expect(isEntryStale(serializedKey, staleTime)).toBe(false) - - // Advance past staleTime - mockNow += 35000 - expect(isEntryStale(serializedKey, staleTime)).toBe(true) - - // "Refetch" by setting new data - setActivityQueryData(claudeQuotaQueryKeys.current(), { fiveHourRemaining: 80 }) - expect(isEntryStale(serializedKey, staleTime)).toBe(false) // Fresh again - - // Data is now fresh - expect( - getActivityQueryData<{ fiveHourRemaining: number }>(claudeQuotaQueryKeys.current())?.fiveHourRemaining, - ).toBe(80) - - // Advance a little (less than staleTime) - mockNow += 10000 - expect(isEntryStale(serializedKey, staleTime)).toBe(false) // Still fresh - }) - - test('invalidation should mark data for immediate refetch', () => { - const staleTime = 30000 - const serializedKey = JSON.stringify(claudeQuotaQueryKeys.current()) - - // Set data - setActivityQueryData(claudeQuotaQueryKeys.current(), { fiveHourRemaining: 70 }) - expect(isEntryStale(serializedKey, staleTime)).toBe(false) - - // Invalidate (sets dataUpdatedAt to 0) - invalidateActivityQuery(claudeQuotaQueryKeys.current()) - expect(isEntryStale(serializedKey, staleTime)).toBe(true) // Immediately stale - - // Data exists but is immediately stale (dataUpdatedAt === 0) - // Next poll interval will trigger refetch regardless of time elapsed - expect( - getActivityQueryData<{ fiveHourRemaining: number }>(claudeQuotaQueryKeys.current())?.fiveHourRemaining, - ).toBe(70) - }) - - test('useClaudeQuotaQuery staleTime of 30s means polling at 60s should always refetch', () => { - // This test verifies the actual configuration used in useClaudeQuotaQuery: - // staleTime: 30 * 1000 (30 seconds) - // refetchInterval: 60 * 1000 (60 seconds, from chat.tsx) - - const staleTime = 30 * 1000 // useClaudeQuotaQuery config - const refetchInterval = 60 * 1000 // chat.tsx config - const serializedKey = JSON.stringify(claudeQuotaQueryKeys.current()) - - // Initial fetch - setActivityQueryData(claudeQuotaQueryKeys.current(), { fiveHourRemaining: 100 }) - expect(isEntryStale(serializedKey, staleTime)).toBe(false) - - // After 60 seconds (when refetch interval fires), data should be stale - mockNow += refetchInterval - expect(isEntryStale(serializedKey, staleTime)).toBe(true) - - // This confirms that the refetch interval tick WILL trigger a new fetch - // because the data is stale at that point (60s > 30s staleTime) - }) -}) - -/** - * Tests for error recovery and edge cases in quota fetching. - */ -describe('Error recovery and edge cases', () => { - const originalFetch = globalThis.fetch - - beforeEach(() => { - resetActivityQueryCache() - }) - - afterEach(() => { - globalThis.fetch = originalFetch - mock.restore() - }) - - test('should preserve old data in cache during fetch error', () => { - // Simulate having cached data - const cachedQuota: ClaudeQuotaData = { - fiveHourRemaining: 50, - fiveHourResetsAt: new Date('2024-02-01T14:00:00Z'), - sevenDayRemaining: 60, - sevenDayResetsAt: new Date('2024-02-07T00:00:00Z'), - } - setActivityQueryData(claudeQuotaQueryKeys.current(), cachedQuota) - - // If fetch fails, the cached data should still be available - // (useActivityQuery preserves data on error) - const cached = getActivityQueryData(claudeQuotaQueryKeys.current()) - expect(cached?.fiveHourRemaining).toBe(50) - }) - - test('should handle network timeout gracefully', async () => { - const mockLogger: Logger = { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), - } - - globalThis.fetch = mock(async () => { - const error = new Error('Request timeout') - error.name = 'TimeoutError' - throw error - }) as unknown as typeof fetch - - await expect( - fetchClaudeQuota('test-token', mockLogger), - ).rejects.toThrow('Request timeout') - }) - - test('should handle malformed JSON response', async () => { - const mockLogger: Logger = { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), - } - - globalThis.fetch = mock(async () => - new Response('not json', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch - - await expect( - fetchClaudeQuota('test-token', mockLogger), - ).rejects.toThrow() - }) - - test('should handle empty response body', async () => { - const mockLogger: Logger = { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), - } - - globalThis.fetch = mock(async () => - new Response('{}', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch - - // Empty response should parse with defaults - const result = await fetchClaudeQuota('test-token', mockLogger) - expect(result.fiveHourRemaining).toBe(100) // Default when null - expect(result.sevenDayRemaining).toBe(100) - }) -}) diff --git a/cli/src/hooks/__tests__/use-directory-browser.test.ts b/cli/src/hooks/__tests__/use-directory-browser.test.ts index 83d8c63b04..899a9c4303 100644 --- a/cli/src/hooks/__tests__/use-directory-browser.test.ts +++ b/cli/src/hooks/__tests__/use-directory-browser.test.ts @@ -1,4 +1,3 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' import { existsSync, mkdirSync, @@ -10,6 +9,8 @@ import { import os from 'os' import path from 'path' +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' + /** * Tests for useDirectoryBrowser hook logic. * diff --git a/cli/src/hooks/__tests__/use-grid-layout.test.ts b/cli/src/hooks/__tests__/use-grid-layout.test.ts new file mode 100644 index 0000000000..ed01a6beba --- /dev/null +++ b/cli/src/hooks/__tests__/use-grid-layout.test.ts @@ -0,0 +1,349 @@ +import { describe, test, expect } from 'bun:test' + +import { MIN_COLUMN_WIDTH } from '../../utils/layout-helpers' +import { + computeGridLayout, + WIDTH_MD_THRESHOLD, + WIDTH_LG_THRESHOLD, + WIDTH_XL_THRESHOLD, +} from '../use-grid-layout' + +describe('computeGridLayout', () => { + describe('threshold constants', () => { + test('thresholds are in ascending order', () => { + expect(WIDTH_MD_THRESHOLD).toBeLessThan(WIDTH_LG_THRESHOLD) + expect(WIDTH_LG_THRESHOLD).toBeLessThan(WIDTH_XL_THRESHOLD) + }) + + test('WIDTH_MD_THRESHOLD is 100', () => { + expect(WIDTH_MD_THRESHOLD).toBe(100) + }) + + test('WIDTH_LG_THRESHOLD is 150', () => { + expect(WIDTH_LG_THRESHOLD).toBe(150) + }) + + test('WIDTH_XL_THRESHOLD is 200', () => { + expect(WIDTH_XL_THRESHOLD).toBe(200) + }) + }) + + describe('maxColumns based on availableWidth', () => { + test('narrow width (< 100) gets 1 column max', () => { + const items = ['a', 'b', 'c', 'd'] + const result = computeGridLayout(items, 80) + expect(result.columns).toBe(1) + }) + + test('medium width (100-149) gets 2 columns max', () => { + const items = ['a', 'b', 'c', 'd'] + const result = computeGridLayout(items, 120) + expect(result.columns).toBe(2) + }) + + test('large width (150-199) gets 3 columns max', () => { + const items = ['a', 'b', 'c', 'd', 'e', 'f'] + const result = computeGridLayout(items, 180) + expect(result.columns).toBe(3) + }) + + test('extra large width (>= 200) gets 4 columns max', () => { + const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] + const result = computeGridLayout(items, 250) + expect(result.columns).toBe(4) + }) + }) + + describe('threshold boundaries', () => { + test('width 99 gives 1 column max', () => { + const items = ['a', 'b', 'c'] + const result = computeGridLayout(items, 99) + expect(result.columns).toBe(1) + }) + + test('width 100 gives 2 columns max', () => { + const items = ['a', 'b', 'c'] + const result = computeGridLayout(items, 100) + expect(result.columns).toBe(2) + }) + + test('width 149 gives 2 columns max', () => { + const items = ['a', 'b', 'c'] + const result = computeGridLayout(items, 149) + expect(result.columns).toBe(2) + }) + + test('width 150 gives 3 columns max', () => { + const items = ['a', 'b', 'c'] + const result = computeGridLayout(items, 150) + expect(result.columns).toBe(3) + }) + + test('width 199 gives 3 columns max (but 4 items prefer 2x2)', () => { + // 4 items with maxColumns=3 prefers 2 columns (2x2 grid) via computeSmartColumns + const items = ['a', 'b', 'c', 'd'] + const result = computeGridLayout(items, 199) + expect(result.columns).toBe(2) + + // 3 items actually uses 3 columns + const threeItems = ['a', 'b', 'c'] + const result3 = computeGridLayout(threeItems, 199) + expect(result3.columns).toBe(3) + }) + + test('width 200 gives 4 columns max', () => { + const items = ['a', 'b', 'c', 'd'] + const result = computeGridLayout(items, 200) + expect(result.columns).toBe(4) + }) + }) + + describe('column count based on item count', () => { + test('0 items gives 1 column', () => { + const result = computeGridLayout([], 200) + expect(result.columns).toBe(1) + }) + + test('1 item gives 1 column', () => { + const result = computeGridLayout(['a'], 200) + expect(result.columns).toBe(1) + }) + + test('2 items on wide screen gives 2 columns', () => { + const result = computeGridLayout(['a', 'b'], 200) + expect(result.columns).toBe(2) + }) + + test('3 items on wide screen gives 3 columns', () => { + const result = computeGridLayout(['a', 'b', 'c'], 200) + expect(result.columns).toBe(3) + }) + + test('4 items on 3-column max gives 2 columns (2x2 grid)', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd'], 180) + expect(result.columns).toBe(2) + }) + + test('6 items on 3-column max gives 3 columns', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd', 'e', 'f'], 180) + expect(result.columns).toBe(3) + }) + }) + + describe('columnWidth calculation', () => { + test('single column uses full availableWidth', () => { + const result = computeGridLayout(['a'], 120) + expect(result.columnWidth).toBe(120) + }) + + test('2 columns splits width with 1 char gap', () => { + const result = computeGridLayout(['a', 'b'], 121) + // 121 - 1 gap = 120, divided by 2 = 60 + expect(result.columnWidth).toBe(60) + }) + + test('3 columns splits width with 2 char gaps', () => { + const result = computeGridLayout(['a', 'b', 'c'], 182) + // 182 - 2 gaps = 180, divided by 3 = 60 + expect(result.columnWidth).toBe(60) + }) + + test('4 columns splits width with 3 char gaps', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd'], 243) + // 243 - 3 gaps = 240, divided by 4 = 60 + expect(result.columnWidth).toBe(60) + }) + + test('columnWidth respects MIN_COLUMN_WIDTH', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd'], 200) + expect(result.columnWidth).toBeGreaterThanOrEqual(MIN_COLUMN_WIDTH) + }) + + test('very narrow width with multiple items clamps to MIN_COLUMN_WIDTH', () => { + // Force 2 columns with narrow width + const result = computeGridLayout(['a', 'b'], 105) + // 105 - 1 gap = 104, divided by 2 = 52 + expect(result.columnWidth).toBe(52) + }) + }) + + describe('columnGroups distribution (round-robin)', () => { + test('empty items gives single empty column', () => { + const result = computeGridLayout([], 200) + expect(result.columnGroups).toEqual([[]]) + }) + + test('1 item in 1 column', () => { + const result = computeGridLayout(['a'], 200) + expect(result.columnGroups).toEqual([['a']]) + }) + + test('2 items distributed across 2 columns', () => { + const result = computeGridLayout(['a', 'b'], 200) + expect(result.columnGroups).toEqual([['a'], ['b']]) + }) + + test('3 items distributed across 3 columns', () => { + const result = computeGridLayout(['a', 'b', 'c'], 200) + expect(result.columnGroups).toEqual([['a'], ['b'], ['c']]) + }) + + test('4 items in 2 columns (round-robin)', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd'], 120) + expect(result.columnGroups).toEqual([ + ['a', 'c'], + ['b', 'd'], + ]) + }) + + test('5 items in 2 columns (uneven distribution)', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd', 'e'], 120) + expect(result.columnGroups).toEqual([ + ['a', 'c', 'e'], + ['b', 'd'], + ]) + }) + + test('6 items in 3 columns', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd', 'e', 'f'], 180) + expect(result.columnGroups).toEqual([ + ['a', 'd'], + ['b', 'e'], + ['c', 'f'], + ]) + }) + + test('7 items in 3 columns (uneven)', () => { + const result = computeGridLayout( + ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + 180, + ) + expect(result.columnGroups).toEqual([ + ['a', 'd', 'g'], + ['b', 'e'], + ['c', 'f'], + ]) + }) + }) + + describe('return value structure', () => { + test('returns all expected properties', () => { + const result = computeGridLayout(['a', 'b'], 120) + expect(result).toHaveProperty('columns') + expect(result).toHaveProperty('columnWidth') + expect(result).toHaveProperty('columnGroups') + }) + + test('columns is a positive integer', () => { + const result = computeGridLayout(['a', 'b', 'c'], 150) + expect(Number.isInteger(result.columns)).toBe(true) + expect(result.columns).toBeGreaterThan(0) + }) + + test('columnWidth is a positive number', () => { + const result = computeGridLayout(['a', 'b'], 120) + expect(result.columnWidth).toBeGreaterThan(0) + }) + + test('columnGroups length matches columns', () => { + const result = computeGridLayout(['a', 'b', 'c'], 150) + expect(result.columnGroups.length).toBe(result.columns) + }) + + test('total items in columnGroups equals input items', () => { + const items = ['a', 'b', 'c', 'd', 'e'] + const result = computeGridLayout(items, 120) + const totalItems = result.columnGroups.flat().length + expect(totalItems).toBe(items.length) + }) + }) + + describe('generic type support', () => { + test('works with number items', () => { + const result = computeGridLayout([1, 2, 3, 4], 120) + expect(result.columnGroups).toEqual([ + [1, 3], + [2, 4], + ]) + }) + + test('works with object items', () => { + const items = [{ id: 1 }, { id: 2 }, { id: 3 }] + const result = computeGridLayout(items, 150) + expect(result.columnGroups[0][0]).toEqual({ id: 1 }) + expect(result.columnGroups[1][0]).toEqual({ id: 2 }) + expect(result.columnGroups[2][0]).toEqual({ id: 3 }) + }) + + test('preserves item references', () => { + const obj1 = { id: 1 } + const obj2 = { id: 2 } + const result = computeGridLayout([obj1, obj2], 120) + expect(result.columnGroups[0][0]).toBe(obj1) + expect(result.columnGroups[1][0]).toBe(obj2) + }) + }) + + describe('edge cases', () => { + test('very small availableWidth (< MIN_COLUMN_WIDTH)', () => { + const result = computeGridLayout(['a', 'b'], 5) + expect(result.columns).toBe(1) + expect(result.columnWidth).toBe(5) + }) + + test('zero availableWidth clamps columnWidth to 1', () => { + const result = computeGridLayout(['a'], 0) + expect(result.columns).toBe(1) + // columnWidth is clamped to at least 1 to prevent layout issues + expect(result.columnWidth).toBe(1) + }) + + test('negative availableWidth clamps columnWidth to 1', () => { + const result = computeGridLayout(['a'], -10) + expect(result.columns).toBe(1) + // columnWidth is clamped to at least 1 to prevent layout issues + expect(result.columnWidth).toBe(1) + }) + + test('large number of items', () => { + const items = Array.from({ length: 100 }, (_, i) => i) + const result = computeGridLayout(items, 250) + expect(result.columns).toBe(4) + expect(result.columnGroups.length).toBe(4) + expect(result.columnGroups.flat().length).toBe(100) + }) + + test('fractional availableWidth is floored for columnWidth', () => { + const result = computeGridLayout(['a', 'b'], 121) + // (121 - 1) / 2 = 60 + expect(result.columnWidth).toBe(60) + }) + }) + + describe('consistency', () => { + test('same input always produces same output', () => { + const items = ['a', 'b', 'c', 'd'] + const width = 150 + + const result1 = computeGridLayout(items, width) + const result2 = computeGridLayout(items, width) + const result3 = computeGridLayout(items, width) + + expect(result1.columns).toBe(result2.columns) + expect(result2.columns).toBe(result3.columns) + expect(result1.columnWidth).toBe(result2.columnWidth) + expect(result1.columnGroups).toEqual(result2.columnGroups) + }) + + test('deterministic across all threshold boundaries', () => { + const items = ['a', 'b', 'c', 'd'] + const boundaries = [99, 100, 149, 150, 199, 200, 250] + + for (const width of boundaries) { + const result1 = computeGridLayout(items, width) + const result2 = computeGridLayout(items, width) + expect(result1.columns).toBe(result2.columns) + } + }) + }) +}) diff --git a/cli/src/hooks/__tests__/use-input-history.test.ts b/cli/src/hooks/__tests__/use-input-history.test.ts new file mode 100644 index 0000000000..7b61ad81b1 --- /dev/null +++ b/cli/src/hooks/__tests__/use-input-history.test.ts @@ -0,0 +1,699 @@ +import { describe, test, expect } from 'bun:test' + +import type { InputMode } from '../../utils/input-modes' + +// Tests cross-mode history navigation (default <-> bash mode) +// Uses mock implementation since React 19 + Bun + RTL renderHook() is unreliable + +function parseHistoryItem(item: string): { + mode: InputMode + displayText: string +} { + if (item.startsWith('!') && item.length > 1) { + return { mode: 'bash', displayText: item.slice(1) } + } + return { mode: 'default', displayText: item } +} + +describe('use-input-history - parseHistoryItem', () => { + describe('default mode entries', () => { + test('parses regular text as default mode', () => { + const result = parseHistoryItem('hello world') + expect(result.mode).toBe('default') + expect(result.displayText).toBe('hello world') + }) + + test('parses empty string as default mode', () => { + const result = parseHistoryItem('') + expect(result.mode).toBe('default') + expect(result.displayText).toBe('') + }) + + test('parses text with special characters as default mode', () => { + const result = parseHistoryItem('fix the bug in @file.ts') + expect(result.mode).toBe('default') + expect(result.displayText).toBe('fix the bug in @file.ts') + }) + + test('parses multiline text as default mode', () => { + const result = parseHistoryItem('first line\nsecond line') + expect(result.mode).toBe('default') + expect(result.displayText).toBe('first line\nsecond line') + }) + }) + + describe('bash mode entries', () => { + test('parses !command as bash mode', () => { + const result = parseHistoryItem('!ls -la') + expect(result.mode).toBe('bash') + expect(result.displayText).toBe('ls -la') + }) + + test('parses !git command as bash mode', () => { + const result = parseHistoryItem('!git status') + expect(result.mode).toBe('bash') + expect(result.displayText).toBe('git status') + }) + + test('parses complex bash command as bash mode', () => { + const result = parseHistoryItem('!npm run test -- --watch') + expect(result.mode).toBe('bash') + expect(result.displayText).toBe('npm run test -- --watch') + }) + + test('parses piped bash command as bash mode', () => { + const result = parseHistoryItem('!cat file.txt | grep error') + expect(result.mode).toBe('bash') + expect(result.displayText).toBe('cat file.txt | grep error') + }) + }) + + describe('edge cases', () => { + test('single ! is treated as default mode (not bash)', () => { + const result = parseHistoryItem('!') + expect(result.mode).toBe('default') + expect(result.displayText).toBe('!') + }) + + test('! in middle of text is default mode', () => { + const result = parseHistoryItem('hello! world') + expect(result.mode).toBe('default') + expect(result.displayText).toBe('hello! world') + }) + + test('! at end of text is default mode', () => { + const result = parseHistoryItem('hello world!') + expect(result.mode).toBe('default') + expect(result.displayText).toBe('hello world!') + }) + + test('!! at start is bash mode with ! prefix command', () => { + const result = parseHistoryItem('!!') + expect(result.mode).toBe('bash') + expect(result.displayText).toBe('!') + }) + + test('! with space is bash mode', () => { + const result = parseHistoryItem('! echo hello') + expect(result.mode).toBe('bash') + expect(result.displayText).toBe(' echo hello') + }) + }) +}) + +interface MockHistoryState { + messageHistory: string[] + historyIndex: number + currentDraft: string + currentDraftMode: InputMode + isNavigating: boolean + inputValue: string + inputMode: InputMode +} + +function createMockHistoryNavigator(initialHistory: string[] = []) { + const state: MockHistoryState = { + messageHistory: initialHistory, + historyIndex: -1, + currentDraft: '', + currentDraftMode: 'default', + isNavigating: false, + inputValue: '', + inputMode: 'default', + } + + const setInputValue = (value: { text: string; cursorPosition: number; lastEditDueToNav: boolean }) => { + state.inputValue = value.text + } + + const setInputMode = (mode: InputMode) => { + state.inputMode = mode + } + + const resetHistoryNavigation = () => { + state.historyIndex = -1 + state.currentDraft = '' + state.currentDraftMode = 'default' + } + + const navigateUp = () => { + const history = state.messageHistory + if (history.length === 0) return + + state.isNavigating = true + + if (state.historyIndex === -1) { + state.currentDraft = state.inputMode === 'bash' ? '!' + state.inputValue : state.inputValue + state.currentDraftMode = state.inputMode + state.historyIndex = history.length - 1 + } else if (state.historyIndex > 0) { + state.historyIndex -= 1 + } + + const historyMessage = history[state.historyIndex] + if (historyMessage === undefined) { + state.isNavigating = false + return + } + + const { mode, displayText } = parseHistoryItem(historyMessage) + + if (mode !== state.inputMode) { + setInputMode(mode) + } + + setInputValue({ + text: displayText, + cursorPosition: displayText.length, + lastEditDueToNav: true, + }) + + state.isNavigating = false + } + + const navigateDown = () => { + const history = state.messageHistory + if (history.length === 0) return + if (state.historyIndex === -1) return + + state.isNavigating = true + + if (state.historyIndex < history.length - 1) { + state.historyIndex += 1 + const historyMessage = history[state.historyIndex] + if (historyMessage === undefined) { + state.isNavigating = false + return + } + + const { mode, displayText } = parseHistoryItem(historyMessage) + + // Switch mode if needed + if (mode !== state.inputMode) { + setInputMode(mode) + } + + setInputValue({ + text: displayText, + cursorPosition: displayText.length, + lastEditDueToNav: true, + }) + } else { + state.historyIndex = -1 + const draft = state.currentDraft + const draftMode = state.currentDraftMode + + if (draftMode !== state.inputMode) { + setInputMode(draftMode) + } + + const textToShow = + draftMode === 'bash' && draft.startsWith('!') ? draft.slice(1) : draft + + setInputValue({ + text: textToShow, + cursorPosition: textToShow.length, + lastEditDueToNav: true, + }) + } + + state.isNavigating = false + } + + const simulateInputModeChange = (newMode: InputMode) => { + const oldMode = state.inputMode + state.inputMode = newMode + + if (!state.isNavigating && oldMode !== newMode) { + resetHistoryNavigation() + } + } + + return { + state, + setInputValue, + setInputMode, + resetHistoryNavigation, + navigateUp, + navigateDown, + simulateInputModeChange, + } +} + +describe('use-input-history - cross-mode navigation', () => { + describe('navigating from default mode to bash entries', () => { + test('navigating up to a bash entry switches to bash mode', () => { + const nav = createMockHistoryNavigator(['hello world', '!ls -la']) + + expect(nav.state.inputMode).toBe('default') + nav.navigateUp() + + expect(nav.state.inputMode).toBe('bash') + expect(nav.state.inputValue).toBe('ls -la') + expect(nav.state.historyIndex).toBe(1) + }) + + test('navigating up through mixed history changes modes appropriately', () => { + const nav = createMockHistoryNavigator([ + 'default entry 1', + '!bash command 1', + 'default entry 2', + '!bash command 2', + ]) + + nav.navigateUp() + expect(nav.state.inputMode).toBe('bash') + expect(nav.state.inputValue).toBe('bash command 2') + + nav.navigateUp() + expect(nav.state.inputMode).toBe('default') + expect(nav.state.inputValue).toBe('default entry 2') + + nav.navigateUp() + expect(nav.state.inputMode).toBe('bash') + expect(nav.state.inputValue).toBe('bash command 1') + + nav.navigateUp() + expect(nav.state.inputMode).toBe('default') + expect(nav.state.inputValue).toBe('default entry 1') + }) + }) + + describe('navigating from bash mode to default entries', () => { + test('navigating up from bash mode to a default entry switches to default mode', () => { + const nav = createMockHistoryNavigator(['hello world', '!ls -la']) + + nav.state.inputMode = 'bash' + nav.state.inputValue = 'pwd' + + nav.navigateUp() + expect(nav.state.inputMode as string).toBe('bash') + expect(nav.state.inputValue).toBe('ls -la') + + nav.navigateUp() + expect(nav.state.inputMode as string).toBe('default') + expect(nav.state.inputValue).toBe('hello world') + }) + }) + + describe('returning to draft restores original mode', () => { + test('navigating back to draft restores default mode', () => { + const nav = createMockHistoryNavigator(['!bash command']) + + nav.state.inputMode = 'default' + nav.state.inputValue = 'my draft text' + + nav.navigateUp() + expect(nav.state.inputMode as string).toBe('bash') + expect(nav.state.inputValue).toBe('bash command') + + nav.navigateDown() + expect(nav.state.inputMode as string).toBe('default') + expect(nav.state.inputValue).toBe('my draft text') + }) + + test('navigating back to draft restores bash mode', () => { + const nav = createMockHistoryNavigator(['default entry']) + + nav.state.inputMode = 'bash' + nav.state.inputValue = 'my bash draft' + + nav.navigateUp() + expect(nav.state.inputMode as string).toBe('default') + expect(nav.state.inputValue).toBe('default entry') + + nav.navigateDown() + expect(nav.state.inputMode as string).toBe('bash') + expect(nav.state.inputValue).toBe('my bash draft') + }) + + test('draft is preserved with ! prefix for bash mode', () => { + const nav = createMockHistoryNavigator(['default entry']) + + nav.state.inputMode = 'bash' + nav.state.inputValue = 'git status' + + nav.navigateUp() + expect(nav.state.currentDraft).toBe('!git status') + expect(nav.state.currentDraftMode).toBe('bash') + + nav.navigateDown() + expect(nav.state.inputValue).toBe('git status') + expect(nav.state.inputMode as string).toBe('bash') + }) + }) + + describe('navigation through entire history', () => { + test('can navigate up through all entries and back down to draft', () => { + const nav = createMockHistoryNavigator([ + 'first', + '!second', + 'third', + ]) + + nav.state.inputValue = 'draft' + nav.state.inputMode = 'default' + + // Navigate up through all entries + nav.navigateUp() + expect(nav.state.inputValue).toBe('third') + expect(nav.state.inputMode).toBe('default') + + nav.navigateUp() + expect(nav.state.inputValue).toBe('second') + expect(nav.state.inputMode as string).toBe('bash') + + nav.navigateUp() + expect(nav.state.inputValue).toBe('first') + expect(nav.state.inputMode).toBe('default') + + // Should stay at oldest entry + nav.navigateUp() + expect(nav.state.inputValue).toBe('first') + expect(nav.state.historyIndex).toBe(0) + + // Navigate back down + nav.navigateDown() + expect(nav.state.inputValue).toBe('second') + expect(nav.state.inputMode as string).toBe('bash') + + nav.navigateDown() + expect(nav.state.inputValue).toBe('third') + expect(nav.state.inputMode).toBe('default') + + nav.navigateDown() + expect(nav.state.inputValue).toBe('draft') + expect(nav.state.inputMode).toBe('default') + + // Should stay at draft + nav.navigateDown() + expect(nav.state.inputValue).toBe('draft') + expect(nav.state.historyIndex).toBe(-1) + }) + }) +}) + +describe('use-input-history - isNavigating flag behavior', () => { + describe('navigation sets and clears isNavigating flag', () => { + test('navigateUp sets isNavigating during mode change', () => { + const nav = createMockHistoryNavigator(['!bash command']) + + nav.state.inputMode = 'default' + expect(nav.state.isNavigating).toBe(false) + + nav.navigateUp() + expect(nav.state.isNavigating).toBe(false) + expect(nav.state.inputMode as string).toBe('bash') + }) + + test('navigateDown sets isNavigating during mode change', () => { + const nav = createMockHistoryNavigator(['default entry', '!bash command']) + + nav.navigateUp() + expect(nav.state.inputMode).toBe('bash') + + nav.navigateDown() + expect(nav.state.inputMode).toBe('default') + expect(nav.state.isNavigating).toBe(false) + }) + }) + + describe('useEffect reset is prevented during navigation', () => { + test('manual mode change resets history navigation', () => { + const nav = createMockHistoryNavigator(['entry 1', 'entry 2']) + + nav.navigateUp() + expect(nav.state.historyIndex).toBe(1) + expect(nav.state.inputValue).toBe('entry 2') + + nav.simulateInputModeChange('bash') + expect(nav.state.historyIndex).toBe(-1) + expect(nav.state.currentDraft).toBe('') + expect(nav.state.currentDraftMode).toBe('default') + }) + + test('mode change during navigation does NOT reset history', () => { + const nav = createMockHistoryNavigator(['default entry', '!bash command']) + + nav.state.isNavigating = true + nav.simulateInputModeChange('bash') + nav.state.historyIndex = 1 + nav.simulateInputModeChange('default') + nav.state.isNavigating = false + }) + + test('exiting feedback mode explicitly resets history navigation', () => { + const nav = createMockHistoryNavigator(['entry 1', 'entry 2']) + + nav.navigateUp() + expect(nav.state.historyIndex).toBe(1) + + nav.resetHistoryNavigation() + + expect(nav.state.historyIndex).toBe(-1) + expect(nav.state.currentDraft).toBe('') + expect(nav.state.currentDraftMode).toBe('default') + }) + }) +}) + +describe('use-input-history - resetHistoryNavigation', () => { + test('resets historyIndex to -1', () => { + const nav = createMockHistoryNavigator(['entry']) + + nav.navigateUp() + expect(nav.state.historyIndex).toBe(0) + + nav.resetHistoryNavigation() + expect(nav.state.historyIndex).toBe(-1) + }) + + test('resets currentDraft to empty string', () => { + const nav = createMockHistoryNavigator(['entry']) + nav.state.inputValue = 'my draft' + + nav.navigateUp() + expect(nav.state.currentDraft).toBe('my draft') + + nav.resetHistoryNavigation() + expect(nav.state.currentDraft).toBe('') + }) + + test('resets currentDraftMode to default', () => { + const nav = createMockHistoryNavigator(['entry']) + nav.state.inputMode = 'bash' + nav.state.inputValue = 'my bash draft' + + nav.navigateUp() + expect(nav.state.currentDraftMode).toBe('bash') + + nav.resetHistoryNavigation() + expect(nav.state.currentDraftMode).toBe('default') + }) + + test('can be called multiple times safely', () => { + const nav = createMockHistoryNavigator(['entry']) + + nav.resetHistoryNavigation() + nav.resetHistoryNavigation() + nav.resetHistoryNavigation() + + expect(nav.state.historyIndex).toBe(-1) + expect(nav.state.currentDraft).toBe('') + expect(nav.state.currentDraftMode).toBe('default') + }) + + test('allows navigation after reset', () => { + const nav = createMockHistoryNavigator(['entry 1', 'entry 2']) + + nav.navigateUp() + expect(nav.state.inputValue).toBe('entry 2') + + nav.resetHistoryNavigation() + + nav.navigateUp() + expect(nav.state.inputValue).toBe('entry 2') + expect(nav.state.historyIndex).toBe(1) + }) +}) + +describe('use-input-history - edge cases', () => { + describe('empty history', () => { + test('navigateUp does nothing with empty history', () => { + const nav = createMockHistoryNavigator([]) + + nav.state.inputValue = 'current text' + nav.navigateUp() + + expect(nav.state.inputValue).toBe('current text') + expect(nav.state.historyIndex).toBe(-1) + }) + + test('navigateDown does nothing with empty history', () => { + const nav = createMockHistoryNavigator([]) + + nav.state.inputValue = 'current text' + nav.navigateDown() + + expect(nav.state.inputValue).toBe('current text') + expect(nav.state.historyIndex).toBe(-1) + }) + }) + + describe('single entry history', () => { + test('can navigate up and down with single entry', () => { + const nav = createMockHistoryNavigator(['only entry']) + nav.state.inputValue = 'draft' + + nav.navigateUp() + expect(nav.state.inputValue).toBe('only entry') + expect(nav.state.historyIndex).toBe(0) + + nav.navigateUp() + expect(nav.state.inputValue).toBe('only entry') + expect(nav.state.historyIndex).toBe(0) + + nav.navigateDown() + expect(nav.state.inputValue).toBe('draft') + expect(nav.state.historyIndex).toBe(-1) + }) + }) + + describe('navigateDown without prior navigateUp', () => { + test('navigateDown at draft does nothing', () => { + const nav = createMockHistoryNavigator(['entry 1', 'entry 2']) + + nav.state.inputValue = 'draft' + nav.navigateDown() + + expect(nav.state.inputValue).toBe('draft') + expect(nav.state.historyIndex).toBe(-1) + }) + }) + + describe('rapid navigation', () => { + test('rapid up/down navigation works correctly', () => { + const nav = createMockHistoryNavigator(['a', 'b', 'c']) + nav.state.inputValue = 'draft' + + nav.navigateUp() // c + nav.navigateUp() // b + nav.navigateDown() // c + nav.navigateUp() // b + nav.navigateUp() // a + nav.navigateDown() // b + nav.navigateDown() // c + nav.navigateDown() // draft + + expect(nav.state.inputValue).toBe('draft') + expect(nav.state.historyIndex).toBe(-1) + }) + }) + + describe('special characters in history', () => { + test('handles entries with special characters', () => { + const nav = createMockHistoryNavigator([ + 'entry with @mention', + '!command with "quotes"', + 'entry with \nnewline', + ]) + + nav.navigateUp() + expect(nav.state.inputValue).toBe('entry with \nnewline') + + nav.navigateUp() + expect(nav.state.inputValue).toBe('command with "quotes"') + expect(nav.state.inputMode).toBe('bash') + + nav.navigateUp() + expect(nav.state.inputValue).toBe('entry with @mention') + expect(nav.state.inputMode).toBe('default') + }) + }) + + describe('unicode in history', () => { + test('handles unicode characters in entries', () => { + const nav = createMockHistoryNavigator([ + '日本語のテキスト', + '!echo 🚀', + 'émojis 👍 and açcénts', + ]) + + nav.navigateUp() + expect(nav.state.inputValue).toBe('émojis 👍 and açcénts') + + nav.navigateUp() + expect(nav.state.inputValue).toBe('echo 🚀') + expect(nav.state.inputMode).toBe('bash') + + nav.navigateUp() + expect(nav.state.inputValue).toBe('日本語のテキスト') + expect(nav.state.inputMode).toBe('default') + }) + }) + + describe('very long entries', () => { + test('handles very long history entries', () => { + const longText = 'a'.repeat(10000) + const longBashCommand = '!' + 'b'.repeat(10000) + + const nav = createMockHistoryNavigator([longText, longBashCommand]) + + nav.navigateUp() + expect(nav.state.inputValue).toBe('b'.repeat(10000)) + expect(nav.state.inputMode).toBe('bash') + + nav.navigateUp() + expect(nav.state.inputValue).toBe(longText) + expect(nav.state.inputMode).toBe('default') + }) + }) +}) + +describe('use-input-history - mode preservation', () => { + test('preserves draft mode when navigating and returning', () => { + const nav = createMockHistoryNavigator([ + 'default 1', + '!bash 1', + 'default 2', + '!bash 2', + ]) + + nav.state.inputMode = 'default' + nav.state.inputValue = 'my default draft' + + nav.navigateUp() + nav.navigateUp() + nav.navigateUp() + nav.navigateUp() + + nav.navigateDown() + nav.navigateDown() + nav.navigateDown() + nav.navigateDown() + expect(nav.state.inputMode).toBe('default') + expect(nav.state.inputValue).toBe('my default draft') + }) + + test('preserves bash mode draft when navigating through default entries', () => { + const nav = createMockHistoryNavigator(['default 1', 'default 2', 'default 3']) + + nav.state.inputMode = 'bash' + nav.state.inputValue = 'npm test' + + nav.navigateUp() + expect(nav.state.inputMode as string).toBe('default') + + nav.navigateUp() + expect(nav.state.inputMode as string).toBe('default') + + nav.navigateUp() + expect(nav.state.inputMode as string).toBe('default') + + nav.navigateDown() + nav.navigateDown() + nav.navigateDown() + expect(nav.state.inputMode).toBe('bash') + expect(nav.state.inputValue).toBe('npm test') + }) +}) diff --git a/cli/src/hooks/__tests__/use-path-tab-completion.test.ts b/cli/src/hooks/__tests__/use-path-tab-completion.test.ts index d44620f783..9faa580a1e 100644 --- a/cli/src/hooks/__tests__/use-path-tab-completion.test.ts +++ b/cli/src/hooks/__tests__/use-path-tab-completion.test.ts @@ -1,8 +1,9 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' import { mkdirSync, mkdtempSync, rmSync } from 'fs' import os from 'os' import path from 'path' +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' + /** * Tests for usePathTabCompletion hook logic. * diff --git a/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts b/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts index dca6b7efb7..68cbd99214 100644 --- a/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts +++ b/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts @@ -1,98 +1,27 @@ import { describe, test, expect } from 'bun:test' -// Helper function extracted from use-suggestion-engine.ts for testing -const isInsideQuotes = (text: string, position: number): boolean => { - let inSingleQuote = false - let inDoubleQuote = false - let inBacktick = false - let escaped = false - - for (let i = 0; i < position; i++) { - const char = text[i] - - if (escaped) { - escaped = false - continue - } - - if (char === '\\') { - escaped = true - continue - } - - if (char === "'" && !inDoubleQuote && !inBacktick) { - inSingleQuote = !inSingleQuote - } else if (char === '"' && !inSingleQuote && !inBacktick) { - inDoubleQuote = !inDoubleQuote - } else if (char === '`' && !inSingleQuote && !inDoubleQuote) { - inBacktick = !inBacktick - } - } - - return inSingleQuote || inDoubleQuote || inBacktick -} - -const parseAtInLine = ( - line: string, -): { active: boolean; query: string; atIndex: number } => { - const atIndex = line.lastIndexOf('@') - if (atIndex === -1) { - return { active: false, query: '', atIndex: -1 } - } - - // Check if @ is inside quotes - if (isInsideQuotes(line, atIndex)) { - return { active: false, query: '', atIndex: -1 } - } - - const beforeChar = atIndex > 0 ? line[atIndex - 1] : '' - - // Don't trigger on escaped @: \@ - if (beforeChar === '\\') { - return { active: false, query: '', atIndex: -1 } - } - - // Don't trigger on email-like patterns or URLs - if (beforeChar && /[a-zA-Z0-9.:]/.test(beforeChar)) { - return { active: false, query: '', atIndex: -1 } - } - - // Require whitespace or start of line before @ - if (beforeChar && !/\s/.test(beforeChar)) { - return { active: false, query: '', atIndex: -1 } - } - - const afterAt = line.slice(atIndex + 1) - const firstSpaceIndex = afterAt.search(/\s/) - const query = - firstSpaceIndex === -1 ? afterAt : afterAt.slice(0, firstSpaceIndex) - - if (firstSpaceIndex !== -1) { - return { active: false, query: '', atIndex: -1 } - } - - return { active: true, query, atIndex } -} +import { isInsideStringDelimiters, parseAtInLine } from '../use-suggestion-engine' describe('@ mention edge cases - quote detection', () => { - test('isInsideQuotes detects position inside double quotes', () => { - expect(isInsideQuotes('"hello @world"', 7)).toBe(true) + test('isInsideStringDelimiters detects position inside double quotes', () => { + expect(isInsideStringDelimiters('"hello @world"', 7)).toBe(true) }) - test('isInsideQuotes detects position inside single quotes', () => { - expect(isInsideQuotes("'hello @world'", 7)).toBe(true) + test('isInsideStringDelimiters does NOT detect position inside single quotes (apostrophes)', () => { + // Single quotes are ignored - they're commonly used as apostrophes + expect(isInsideStringDelimiters("'hello @world'", 7)).toBe(false) }) - test('isInsideQuotes detects position inside backticks', () => { - expect(isInsideQuotes('`hello @world`', 7)).toBe(true) + test('isInsideStringDelimiters detects position inside backticks', () => { + expect(isInsideStringDelimiters('`hello @world`', 7)).toBe(true) }) - test('isInsideQuotes returns false for position outside quotes', () => { - expect(isInsideQuotes('"hello" @world', 8)).toBe(false) + test('isInsideStringDelimiters returns false for position outside quotes', () => { + expect(isInsideStringDelimiters('"hello" @world', 8)).toBe(false) }) - test('isInsideQuotes handles escaped quotes', () => { - expect(isInsideQuotes('"hello \\" @world"', 11)).toBe(true) + test('isInsideStringDelimiters handles escaped quotes', () => { + expect(isInsideStringDelimiters('"hello \\" @world"', 11)).toBe(true) }) }) @@ -114,7 +43,8 @@ describe('parseAtInLine - @ mention trigger logic', () => { expect(result.active).toBe(false) }) - test('does NOT trigger for @ inside single quotes', () => { + test('does NOT trigger for @ immediately after single quote (whitespace still required)', () => { + // Single quotes don't create quoted regions, but whitespace before @ is still required const result = parseAtInLine("'@agent'") expect(result.active).toBe(false) }) @@ -175,44 +105,24 @@ describe('parseAtInLine - @ mention trigger logic', () => { describe('parseAtInLine - comprehensive edge cases', () => { // Email variations - test('does NOT trigger for email with subdomain', () => { - const result = parseAtInLine('user@mail.example.com') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for email with numbers', () => { - const result = parseAtInLine('user123@example.com') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for email with underscores', () => { - const result = parseAtInLine('user_name@example.com') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for email with hyphens', () => { - const result = parseAtInLine('user-name@example.com') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for email with dots in username', () => { - const result = parseAtInLine('first.last@example.com') + test.each([ + ['user@mail.example.com', 'email with subdomain'], + ['user123@example.com', 'email with numbers'], + ['user_name@example.com', 'email with underscores'], + ['user-name@example.com', 'email with hyphens'], + ['first.last@example.com', 'email with dots in username'], + ])('does NOT trigger for %s (%s)', (input) => { + const result = parseAtInLine(input) expect(result.active).toBe(false) }) // URL variations - test('does NOT trigger for http URL', () => { - const result = parseAtInLine('http://example.com/@user') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for https URL', () => { - const result = parseAtInLine('https://example.com/@user') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for URL with port', () => { - const result = parseAtInLine('http://localhost:3000/@user') + test.each([ + ['http://example.com/@user', 'http URL'], + ['https://example.com/@user', 'https URL'], + ['http://localhost:3000/@user', 'URL with port'], + ])('does NOT trigger for %s (%s)', (input) => { + const result = parseAtInLine(input) expect(result.active).toBe(false) }) @@ -283,20 +193,12 @@ describe('parseAtInLine - comprehensive edge cases', () => { }) // Whitespace variations - test('triggers with tab before @', () => { - const result = parseAtInLine('\t@agent') - expect(result.active).toBe(true) - expect(result.query).toBe('agent') - }) - - test('triggers with newline before @ (in same line context)', () => { - const result = parseAtInLine(' @agent') - expect(result.active).toBe(true) - expect(result.query).toBe('agent') - }) - - test('triggers with multiple spaces before @', () => { - const result = parseAtInLine('text @agent') + test.each([ + ['\t@agent', 'tab before @'], + [' @agent', 'space before @'], + ['text @agent', 'multiple spaces before @'], + ])('triggers with %s (%s)', (input) => { + const result = parseAtInLine(input) expect(result.active).toBe(true) expect(result.query).toBe('agent') }) @@ -320,13 +222,11 @@ describe('parseAtInLine - comprehensive edge cases', () => { }) // Code-like contexts (where @ might appear) - test('does NOT trigger for decorator-like syntax', () => { - const result = parseAtInLine('something.@decorator') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for array access', () => { - const result = parseAtInLine('array.@index') + test.each([ + ['something.@decorator', 'decorator-like syntax'], + ['array.@index', 'array access'], + ])('does NOT trigger for %s (%s)', (input) => { + const result = parseAtInLine(input) expect(result.active).toBe(false) }) @@ -360,9 +260,11 @@ describe('parseAtInLine - comprehensive edge cases', () => { expect(result.active).toBe(false) }) - test('does NOT trigger when inside unclosed single quote', () => { + test('DOES trigger when inside unclosed single quote (apostrophes dont suppress)', () => { + // Single quotes are treated as apostrophes, not string delimiters const result = parseAtInLine("'unclosed @mention") - expect(result.active).toBe(false) + expect(result.active).toBe(true) + expect(result.query).toBe('mention') }) test('does NOT trigger when inside unclosed backtick', () => { @@ -370,3 +272,90 @@ describe('parseAtInLine - comprehensive edge cases', () => { expect(result.active).toBe(false) }) }) + +describe('single quote handling - apostrophes should NOT suppress @ menu', () => { + // Common contractions with apostrophes - use test.each for repetitive cases + const contractions = [ + ["don't", 'agent'], + ["it's", 'agent'], + ["I'm", 'agent'], + ["can't", 'agent'], + ["won't", 'agent'], + ["you're", 'agent'], + ["they're", 'agent'], + ["doesn't", 'agent'], + ] as const + + test.each(contractions)( + 'triggers @ after contraction "%s"', + (contraction, expectedQuery) => { + const result = parseAtInLine(`I ${contraction} @${expectedQuery}`) + expect(result.active).toBe(true) + expect(result.query).toBe(expectedQuery) + }, + ) + + // Possessives with apostrophes + const possessives = [ + ["user's", 'mention'], + ["file's", 'content'], + ] as const + + test.each(possessives)( + 'triggers @ after possessive "%s"', + (possessive, expectedQuery) => { + const result = parseAtInLine(`${possessive} @${expectedQuery}`) + expect(result.active).toBe(true) + expect(result.query).toBe(expectedQuery) + }, + ) + + // Multiple apostrophes in sentence + test('triggers @ with multiple apostrophes in sentence', () => { + const result = parseAtInLine("I don't think it's working @agent") + expect(result.active).toBe(true) + expect(result.query).toBe('agent') + }) + + // Single quotes that look like string delimiters + test('triggers @ after space inside single-quoted-looking string', () => { + // The @ triggers because there's a space before it, not because of single quotes + const result = parseAtInLine("'hello @world'") + expect(result.active).toBe(true) + // Query includes the trailing quote since it's not a delimiter + expect(result.query).toBe("world'") + }) + + test('does NOT trigger @ at start of single-quoted-looking string (whitespace required)', () => { + // Single quotes don't create quoted regions, but whitespace before @ is still required + const result = parseAtInLine("'@agent'") + expect(result.active).toBe(false) + }) + + // Mixed quotes - double quotes still suppress + test('does NOT trigger when @ is inside double quotes even with apostrophes', () => { + const result = parseAtInLine('"I don\'t @agent"') + expect(result.active).toBe(false) + }) + + test('does NOT trigger when @ is inside backticks even with apostrophes', () => { + const result = parseAtInLine("`I don't @agent`") + expect(result.active).toBe(false) + }) + + // Real-world usage examples + const realWorldExamples = [ + ["Why doesn't this work? @agent", 'agent'], + ["That's what @file-picker", 'file-picker'], + ["What's @commander", 'commander'], + ] as const + + test.each(realWorldExamples)( + 'triggers in natural sentence: "%s"', + (sentence, expectedQuery) => { + const result = parseAtInLine(sentence) + expect(result.active).toBe(true) + expect(result.query).toBe(expectedQuery) + }, + ) +}) diff --git a/cli/src/hooks/__tests__/use-timeout.test.ts b/cli/src/hooks/__tests__/use-timeout.test.ts index dbddd5869a..8367b6b42a 100644 --- a/cli/src/hooks/__tests__/use-timeout.test.ts +++ b/cli/src/hooks/__tests__/use-timeout.test.ts @@ -1,5 +1,5 @@ -import React from 'react' import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import React from 'react' import { useTimeout } from '../use-timeout' @@ -10,20 +10,31 @@ import { useTimeout } from '../use-timeout' */ describe('useTimeout', () => { - const reactInternals = (React as any) - .__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE + // Access React internals for testing hooks outside a renderer + type ReactInternals = { + H: { + useRef: (value: T) => { current: T } + useCallback: (callback: T) => T + useEffect: (effect: () => void) => void + } + } + const reactInternals = ( + React as unknown as { + __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: ReactInternals + } + ).__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE let originalSetTimeout: typeof setTimeout let originalClearTimeout: typeof clearTimeout - let timers: { id: number; ms: number; fn: Function; cleared: boolean }[] + let timers: { id: number; ms: number; fn: () => void; cleared: boolean }[] let nextId: number - let originalDispatcher: any + let originalDispatcher: ReactInternals['H'] | undefined beforeEach(() => { originalDispatcher = reactInternals.H reactInternals.H = { - useRef: (value: any) => ({ current: value }), - useCallback: (callback: any) => callback, - useEffect: (effect: any) => { + useRef: (value: T) => ({ current: value }), + useCallback: (callback: T) => callback, + useEffect: (effect: () => void) => { effect() }, } @@ -34,21 +45,21 @@ describe('useTimeout', () => { originalClearTimeout = globalThis.clearTimeout // Mock setTimeout to track all scheduled timers - globalThis.setTimeout = ((fn: Function, ms?: number) => { + globalThis.setTimeout = ((fn: () => void, ms?: number) => { const id = nextId++ timers.push({ id, ms: Number(ms ?? 0), fn, cleared: false }) - return id as any - }) as any + return id as unknown as ReturnType + }) as typeof setTimeout // Mock clearTimeout to mark timers as cleared - globalThis.clearTimeout = ((id?: any) => { - const timer = timers.find((t) => t.id === id) + globalThis.clearTimeout = ((id?: ReturnType) => { + const timer = timers.find((t) => t.id === (id as unknown as number)) if (timer) timer.cleared = true - }) as any + }) as typeof clearTimeout }) afterEach(() => { - reactInternals.H = originalDispatcher + reactInternals.H = originalDispatcher! globalThis.setTimeout = originalSetTimeout globalThis.clearTimeout = originalClearTimeout }) diff --git a/cli/src/hooks/__tests__/use-usage-query.test.ts b/cli/src/hooks/__tests__/use-usage-query.test.ts index 7ade593411..dffe8403a5 100644 --- a/cli/src/hooks/__tests__/use-usage-query.test.ts +++ b/cli/src/hooks/__tests__/use-usage-query.test.ts @@ -1,19 +1,6 @@ -import { - describe, - test, - expect, - beforeEach, - afterEach, - mock, -} from 'bun:test' - -import type { ClientEnv } from '@codebuff/common/types/contracts/env' +import { createMockLogger } from '@codebuff/common/testing/mocks/logger' +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' -import { - fetchUsageData, - usageQueryKeys, - useRefreshUsage, -} from '../use-usage-query' import { resetActivityQueryCache, getActivityQueryData, @@ -21,6 +8,13 @@ import { invalidateActivityQuery, removeActivityQuery, } from '../use-activity-query' +import { + fetchUsageData, + usageQueryKeys, +} from '../use-usage-query' + +import type { ClientEnv } from '@codebuff/common/types/contracts/env' + beforeEach(() => { resetActivityQueryCache() @@ -145,15 +139,10 @@ describe('fetchUsageData', () => { globalThis.fetch = mock( async () => new Response('Error', { status: 500 }), ) as unknown as typeof fetch - const mockLogger = { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), - } + const mockLogger = createMockLogger() await expect( - fetchUsageData({ authToken: 'test-token', logger: mockLogger as any }), + fetchUsageData({ authToken: 'test-token', logger: mockLogger }), ).rejects.toThrow('Failed to fetch usage: 500') }) @@ -161,15 +150,10 @@ describe('fetchUsageData', () => { globalThis.fetch = mock( async () => new Response('Unauthorized', { status: 401 }), ) as unknown as typeof fetch - const mockLogger = { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), - } + const mockLogger = createMockLogger() await expect( - fetchUsageData({ authToken: 'invalid-token', logger: mockLogger as any }), + fetchUsageData({ authToken: 'invalid-token', logger: mockLogger }), ).rejects.toThrow('Failed to fetch usage: 401') }) @@ -177,15 +161,10 @@ describe('fetchUsageData', () => { globalThis.fetch = mock( async () => new Response('Payment Required', { status: 402 }), ) as unknown as typeof fetch - const mockLogger = { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), - } + const mockLogger = createMockLogger() await expect( - fetchUsageData({ authToken: 'test-token', logger: mockLogger as any }), + fetchUsageData({ authToken: 'test-token', logger: mockLogger }), ).rejects.toThrow('Failed to fetch usage: 402') }) @@ -254,20 +233,14 @@ describe('fetchUsageData', () => { globalThis.fetch = mock( async () => new Response('Server Error', { status: 503 }), ) as unknown as typeof fetch - - const errorMock = mock(() => {}) - const mockLogger = { - error: errorMock, - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), - } + + const mockLogger = createMockLogger() await expect( - fetchUsageData({ authToken: 'test-token', logger: mockLogger as any }), + fetchUsageData({ authToken: 'test-token', logger: mockLogger }), ).rejects.toThrow() - expect(errorMock).toHaveBeenCalledWith( + expect(mockLogger.error).toHaveBeenCalledWith( { status: 503 }, 'Failed to fetch usage data from API', ) @@ -299,7 +272,9 @@ describe('usageQueryKeys', () => { } setActivityQueryData(usageQueryKeys.current(), mockData) - expect(getActivityQueryData(usageQueryKeys.current())).toEqual(mockData) + expect( + getActivityQueryData(usageQueryKeys.current()), + ).toEqual(mockData) }) }) @@ -323,13 +298,17 @@ describe('useRefreshUsage behavior', () => { // Pre-populate cache setActivityQueryData(usageQueryKeys.current(), mockData) - expect(getActivityQueryData(usageQueryKeys.current())).toEqual(mockData) + expect( + getActivityQueryData(usageQueryKeys.current()), + ).toEqual(mockData) // Call the underlying invalidation function (what useRefreshUsage wraps) invalidateActivityQuery(usageQueryKeys.current()) // Data should still exist (invalidation doesn't remove data) - expect(getActivityQueryData(usageQueryKeys.current())).toEqual(mockData) + expect( + getActivityQueryData(usageQueryKeys.current()), + ).toEqual(mockData) }) test('invalidation marks data as stale for refetching', () => { @@ -344,7 +323,9 @@ describe('useRefreshUsage behavior', () => { invalidateActivityQuery(usageQueryKeys.current()) // Data is still accessible (stale but usable) - const cached = getActivityQueryData(usageQueryKeys.current()) + const cached = getActivityQueryData( + usageQueryKeys.current(), + ) expect(cached?.usage).toBe(200) expect(cached?.remainingBalance).toBe(300) }) @@ -365,7 +346,9 @@ describe('usage query cache behavior', () => { } setActivityQueryData(usageQueryKeys.current(), mockData) - expect(getActivityQueryData(usageQueryKeys.current())).toEqual(mockData) + expect( + getActivityQueryData(usageQueryKeys.current()), + ).toEqual(mockData) }) test('should update cache when new data is set', () => { @@ -384,10 +367,14 @@ describe('usage query cache behavior', () => { } setActivityQueryData(usageQueryKeys.current(), initialData) - expect(getActivityQueryData(usageQueryKeys.current())?.usage).toBe(100) + expect( + getActivityQueryData(usageQueryKeys.current())?.usage, + ).toBe(100) setActivityQueryData(usageQueryKeys.current(), updatedData) - expect(getActivityQueryData(usageQueryKeys.current())?.usage).toBe(150) + expect( + getActivityQueryData(usageQueryKeys.current())?.usage, + ).toBe(150) }) test('should preserve data after invalidation', () => { @@ -402,7 +389,9 @@ describe('usage query cache behavior', () => { invalidateActivityQuery(usageQueryKeys.current()) // Data should still be accessible - const cached = getActivityQueryData(usageQueryKeys.current()) + const cached = getActivityQueryData( + usageQueryKeys.current(), + ) expect(cached).toEqual(mockData) }) @@ -415,10 +404,14 @@ describe('usage query cache behavior', () => { } setActivityQueryData(usageQueryKeys.current(), mockData) - expect(getActivityQueryData(usageQueryKeys.current())).toBeDefined() + expect( + getActivityQueryData(usageQueryKeys.current()), + ).toBeDefined() removeActivityQuery(usageQueryKeys.current()) - expect(getActivityQueryData(usageQueryKeys.current())).toBeUndefined() + expect( + getActivityQueryData(usageQueryKeys.current()), + ).toBeUndefined() }) test('should handle balance breakdown with all credit types', () => { @@ -437,7 +430,9 @@ describe('usage query cache behavior', () => { } setActivityQueryData(usageQueryKeys.current(), mockData) - const cached = getActivityQueryData(usageQueryKeys.current()) + const cached = getActivityQueryData( + usageQueryKeys.current(), + ) expect(cached?.balanceBreakdown?.free).toBe(300) expect(cached?.balanceBreakdown?.paid).toBe(700) @@ -456,7 +451,9 @@ describe('usage query cache behavior', () => { } setActivityQueryData(usageQueryKeys.current(), mockData) - const cached = getActivityQueryData(usageQueryKeys.current()) + const cached = getActivityQueryData( + usageQueryKeys.current(), + ) expect(cached?.usage).toBe(0) expect(cached?.remainingBalance).toBe(0) @@ -472,10 +469,14 @@ describe('usage query cache behavior', () => { } setActivityQueryData(usageQueryKeys.current(), mockData) - expect(getActivityQueryData(usageQueryKeys.current())).toBeDefined() + expect( + getActivityQueryData(usageQueryKeys.current()), + ).toBeDefined() resetActivityQueryCache() - expect(getActivityQueryData(usageQueryKeys.current())).toBeUndefined() + expect( + getActivityQueryData(usageQueryKeys.current()), + ).toBeUndefined() }) test('multiple invalidations preserve data', () => { @@ -494,6 +495,8 @@ describe('usage query cache behavior', () => { invalidateActivityQuery(usageQueryKeys.current()) // Data should still be there - expect(getActivityQueryData(usageQueryKeys.current())).toEqual(mockData) + expect( + getActivityQueryData(usageQueryKeys.current()), + ).toEqual(mockData) }) }) diff --git a/cli/src/hooks/__tests__/use-user-details-query.test.ts b/cli/src/hooks/__tests__/use-user-details-query.test.ts index 3b7c057546..1dcdaae4e5 100644 --- a/cli/src/hooks/__tests__/use-user-details-query.test.ts +++ b/cli/src/hooks/__tests__/use-user-details-query.test.ts @@ -9,9 +9,9 @@ import { } from 'bun:test' import { createMockApiClient } from '../../__tests__/helpers/mock-api-client' +import * as CodebuffApiModule from '../../utils/codebuff-api' import { fetchUserDetails } from '../use-user-details-query' -import * as CodebuffApiModule from '../../utils/codebuff-api' import type { Logger } from '@codebuff/common/types/contracts/logger' describe('fetchUserDetails', () => { @@ -162,29 +162,6 @@ describe('fetchUserDetails', () => { expect(result).toEqual(mockUserDetails) }) - test('returns null referral_code when not set', async () => { - const mockUserDetails = { - referral_code: null, - } - - const meMock = mock(() => - Promise.resolve({ - ok: true, - status: 200, - data: mockUserDetails, - }), - ) - const apiClient = createMockApiClient({ me: meMock }) - - const result = await fetchUserDetails({ - authToken: 'valid-token', - fields: ['referral_code'] as const, - logger: mockLogger, - apiClient, - }) - - expect(result?.referral_code).toBe(null) - }) }) describe('environment validation', () => { @@ -202,7 +179,7 @@ describe('fetchUserDetails', () => { CodebuffApiModule, 'setApiClientAuthToken', ) - spyOn(CodebuffApiModule, 'getApiClient').mockReturnValue(apiClient as any) + spyOn(CodebuffApiModule, 'getApiClient').mockReturnValue(apiClient as ReturnType) await expect( fetchUserDetails({ diff --git a/cli/src/hooks/helpers/__tests__/send-message.test.ts b/cli/src/hooks/helpers/__tests__/send-message.test.ts index 32ac67ebca..e40659d825 100644 --- a/cli/src/hooks/helpers/__tests__/send-message.test.ts +++ b/cli/src/hooks/helpers/__tests__/send-message.test.ts @@ -28,13 +28,17 @@ ensureEnv() const { useChatStore } = await import('../../../state/chat-store') const { createStreamController } = await import('../../stream-state') -const { setupStreamingContext, handleRunError } = await import( - '../send-message' -) -const { createBatchedMessageUpdater } = await import( - '../../../utils/message-updater' -) +const { + setupStreamingContext, + handleRunCompletion, + handleRunError, + finalizeQueueState, + resetEarlyReturnState, +} = await import('../send-message') +const { createBatchedMessageUpdater } = + await import('../../../utils/message-updater') import { createPaymentRequiredError } from '@codebuff/sdk' +import type { RunState } from '@codebuff/sdk' const createMockTimerController = (): SendMessageTimerController & { startCalls: string[] @@ -71,7 +75,7 @@ const createBaseMessages = (): ChatMessage[] => [ describe('setupStreamingContext', () => { describe('abort flow', () => { - test('abort handler appends interruption notice and marks complete', () => { + test('abort handler appends interruption notice, marks complete, and releases chain lock', () => { let messages = createBaseMessages() const streamRefs = createStreamController() const timerController = createMockTimerController() @@ -101,6 +105,7 @@ describe('setupStreamingContext', () => { setIsRetrying: (value: boolean) => { isRetrying = value }, + setStreamingAgents: () => {}, }) // Trigger abort @@ -109,14 +114,12 @@ describe('setupStreamingContext', () => { // Verify wasAbortedByUser is set expect(streamRefs.state.wasAbortedByUser).toBe(true) - // Verify stream status reset + // Verify stream status reset for UI feedback expect(streamStatus).toBe('idle') - // Verify queue processing enabled (no isQueuePausedRef) - expect(canProcessQueue).toBe(true) - - // Verify chain in progress reset + // Chain lock is released immediately so new messages can be sent directly expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) // Verify retrying reset expect(isRetrying).toBe(false) @@ -134,19 +137,21 @@ describe('setupStreamingContext', () => { // The interruption notice should be added to blocks const lastBlock = aiMessage!.blocks?.[aiMessage!.blocks.length - 1] expect(lastBlock?.type).toBe('text') - expect((lastBlock as any)?.content).toContain('[response interrupted]') + const textBlock = lastBlock as { type: 'text'; content: string } + expect(textBlock?.content).toContain('[response interrupted]') // Verify message marked complete expect(aiMessage!.isComplete).toBe(true) }) - test('abort respects isQueuePausedRef when set', () => { + test('abort sets canProcessQueue based on queue pause state', () => { let messages = createBaseMessages() const streamRefs = createStreamController() const timerController = createMockTimerController() const abortControllerRef = { current: null as AbortController | null } const isQueuePausedRef = { current: true } let canProcessQueue = false + let canProcessQueueCallCount = 0 const { abortController } = setupStreamingContext({ aiMessageId: 'ai-1', @@ -159,19 +164,113 @@ describe('setupStreamingContext', () => { setStreamStatus: () => {}, setCanProcessQueue: (can: boolean) => { canProcessQueue = can + canProcessQueueCallCount++ }, isQueuePausedRef, updateChainInProgress: () => {}, setIsRetrying: () => {}, + setStreamingAgents: () => {}, }) // Trigger abort abortController.abort() - // When queue is paused, canProcessQueue should be false + // Abort handler sets canProcessQueue respecting queue pause state + expect(canProcessQueueCallCount).toBe(1) + // Queue was paused, so canProcessQueue stays false expect(canProcessQueue).toBe(false) }) + test('abort resets isProcessingQueueRef', () => { + let messages = createBaseMessages() + const streamRefs = createStreamController() + const timerController = createMockTimerController() + const abortControllerRef = { current: null as AbortController | null } + const isProcessingQueueRef = { current: true } + + const { abortController } = setupStreamingContext({ + aiMessageId: 'ai-1', + timerController, + setMessages: (fn: any) => { + messages = fn(messages) + }, + streamRefs, + abortControllerRef, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + isProcessingQueueRef, + updateChainInProgress: () => {}, + setIsRetrying: () => {}, + setStreamingAgents: () => {}, + }) + + // Verify ref starts as true + expect(isProcessingQueueRef.current).toBe(true) + + // Trigger abort + abortController.abort() + + // isProcessingQueueRef is reset by abort handler so new messages can be sent + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('abort releases chain lock and processing state, respects queue pause', () => { + let messages = createBaseMessages() + const streamRefs = createStreamController() + const timerController = createMockTimerController() + const abortControllerRef = { current: null as AbortController | null } + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: true } + let streamStatus = 'streaming' as StreamStatus + let canProcessQueue = true + let chainInProgress = true + let isRetrying = true + + const { abortController } = setupStreamingContext({ + aiMessageId: 'ai-1', + timerController, + setMessages: (fn: any) => { + messages = fn(messages) + }, + streamRefs, + abortControllerRef, + setStreamStatus: (status) => { + streamStatus = status + }, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isQueuePausedRef, + isProcessingQueueRef, + updateChainInProgress: (value) => { + chainInProgress = value + }, + setIsRetrying: (value) => { + isRetrying = value + }, + setStreamingAgents: () => {}, + }) + + // Sanity check initial state + expect(isProcessingQueueRef.current).toBe(true) + expect(isQueuePausedRef.current).toBe(true) + expect(streamStatus).toBe('streaming') + expect(canProcessQueue).toBe(true) + expect(chainInProgress).toBe(true) + expect(isRetrying).toBe(true) + + // Trigger abort + abortController.abort() + + // After abort, chain lock and processing lock are released immediately + // so new messages can be sent directly instead of being queued. + expect(isProcessingQueueRef.current).toBe(false) + expect(canProcessQueue).toBe(false) // Respects isQueuePausedRef (true) + expect(chainInProgress).toBe(false) // Released immediately + expect(isRetrying).toBe(false) + expect(streamStatus).toBe('idle') + }) + test('abort handler stores abortController in ref', () => { let messages = createBaseMessages() const streamRefs = createStreamController() @@ -190,6 +289,7 @@ describe('setupStreamingContext', () => { setCanProcessQueue: () => {}, updateChainInProgress: () => {}, setIsRetrying: () => {}, + setStreamingAgents: () => {}, }) // Verify abortController is stored in ref @@ -218,6 +318,7 @@ describe('setupStreamingContext', () => { setCanProcessQueue: () => {}, updateChainInProgress: () => {}, setIsRetrying: () => {}, + setStreamingAgents: () => {}, }) // Verify streamRefs was reset @@ -230,6 +331,228 @@ describe('setupStreamingContext', () => { }) }) +describe('handleRunCompletion', () => { + describe('abort path', () => { + test('skips finalizeQueueState when wasAbortedByUser is true (abort handler already released locks)', () => { + const timerController = createMockTimerController() + let messages = createBaseMessages() + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + messages = fn(messages) + }) + + // These simulate state that was already cleaned up by the abort handler + let streamStatus: StreamStatus = 'idle' + let canProcessQueue = true + let chainInProgress = false + const isProcessingQueueRef = { current: false } + const isQueuePausedRef = { current: false } + let hasReceivedPlanResponse = false + + // Track if setters are called (they shouldn't be) + let setStreamStatusCalled = false + let setCanProcessQueueCalled = false + let updateChainInProgressCalled = false + + const runState = { + traceSessionId: 'trace-test', + sessionState: undefined, + output: { type: 'lastMessage' as const, value: [] }, + } + + handleRunCompletion({ + runState, + actualCredits: undefined, + agentMode: 'DEFAULT' as any, + timerController, + updater, + aiMessageId: 'ai-1', + wasAbortedByUser: true, + setStreamStatus: (status: StreamStatus) => { + setStreamStatusCalled = true + streamStatus = status + }, + setCanProcessQueue: (can: boolean) => { + setCanProcessQueueCalled = true + canProcessQueue = can + }, + updateChainInProgress: (value: boolean) => { + updateChainInProgressCalled = true + chainInProgress = value + }, + setHasReceivedPlanResponse: (value: boolean) => { + hasReceivedPlanResponse = value + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // handleRunCompletion should NOT call finalizeQueueState for aborted runs + // (the abort handler already released the locks) + expect(setStreamStatusCalled).toBe(false) + expect(setCanProcessQueueCalled).toBe(false) + expect(updateChainInProgressCalled).toBe(false) + }) + + test('does not process server response when wasAbortedByUser is true', () => { + const timerController = createMockTimerController() + let messages = createBaseMessages() + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + messages = fn(messages) + }) + + let hasReceivedPlanResponse = false + + const runState = { + traceSessionId: 'trace-test', + sessionState: undefined, + output: { + type: 'lastMessage' as const, + value: [ + { + type: 'text' as const, + text: 'Server response that should be ignored', + }, + ], + }, + } + + handleRunCompletion({ + runState, + actualCredits: 42, + agentMode: 'PLAN' as any, + timerController, + updater, + aiMessageId: 'ai-1', + wasAbortedByUser: true, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + setHasReceivedPlanResponse: (value: boolean) => { + hasReceivedPlanResponse = value + }, + }) + + // Should NOT set plan response (abort path returns early before processing output) + expect(hasReceivedPlanResponse).toBe(false) + + // Timer should NOT be stopped by handleRunCompletion (abort handler already stopped it) + expect(timerController.stopCalls).not.toContain('success') + expect(timerController.stopCalls).not.toContain('error') + }) + + test('does not call resumeQueue in abort path (abort handler already released locks)', () => { + const timerController = createMockTimerController() + let messages = createBaseMessages() + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + messages = fn(messages) + }) + + let resumeQueueCalled = false + let canProcessQueueCalled = false + + const runState = { + traceSessionId: 'trace-test', + sessionState: undefined, + output: { type: 'lastMessage' as const, value: [] }, + } + + handleRunCompletion({ + runState, + actualCredits: undefined, + agentMode: 'DEFAULT' as any, + timerController, + updater, + aiMessageId: 'ai-1', + wasAbortedByUser: true, + setStreamStatus: () => {}, + setCanProcessQueue: () => { + canProcessQueueCalled = true + }, + updateChainInProgress: () => {}, + setHasReceivedPlanResponse: () => {}, + resumeQueue: () => { + resumeQueueCalled = true + }, + }) + + // Neither should be called - abort handler already handled cleanup + expect(resumeQueueCalled).toBe(false) + expect(canProcessQueueCalled).toBe(false) + }) + }) +}) + +describe('finalizeQueueState', () => { + test('sets stream status to idle and resets queue state', () => { + let streamStatus = 'streaming' as StreamStatus + let canProcessQueue = false + let chainInProgress = true + const isProcessingQueueRef = { current: true } + + finalizeQueueState({ + setStreamStatus: (status) => { + streamStatus = status + }, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + updateChainInProgress: (value) => { + chainInProgress = value + }, + isProcessingQueueRef, + }) + + expect(streamStatus).toBe('idle') + expect(canProcessQueue).toBe(true) + expect(chainInProgress).toBe(false) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('calls resumeQueue instead of setCanProcessQueue when provided', () => { + let streamStatus = 'streaming' as StreamStatus + let canProcessQueueCalled = false + let resumeQueueCalled = false + let chainInProgress = true + + finalizeQueueState({ + setStreamStatus: (status) => { + streamStatus = status + }, + setCanProcessQueue: () => { + canProcessQueueCalled = true + }, + updateChainInProgress: (value) => { + chainInProgress = value + }, + resumeQueue: () => { + resumeQueueCalled = true + }, + }) + + expect(streamStatus).toBe('idle') + expect(resumeQueueCalled).toBe(true) + expect(canProcessQueueCalled).toBe(false) + expect(chainInProgress).toBe(false) + }) + + test('respects isQueuePausedRef when no resumeQueue provided', () => { + let canProcessQueue = true + const isQueuePausedRef = { current: true } + + finalizeQueueState({ + setStreamStatus: () => {}, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + updateChainInProgress: () => {}, + isQueuePausedRef, + }) + + // When queue was paused before streaming, canProcessQueue should be false + expect(canProcessQueue).toBe(false) + }) +}) + describe('handleRunError', () => { let originalGetState: typeof useChatStore.getState @@ -241,7 +564,7 @@ describe('handleRunError', () => { useChatStore.getState = originalGetState }) - test('appends error to existing streamed content for regular errors', () => { + test('stores error in userError field for regular errors', () => { let messages: ChatMessage[] = [ { id: 'ai-1', @@ -264,7 +587,6 @@ describe('handleRunError', () => { handleRunError({ error: new Error('Network timeout'), - aiMessageId: 'ai-1', timerController, updater, setIsRetrying: (value: boolean) => { @@ -281,15 +603,12 @@ describe('handleRunError', () => { }, }) - // Flush the batched updates - updater.flush() - const aiMessage = messages.find((m) => m.id === 'ai-1') expect(aiMessage).toBeDefined() - // Content should be appended, not overwritten - expect(aiMessage!.content).toContain('Partial streamed content') - expect(aiMessage!.content).toContain('Network timeout') + // Content should be preserved, error stored in userError + expect(aiMessage!.content).toBe('Partial streamed content') + expect(aiMessage!.userError).toBe('Network timeout') // Verify state resets expect(streamStatus).toBe('idle') @@ -322,7 +641,6 @@ describe('handleRunError', () => { handleRunError({ error: new Error('Something failed'), - aiMessageId: 'ai-1', timerController, updater, setIsRetrying: () => {}, @@ -331,11 +649,9 @@ describe('handleRunError', () => { updateChainInProgress: () => {}, }) - updater.flush() - const aiMessage = messages.find((m) => m.id === 'ai-1') - // Should contain error message - expect(aiMessage!.content).toContain('Something failed') + // Error should be in userError field + expect(aiMessage!.userError).toBe('Something failed') expect(aiMessage!.isComplete).toBe(true) }) @@ -363,7 +679,6 @@ describe('handleRunError', () => { handleRunError({ error: new Error('Regular error'), - aiMessageId: 'ai-1', timerController, updater, setIsRetrying: () => {}, @@ -376,13 +691,13 @@ describe('handleRunError', () => { expect(setInputModeMock).not.toHaveBeenCalled() }) - test('Payment required error (402) uses setError, invalidates queries, and switches input mode', () => { + test('resets isProcessingQueueRef to false on error', () => { let messages: ChatMessage[] = [ { id: 'ai-1', variant: 'ai', - content: 'Partial streamed content', - blocks: [{ type: 'text', content: 'some block' }], + content: '', + blocks: [], timestamp: 'now', }, ] @@ -391,43 +706,1134 @@ describe('handleRunError', () => { const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { messages = fn(messages) }) + const isProcessingQueueRef = { current: true } - const setInputModeMock = mock(() => {}) - useChatStore.getState = () => ({ - ...originalGetState(), - setInputMode: setInputModeMock, + // Verify ref starts as true + expect(isProcessingQueueRef.current).toBe(true) + + handleRunError({ + error: new Error('Some error'), + timerController, + updater, + setIsRetrying: () => {}, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + isProcessingQueueRef, }) - const paymentError = createPaymentRequiredError('Out of credits') + // Verify isProcessingQueueRef is reset to false + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('respects isQueuePausedRef when setting canProcessQueue on error', () => { + let messages: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: '', + blocks: [], + timestamp: 'now', + }, + ] + + const timerController = createMockTimerController() + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + messages = fn(messages) + }) + const isQueuePausedRef = { current: true } + let canProcessQueue = true handleRunError({ - error: paymentError, - aiMessageId: 'ai-1', + error: new Error('Some error'), timerController, updater, setIsRetrying: () => {}, setStreamStatus: () => {}, - setCanProcessQueue: () => {}, + setCanProcessQueue: (can: boolean) => { + canProcessQueue = can + }, updateChainInProgress: () => {}, + isQueuePausedRef, + }) + + // When queue was paused before streaming, canProcessQueue should be false + expect(canProcessQueue).toBe(false) + }) + + test('context length exceeded error (AI_APICallError) stores error in userError and preserves content', () => { + let messages: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'Partial streamed content before error', + blocks: [{ type: 'text', content: 'some block content' }], + timestamp: 'now', + }, + ] + + const timerController = createMockTimerController() + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + messages = fn(messages) + }) + + // Create an error that matches the real AI_APICallError structure + const contextLengthError = Object.assign( + new Error( + 'This endpoint\'s maximum context length is 200000 tokens. However, you requested about 201209 tokens (158536 of text input, 10673 of tool input, 32000 in the output). Please reduce the length of either one, or use the "middle-out" transform to compress your prompt automatically.', + ), + { + name: 'AI_APICallError', + statusCode: 400, + }, + ) + + let streamStatus = 'streaming' as StreamStatus + let canProcessQueue = false + let chainInProgress = true + let isRetrying = true + + handleRunError({ + error: contextLengthError, + timerController, + updater, + setIsRetrying: (value: boolean) => { + isRetrying = value + }, + setStreamStatus: (status: StreamStatus) => { + streamStatus = status + }, + setCanProcessQueue: (can: boolean) => { + canProcessQueue = can + }, + updateChainInProgress: (value: boolean) => { + chainInProgress = value + }, }) const aiMessage = messages.find((m) => m.id === 'ai-1') expect(aiMessage).toBeDefined() - // For PaymentRequiredError, setError is used which OVERWRITES content - expect(aiMessage!.content).not.toContain('Partial streamed content') - expect(aiMessage!.content).toContain('Out of credits') + // Content should be preserved + expect(aiMessage!.content).toBe('Partial streamed content before error') - // Blocks should be preserved for debugging context - expect(aiMessage!.blocks).toEqual([{ type: 'text', content: 'some block' }]) + // Blocks should be preserved + expect(aiMessage!.blocks).toEqual([ + { type: 'text', content: 'some block content' }, + ]) + + // Error should be stored in userError (displayed in UserErrorBanner) + expect(aiMessage!.userError).toContain( + 'maximum context length is 200000 tokens', + ) + expect(aiMessage!.userError).toContain('201209 tokens') // Message should be marked complete expect(aiMessage!.isComplete).toBe(true) - // Input mode should switch to outOfCredits - expect(setInputModeMock).toHaveBeenCalledWith('outOfCredits') + // State should be reset + expect(streamStatus).toBe('idle') + expect(canProcessQueue).toBe(true) + expect(chainInProgress).toBe(false) + expect(isRetrying).toBe(false) + + // Timer should be stopped with error + expect(timerController.stopCalls).toContain('error') + }) + + test('Payment required error (402) uses setError, invalidates queries, and switches input mode', () => { + let messages: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'Partial streamed content', + blocks: [{ type: 'text', content: 'some block' }], + timestamp: 'now', + }, + ] + + const timerController = createMockTimerController() + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + messages = fn(messages) + }) + + const setInputModeMock = mock(() => {}) + useChatStore.getState = () => ({ + ...originalGetState(), + setInputMode: setInputModeMock, + }) + + const paymentError = createPaymentRequiredError('Out of credits') + + handleRunError({ + error: paymentError, + timerController, + updater, + setIsRetrying: () => {}, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + }) + + const aiMessage = messages.find((m) => m.id === 'ai-1') + expect(aiMessage).toBeDefined() + + // For PaymentRequiredError, setError sets userError (not content) + // Content is preserved, error is stored in userError field + expect(aiMessage!.content).toBe('Partial streamed content') + expect(aiMessage!.userError).toContain('Out of credits') + + // Blocks should be preserved for debugging context + expect(aiMessage!.blocks).toEqual([{ type: 'text', content: 'some block' }]) + + // Message should be marked complete + expect(aiMessage!.isComplete).toBe(true) + + // Input mode should switch to outOfCredits + expect(setInputModeMock).toHaveBeenCalledWith('outOfCredits') // Timer should still be stopped with error expect(timerController.stopCalls).toContain('error') }) }) + +/** + * CLI-level async race test: reproduces the exact bug scenario where aborting + * run A and attempting run B before A resolves would lose message history. + * + * This test simulates the full lifecycle at the helper level: + * 1. Start run A (setupStreamingContext) + * 2. Abort run A mid-stream + * 3. Attempt run B — verify it's blocked (chain lock held) + * 4. Resolve run A (handleRunCompletion with updated state) + * 5. Verify run B is now unblocked and can use state from A + */ +describe('CLI-level race condition: abort run A, attempt run B before A resolves', () => { + /** + * Simulates the queue-processing gate checks from useMessageQueue.processNextMessage. + * Returns true if a queued message would be allowed to proceed. + */ + const canQueueProcessNextMessage = (opts: { + isChainInProgress: boolean + canProcessQueue: boolean + streamStatus: StreamStatus + isProcessingQueue: boolean + isQueuePaused: boolean + }): boolean => { + if (opts.isQueuePaused) return false + if (!opts.canProcessQueue) return false + if (opts.streamStatus !== 'idle') return false + if (opts.isChainInProgress) return false + if (opts.isProcessingQueue) return false + return true + } + + test('run B can proceed immediately after abort (chain lock released by abort handler)', () => { + // --- Shared mutable state (simulates React refs and state in the CLI) --- + let streamStatus: StreamStatus = 'idle' + let canProcessQueue = false + let chainInProgress = true // Set true at start of sendMessage + const isProcessingQueueRef = { current: false } + const isQueuePausedRef = { current: false } + + const setStreamStatus = (status: StreamStatus) => { + streamStatus = status + } + const setCanProcessQueue = (can: boolean) => { + canProcessQueue = can + } + const updateChainInProgress = (value: boolean) => { + chainInProgress = value + } + + // --- PHASE 1: Start run A (setupStreamingContext) --- + let messagesA = createBaseMessages() + const streamRefsA = createStreamController() + const timerControllerA = createMockTimerController() + const abortControllerRefA = { current: null as AbortController | null } + + const { updater: updaterA, abortController: abortControllerA } = + setupStreamingContext({ + aiMessageId: 'ai-1', + timerController: timerControllerA, + setMessages: (fn: any) => { + messagesA = fn(messagesA) + }, + streamRefs: streamRefsA, + abortControllerRef: abortControllerRefA, + setStreamStatus, + setCanProcessQueue, + isQueuePausedRef, + isProcessingQueueRef, + updateChainInProgress, + setIsRetrying: () => {}, + setStreamingAgents: () => {}, + }) + + // Simulate streaming has started + streamStatus = 'streaming' + + // Verify run A is actively streaming + expect(streamStatus).toBe('streaming') + expect(chainInProgress).toBe(true) + + // --- PHASE 2: User aborts run A --- + abortControllerA.abort() + + // Abort handler fires synchronously: UI is updated AND chain lock is released + expect(streamRefsA.state.wasAbortedByUser).toBe(true) + expect(streamStatus as StreamStatus).toBe('idle') + expect(chainInProgress).toBe(false) // Chain lock released immediately! + expect(canProcessQueue).toBe(true) + + // --- PHASE 3: User types run B — verify it's UNBLOCKED --- + const canProcessRunB = canQueueProcessNextMessage({ + isChainInProgress: chainInProgress, + canProcessQueue, + streamStatus, + isProcessingQueue: isProcessingQueueRef.current, + isQueuePaused: isQueuePausedRef.current, + }) + + // Run B can proceed immediately — this is the core fix. + // New messages are sent directly instead of being queued. + expect(canProcessRunB).toBe(true) + }) + + test('handleRunCompletion does not interfere after abort (no-op for aborted runs)', () => { + // After abort releases the chain lock, handleRunCompletion should be a no-op + // to avoid interfering with any new run that may have started. + + let streamStatus: StreamStatus = 'idle' + let canProcessQueue = true + let chainInProgress = false // Already released by abort handler + const isProcessingQueueRef = { current: false } + const isQueuePausedRef = { current: false } + + const timerController = createMockTimerController() + let messages = createBaseMessages() + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + messages = fn(messages) + }) + + // Track calls + let setStreamStatusCallCount = 0 + let updateChainInProgressCallCount = 0 + + const runState: RunState = { + traceSessionId: 'trace-test', + sessionState: {} as any, + output: { type: 'lastMessage' as const, value: [] }, + } + + handleRunCompletion({ + runState, + actualCredits: undefined, + agentMode: 'DEFAULT' as any, + timerController, + updater, + aiMessageId: 'ai-1', + wasAbortedByUser: true, + setStreamStatus: () => { + setStreamStatusCallCount++ + }, + setCanProcessQueue: (can: boolean) => { + canProcessQueue = can + }, + updateChainInProgress: () => { + updateChainInProgressCallCount++ + }, + setHasReceivedPlanResponse: () => {}, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // handleRunCompletion should be a no-op for aborted runs + expect(setStreamStatusCallCount).toBe(0) + expect(updateChainInProgressCallCount).toBe(0) + // State should be unchanged (still in the "released" state from abort handler) + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + }) + + test('aborted run A finally block must not clear isProcessingQueueRef owned by run B', () => { + // Regression test for overlap hazard: after abort releases the chain lock, + // run B can start from the queue and set isProcessingQueueRef = true. + // Run A's late-executing finally block must NOT clear it. + // + // This tests the pattern used in use-send-message.ts where the finally block + // guards isProcessingQueueRef cleanup with !abortController.signal.aborted. + + const isProcessingQueueRef = { current: false } + const isQueuePausedRef = { current: false } + let chainInProgress = true + let canProcessQueue = false + let streamStatus: StreamStatus = 'idle' + + // --- Run A setup and abort --- + let messagesA = createBaseMessages() + const sharedStreamRefs = createStreamController() + const timerA = createMockTimerController() + const abortRefA = { current: null as AbortController | null } + + const { abortController: abortA } = setupStreamingContext({ + aiMessageId: 'ai-run-a', + timerController: timerA, + setMessages: (fn: any) => { + messagesA = fn(messagesA) + }, + streamRefs: sharedStreamRefs, + abortControllerRef: abortRefA, + setStreamStatus: (status: StreamStatus) => { + streamStatus = status + }, + setCanProcessQueue: (can: boolean) => { + canProcessQueue = can + }, + isQueuePausedRef, + isProcessingQueueRef, + updateChainInProgress: (value: boolean) => { + chainInProgress = value + }, + setIsRetrying: () => {}, + setStreamingAgents: () => {}, + }) + + // Abort run A + abortA.abort() + expect(chainInProgress).toBe(false) + expect(isProcessingQueueRef.current).toBe(false) + + // --- Run B starts from queue, takes ownership of isProcessingQueueRef --- + isProcessingQueueRef.current = true // Queue's processNextMessage sets this + chainInProgress = true + canProcessQueue = false + + // --- Simulate run A's finally block (late execution) --- + // In use-send-message.ts, the finally block guards with !abortController.signal.aborted. + // Verify abortA.signal.aborted is true so the guard would skip cleanup. + expect(abortA.signal.aborted).toBe(true) + + // The finally block pattern: only clean up if NOT aborted + if (!abortA.signal.aborted) { + // This should NOT execute + isProcessingQueueRef.current = false + } + + // isProcessingQueueRef must still be true (owned by run B) + expect(isProcessingQueueRef.current).toBe(true) + // chainInProgress must still be true (owned by run B) + expect(chainInProgress).toBe(true) + }) + + test('reject-after-abort must not run handleRunError cleanup that could clobber run B', () => { + // Regression test: if client.run() rejects after abort (e.g., network teardown), + // handleRunError should NOT run because it would reset shared queue/stream state + // that run B may have already claimed. + // + // This tests the pattern used in use-send-message.ts where the catch block + // guards handleRunError with !abortController.signal.aborted. + + let streamStatus: StreamStatus = 'idle' + let canProcessQueue = true + let chainInProgress = false // Released by abort handler + const isProcessingQueueRef = { current: false } + const isQueuePausedRef = { current: false } + + // --- Simulate run A was aborted --- + const abortController = new AbortController() + abortController.abort() + expect(abortController.signal.aborted).toBe(true) + + // --- Run B has started and claimed shared state --- + chainInProgress = true + canProcessQueue = false + isProcessingQueueRef.current = true + streamStatus = 'streaming' + + // --- Simulate what happens if client.run() rejects after abort --- + // The catch block pattern: only handle error if NOT aborted + const error = new Error('AbortError: The operation was aborted') + + if (!abortController.signal.aborted) { + // This should NOT execute — handleRunError would clobber run B's state + handleRunError({ + error, + timerController: createMockTimerController(), + updater: createBatchedMessageUpdater('ai-1', () => {}), + setIsRetrying: () => {}, + setStreamStatus: (status: StreamStatus) => { + streamStatus = status + }, + setCanProcessQueue: (can: boolean) => { + canProcessQueue = can + }, + updateChainInProgress: (value: boolean) => { + chainInProgress = value + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + } + + // Run B's state must be untouched + expect(chainInProgress).toBe(true) // Still owned by run B + expect(canProcessQueue).toBe(false) // Still owned by run B + expect(isProcessingQueueRef.current).toBe(true) // Still owned by run B + expect(streamStatus).toBe('streaming') // Still owned by run B + }) + + test('handleRunError WOULD clobber run B state if called without abort guard (documents why guard is needed)', () => { + // This test proves that handleRunError resets shared state, which is why + // the catch block in use-send-message.ts MUST guard it with abort check. + + let streamStatus: StreamStatus = 'streaming' + let canProcessQueue = false + let chainInProgress = true + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + // Call handleRunError without guard (simulates the bug scenario) + handleRunError({ + error: new Error('AbortError'), + timerController: createMockTimerController(), + updater: createBatchedMessageUpdater('ai-1', (fn: any) => {}), + setIsRetrying: () => {}, + setStreamStatus: (status: StreamStatus) => { + streamStatus = status + }, + setCanProcessQueue: (can: boolean) => { + canProcessQueue = can + }, + updateChainInProgress: (value: boolean) => { + chainInProgress = value + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // handleRunError resets ALL shared state — this would clobber run B + expect(chainInProgress).toBe(false) // Clobbered! + expect(canProcessQueue).toBe(true) // Clobbered! + expect(isProcessingQueueRef.current).toBe(false) // Clobbered! + expect(streamStatus as StreamStatus).toBe('idle') // Clobbered! + }) + + test('full two-run lifecycle with shared streamRefs: run A abort → run B starts immediately', () => { + // End-to-end test: two complete runs sharing the SAME streamRefs instance + // (matching production behavior where streamRefs is reused across sends). + // Verifies that run B can start immediately after abort, and that run A's + // late-resolving handleRunCompletion does NOT interfere with run B. + + let streamStatus: StreamStatus = 'idle' + let canProcessQueue = false + let chainInProgress = true + const isProcessingQueueRef = { current: false } + const isQueuePausedRef = { current: false } + let previousRunState: RunState | null = null + + const setStreamStatus = (status: StreamStatus) => { + streamStatus = status + } + const setCanProcessQueue = (can: boolean) => { + canProcessQueue = can + } + const updateChainInProgress = (value: boolean) => { + chainInProgress = value + } + + // CRITICAL: Use a single shared streamRefs instance, just like production. + // In production, streamRefsRef is created once via useRef and reused. + const sharedStreamRefs = createStreamController() + + // === RUN A === + let messagesA = createBaseMessages() + const timerA = createMockTimerController() + const abortRefA = { current: null as AbortController | null } + + const { updater: updaterA, abortController: abortA } = + setupStreamingContext({ + aiMessageId: 'ai-run-a', + timerController: timerA, + setMessages: (fn: any) => { + messagesA = fn(messagesA) + }, + streamRefs: sharedStreamRefs, + abortControllerRef: abortRefA, + setStreamStatus, + setCanProcessQueue, + isQueuePausedRef, + isProcessingQueueRef, + updateChainInProgress, + setIsRetrying: () => {}, + setStreamingAgents: () => {}, + }) + + streamStatus = 'streaming' + + // Abort run A + abortA.abort() + expect(chainInProgress).toBe(false) // Lock released immediately! + expect(canProcessQueue).toBe(true) + expect(sharedStreamRefs.state.wasAbortedByUser).toBe(true) + + // === RUN B starts immediately (before A's client.run() resolves) === + chainInProgress = true + canProcessQueue = false + + let messagesB: ChatMessage[] = [ + { + id: 'ai-run-b', + variant: 'ai', + content: '', + blocks: [], + timestamp: 'now', + }, + ] + const timerB = createMockTimerController() + const abortRefB = { current: null as AbortController | null } + + // Run B's setupStreamingContext calls sharedStreamRefs.reset(), + // which clears wasAbortedByUser. This is the key race condition. + const { updater: updaterB, abortController: abortB } = + setupStreamingContext({ + aiMessageId: 'ai-run-b', + timerController: timerB, + setMessages: (fn: any) => { + messagesB = fn(messagesB) + }, + streamRefs: sharedStreamRefs, + abortControllerRef: abortRefB, + setStreamStatus, + setCanProcessQueue, + isQueuePausedRef, + isProcessingQueueRef, + updateChainInProgress, + setIsRetrying: () => {}, + setStreamingAgents: () => {}, + }) + + // After B starts, shared streamRefs.wasAbortedByUser is reset to false. + // This is why we use per-run abortController.signal.aborted instead. + expect(sharedStreamRefs.state.wasAbortedByUser).toBe(false) + + // Now run A's client.run() resolves (after B has already started and reset shared state). + // handleRunCompletion uses the per-run wasAbortedByUser boolean (from abortA.signal.aborted), + // NOT the shared streamRefs, so it correctly knows A was aborted. + const runStateA: RunState = { + traceSessionId: 'trace-test-a', + sessionState: { + id: 'session-abc', + messages: [ + { role: 'user', content: 'first message' }, + { role: 'assistant', content: 'partial response before cancel' }, + ], + } as any, + output: { type: 'lastMessage' as const, value: [] }, + } + previousRunState = runStateA + + handleRunCompletion({ + runState: runStateA, + actualCredits: undefined, + agentMode: 'DEFAULT' as any, + timerController: timerA, + updater: updaterA, + aiMessageId: 'ai-run-a', + wasAbortedByUser: abortA.signal.aborted, // per-run flag, not shared state + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + setHasReceivedPlanResponse: () => {}, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // handleRunCompletion for aborted run A should be a no-op + // (it should NOT interfere with run B's chain lock) + expect(chainInProgress).toBe(true) // Still true from run B! + + // Simulate run B completing normally + const runStateB: RunState = { + traceSessionId: 'trace-test-b', + sessionState: { + id: 'session-abc', + messages: [ + { role: 'user', content: 'first message' }, + { role: 'assistant', content: 'partial response before cancel' }, + { role: 'user', content: 'second message' }, + { role: 'assistant', content: 'full response to second message' }, + ], + } as any, + output: { + type: 'lastMessage' as const, + value: [{ type: 'text' as const, text: 'full response' }], + }, + } + previousRunState = runStateB + + handleRunCompletion({ + runState: runStateB, + actualCredits: 5, + agentMode: 'DEFAULT' as any, + timerController: timerB, + updater: updaterB, + aiMessageId: 'ai-run-b', + wasAbortedByUser: abortB.signal.aborted, // per-run flag: false (B was not aborted) + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + setHasReceivedPlanResponse: () => {}, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // Final state: run B completed normally + expect(previousRunState!.sessionState as any).toEqual({ + id: 'session-abc', + messages: [ + { role: 'user', content: 'first message' }, + { role: 'assistant', content: 'partial response before cancel' }, + { role: 'user', content: 'second message' }, + { role: 'assistant', content: 'full response to second message' }, + ], + }) + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + }) +}) + +/** + * Tests for early return queue state reset in sendMessage. + * These test the resetEarlyReturnState helper used across multiple early return paths: + * - prepareUserMessage exception + * - validation failure (success: false) + * - validation exception + */ +describe('resetEarlyReturnState', () => { + describe('prepareUserMessage exception path', () => { + test('resets chain in progress to false', () => { + let chainInProgress = true + + resetEarlyReturnState({ + updateChainInProgress: (value) => { + chainInProgress = value + }, + setCanProcessQueue: () => {}, + }) + + expect(chainInProgress).toBe(false) + }) + + test('sets canProcessQueue to true when queue is not paused', () => { + let canProcessQueue = false + const isQueuePausedRef = { current: false } + + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isQueuePausedRef, + }) + + expect(canProcessQueue).toBe(true) + }) + + test('sets canProcessQueue to false when queue is paused', () => { + let canProcessQueue = true + const isQueuePausedRef = { current: true } + + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isQueuePausedRef, + }) + + expect(canProcessQueue).toBe(false) + }) + + test('resets isProcessingQueueRef to false', () => { + const isProcessingQueueRef = { current: true } + + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: () => {}, + isProcessingQueueRef, + }) + + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('handles missing isProcessingQueueRef gracefully', () => { + // Should not throw when isProcessingQueueRef is undefined + expect(() => { + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: () => {}, + }) + }).not.toThrow() + }) + + test('handles missing isQueuePausedRef gracefully (defaults to canProcessQueue=true)', () => { + let canProcessQueue = false + + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + // No isQueuePausedRef - should default to !undefined = true + }) + + expect(canProcessQueue).toBe(true) + }) + }) + + describe('validation failure path (success: false)', () => { + test('resets all queue state correctly when processing queued message', () => { + let chainInProgress = true + let canProcessQueue = false + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + resetEarlyReturnState({ + updateChainInProgress: (value) => { + chainInProgress = value + }, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('respects queue paused state after validation failure', () => { + let chainInProgress = true + let canProcessQueue = true + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: true } + + resetEarlyReturnState({ + updateChainInProgress: (value) => { + chainInProgress = value + }, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(false) // Queue was paused, should stay paused + expect(isProcessingQueueRef.current).toBe(false) + }) + }) + + describe('validation exception path', () => { + test('resets all queue state correctly when validation throws', () => { + let chainInProgress = true + let canProcessQueue = false + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + // Simulating what happens after catching validation exception + resetEarlyReturnState({ + updateChainInProgress: (value) => { + chainInProgress = value + }, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('preserves queue pause state when validation throws', () => { + let canProcessQueue = true + const isQueuePausedRef = { current: true } + const isProcessingQueueRef = { current: true } + + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // Queue was explicitly paused before, should remain paused after error + expect(canProcessQueue).toBe(false) + // But processing lock should be released to allow manual resume + expect(isProcessingQueueRef.current).toBe(false) + }) + }) + + describe('complete early return scenarios', () => { + test('queue can process next message after prepareUserMessage exception', () => { + // Scenario: Message was being processed from queue, prepareUserMessage throws + let chainInProgress = true + let canProcessQueue = false + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + // After exception, reset is called + resetEarlyReturnState({ + updateChainInProgress: (value) => { + chainInProgress = value + }, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // Queue should be able to process next message + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('queue can process next message after validation returns success=false', () => { + // Scenario: Message was being processed, validation returns failure + let chainInProgress = true + let canProcessQueue = false + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + resetEarlyReturnState({ + updateChainInProgress: (value) => { + chainInProgress = value + }, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // All locks released, queue can continue + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('queue can process next message after validation throws exception', () => { + // Scenario: Message was being processed, validation throws + let chainInProgress = true + let canProcessQueue = false + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + resetEarlyReturnState({ + updateChainInProgress: (value) => { + chainInProgress = value + }, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // All locks released, queue can continue + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('queue remains blocked after error if user had paused it', () => { + // Scenario: User paused queue, then an error occurred + // Queue should remain paused after error recovery + let chainInProgress = true + let canProcessQueue = true + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: true } // User explicitly paused + + resetEarlyReturnState({ + updateChainInProgress: (value) => { + chainInProgress = value + }, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // Chain is no longer in progress + expect(chainInProgress).toBe(false) + // But queue should remain blocked because user paused it + expect(canProcessQueue).toBe(false) + // Processing lock is released though + expect(isProcessingQueueRef.current).toBe(false) + }) + }) +}) + +describe('freebuff gate errors', () => { + const makeUpdater = (messages: ChatMessage[]) => { + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + const next = fn(messages) + messages.length = 0 + messages.push(...next) + }) + return updater + } + + const baseMessage = (): ChatMessage[] => [ + { + id: 'ai-1', + variant: 'ai', + content: '', + blocks: [], + timestamp: 'now', + }, + ] + + const gateError = (kind: string, statusCode: number) => ({ + error: kind, + statusCode, + message: 'server said so', + }) + + test('handleRunError maps 409 session_superseded to the restart-required message', () => { + const messages = baseMessage() + const updater = makeUpdater(messages) + handleRunError({ + error: gateError('session_superseded', 409), + timerController: createMockTimerController(), + updater, + setIsRetrying: () => {}, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + }) + updater.flush() + expect(messages[0].userError).toContain('Another freebuff CLI took over') + }) + + test('handleRunError suppresses the inline error for 410 session_expired (ended banner takes over)', () => { + const messages = baseMessage() + const updater = makeUpdater(messages) + handleRunError({ + error: gateError('session_expired', 410), + timerController: createMockTimerController(), + updater, + setIsRetrying: () => {}, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + }) + updater.flush() + // New contract: the gate handler flips the session store into `ended` + // and the session-ended banner is the user-facing signal, so we do NOT + // also surface an inline userError inside the chat transcript. + expect(messages[0].userError).toBeUndefined() + }) + + test('handleRunError suppresses the inline error for 428 waiting_room_required (ended banner takes over)', () => { + const messages = baseMessage() + const updater = makeUpdater(messages) + handleRunError({ + error: gateError('waiting_room_required', 428), + timerController: createMockTimerController(), + updater, + setIsRetrying: () => {}, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + }) + updater.flush() + expect(messages[0].userError).toBeUndefined() + }) + + test('handleRunError maps 429 waiting_room_queued to the still-queued message', () => { + const messages = baseMessage() + const updater = makeUpdater(messages) + handleRunError({ + error: gateError('waiting_room_queued', 429), + timerController: createMockTimerController(), + updater, + setIsRetrying: () => {}, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + }) + updater.flush() + expect(messages[0].userError).toContain('still in the waiting room') + }) + + test('handleRunError ignores gate-shaped errors with non-matching status code', () => { + // An error body with error: 'session_superseded' but a 500 status should + // NOT be classified as a gate error (prevents generic 5xx from mimicking + // the structured gate responses). + const messages = baseMessage() + const updater = makeUpdater(messages) + const err = Object.assign(new Error('oops'), { + error: 'session_superseded', + statusCode: 500, + }) + handleRunError({ + error: err, + timerController: createMockTimerController(), + updater, + setIsRetrying: () => {}, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + }) + updater.flush() + expect(messages[0].userError).toBe('oops') + expect(messages[0].userError).not.toContain('took over') + }) + + test('handleRunCompletion with gate error output routes through the gate handler', () => { + const messages = baseMessage() + const updater = makeUpdater(messages) + const runState: RunState = { + traceSessionId: 'trace-test', + sessionState: undefined as any, + output: { + type: 'error', + message: 'server said so', + error: 'session_expired', + statusCode: 410, + } as any, + } + handleRunCompletion({ + runState, + actualCredits: undefined, + agentMode: 'LITE', + timerController: createMockTimerController(), + updater, + aiMessageId: 'ai-1', + wasAbortedByUser: false, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + setHasReceivedPlanResponse: () => {}, + }) + updater.flush() + // 410 is now handled by the ended banner, not an inline error. The + // assertion here just confirms routing happened via the gate handler + // (which swallows the userError) rather than the generic error path + // (which would set a userError from the message). + expect(messages[0].userError).toBeUndefined() + }) +}) diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index 39e209cfad..e8ceb9421a 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -1,13 +1,25 @@ +import { getErrorObject } from '@codebuff/common/util/error' + +import { + markFreebuffSessionCountryBlocked, + markFreebuffSessionEnded, + markFreebuffSessionSuperseded, + refreshFreebuffSession, +} from '../use-freebuff-session' import { getProjectRoot } from '../../project-files' import { useChatStore } from '../../state/chat-store' +import { IS_FREEBUFF } from '../../utils/constants' import { processBashContext } from '../../utils/bash-context-processor' +import { markRunningAgentsAsCancelled } from '../../utils/block-operations' import { - createErrorMessage, + getCountryBlockFromFreeModeError, + getFreeModeUnavailableErrorMessage, + getFreebuffGateErrorKind, + getFreebuffRateLimitErrorMessage, isOutOfCreditsError, + isFreeModeUnavailableError, OUT_OF_CREDITS_MESSAGE, } from '../../utils/error-handling' -import { invalidateActivityQuery } from '../use-activity-query' -import { usageQueryKeys } from '../use-usage-query' import { formatElapsedTime } from '../../utils/format-elapsed-time' import { processImagesForMessage } from '../../utils/image-processor' import { logger } from '../../utils/logger' @@ -18,26 +30,84 @@ import { type BatchedMessageUpdater, } from '../../utils/message-updater' import { createModeDividerMessage } from '../../utils/send-message-helpers' +import { yieldToEventLoop } from '../../utils/yield-to-event-loop' +import { invalidateActivityQuery } from '../use-activity-query' +import { usageQueryKeys } from '../use-usage-query' import type { PendingAttachment, + PendingFileAttachment, PendingImageAttachment, PendingTextAttachment, -} from '../../state/chat-store' +} from '../../types/store' import type { ChatMessage } from '../../types/chat' import type { AgentMode } from '../../utils/constants' - import type { SendMessageTimerController } from '../../utils/send-message-timer' import type { StreamController } from '../stream-state' import type { StreamStatus } from '../use-message-queue' import type { MessageContent, RunState } from '@codebuff/sdk' import type { MutableRefObject, SetStateAction } from 'react' -import { getErrorObject } from '@codebuff/common/util/error' -const yieldToEventLoop = () => - new Promise((resolve) => { - setTimeout(resolve, 0) - }) +/** Resets queue state on early return (before streaming starts). */ +export type ResetEarlyReturnStateParams = { + setCanProcessQueue: (can: boolean) => void + updateChainInProgress: (value: boolean) => void + isProcessingQueueRef?: MutableRefObject + isQueuePausedRef?: MutableRefObject +} + +export const resetEarlyReturnState = ( + params: ResetEarlyReturnStateParams, +): void => { + const { + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + } = params + + updateChainInProgress(false) + setCanProcessQueue(!isQueuePausedRef?.current) + if (isProcessingQueueRef) { + isProcessingQueueRef.current = false + } +} + +/** Resets queue state after streaming completes, aborts, or errors. */ +export type FinalizeQueueStateParams = { + setStreamStatus: (status: StreamStatus) => void + setCanProcessQueue: (can: boolean) => void + updateChainInProgress: (value: boolean) => void + isProcessingQueueRef?: MutableRefObject + isQueuePausedRef?: MutableRefObject + resumeQueue?: () => void +} + +export const finalizeQueueState = (params: FinalizeQueueStateParams): void => { + const { + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + resumeQueue, + } = params + + setStreamStatus('idle') + // Release lock here as part of normal completion flow. + // Also released in finally block and .catch() as safety nets (idempotent). + if (isProcessingQueueRef) { + isProcessingQueueRef.current = false + } + if (resumeQueue) { + resumeQueue() + } else { + setCanProcessQueue(!isQueuePausedRef?.current) + } + updateChainInProgress(false) +} + +const DEFAULT_RUN_OUTPUT_ERROR_MESSAGE = 'No output from agent run' export type PrepareUserMessageDeps = { setMessages: (update: SetStateAction) => void @@ -87,6 +157,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) { @@ -98,11 +172,29 @@ export const prepareUserMessage = async (params: { : textAttachmentContent } - const { attachments: imageAttachments, messageContent } = await processImagesForMessage({ - content: finalContent, - pendingImages, - projectRoot: getProjectRoot(), - }) + // 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, + projectRoot: getProjectRoot(), + }) const shouldInsertDivider = lastMessageMode === null || lastMessageMode !== agentMode @@ -115,8 +207,23 @@ 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 @@ -132,7 +239,7 @@ export const prepareUserMessage = async (params: { next = postUserMessage(next) } if (next.length > 100) { - return next.slice(-100) + next = next.slice(-100) } return next }) @@ -158,11 +265,12 @@ export const setupStreamingContext = (params: { setStreamStatus: (status: StreamStatus) => void setCanProcessQueue: (can: boolean) => void isQueuePausedRef?: MutableRefObject + isProcessingQueueRef?: MutableRefObject updateChainInProgress: (value: boolean) => void setIsRetrying: (value: boolean) => void + setStreamingAgents: (updater: (prev: Set) => Set) => void }) => { const { - aiMessageId, timerController, setMessages, streamRefs, @@ -170,27 +278,51 @@ export const setupStreamingContext = (params: { setStreamStatus, setCanProcessQueue, isQueuePausedRef, + isProcessingQueueRef, updateChainInProgress, setIsRetrying, + setStreamingAgents, } = params + const { aiMessageId } = params streamRefs.reset() timerController.start(aiMessageId) const updater = createBatchedMessageUpdater(aiMessageId, setMessages) + // Clear any previous UI-only error on this message when starting a new run + updater.clearUserError() const hasReceivedContentRef = { current: false } const abortController = new AbortController() abortControllerRef.current = abortController abortController.signal.addEventListener('abort', () => { - // Abort means the user stopped streaming; finalize with an interruption notice. + // Abort means the user stopped streaming; update UI with an interruption notice. + // Release the chain lock immediately so new messages can be sent directly instead + // of being queued. The minor trade-off is that if the user sends a new message + // before client.run() resolves, it may use stale previousRunStateRef. This is + // acceptable because: (1) the user explicitly cancelled, and (2) client.run() + // will update previousRunStateRef when it eventually resolves, so subsequent + // runs will have the full state. streamRefs.setters.setWasAbortedByUser(true) - setStreamStatus('idle') - setCanProcessQueue(!isQueuePausedRef?.current) - updateChainInProgress(false) setIsRetrying(false) timerController.stop('aborted') - updater.updateAiMessageBlocks((blocks) => appendInterruptionNotice(blocks)) + // Update stream status so the UI reflects cancellation visually + setStreamStatus('idle') + + // Clear streaming agents so cancelled status displays correctly in UI + setStreamingAgents(() => new Set()) + + // Release chain lock and queue state so new messages are sent directly + updateChainInProgress(false) + setCanProcessQueue(!isQueuePausedRef?.current) + if (isProcessingQueueRef) { + isProcessingQueueRef.current = false + } + + updater.updateAiMessageBlocks((blocks) => { + const cancelledBlocks = markRunningAgentsAsCancelled(blocks) + return appendInterruptionNotice(cancelledBlocks) + }) updater.markComplete() }) @@ -204,12 +336,14 @@ export const handleRunCompletion = (params: { timerController: SendMessageTimerController updater: BatchedMessageUpdater aiMessageId: string - streamRefs: StreamController + wasAbortedByUser: boolean setStreamStatus: (status: StreamStatus) => void setCanProcessQueue: (can: boolean) => void updateChainInProgress: (value: boolean) => void setHasReceivedPlanResponse: (value: boolean) => void resumeQueue?: () => void + isProcessingQueueRef?: MutableRefObject + isQueuePausedRef?: MutableRefObject }) => { const { runState, @@ -217,36 +351,45 @@ export const handleRunCompletion = (params: { agentMode, timerController, updater, - aiMessageId, - streamRefs, + wasAbortedByUser, setStreamStatus, setCanProcessQueue, updateChainInProgress, setHasReceivedPlanResponse, resumeQueue, + isProcessingQueueRef, + isQueuePausedRef, } = params + // If user aborted, the abort handler already handled UI updates and released the + // chain lock. Don't finalize queue state again to avoid interfering with any new + // run that may have started after the abort. Uses per-run abort signal (not shared + // streamRefs) so a newer run's reset() can't clear this flag. + if (wasAbortedByUser) { + return + } + const output = runState.output const finalizeAfterError = () => { - setStreamStatus('idle') - setCanProcessQueue(true) - updateChainInProgress(false) + finalizeQueueState({ + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) timerController.stop('error') } if (!output) { - if (!streamRefs.state.wasAbortedByUser) { - updater.setError('No output from agent run') + if (!wasAbortedByUser) { + updater.setError(DEFAULT_RUN_OUTPUT_ERROR_MESSAGE) finalizeAfterError() } return } if (output.type === 'error') { - if (streamRefs.state.wasAbortedByUser) { - return - } - if (isOutOfCreditsError(output)) { updater.setError(OUT_OF_CREDITS_MESSAGE) useChatStore.getState().setInputMode('outOfCredits') @@ -255,11 +398,37 @@ export const handleRunCompletion = (params: { return } - const partial = createErrorMessage( - output.message ?? 'No output from agent run', - aiMessageId, - ) - updater.setError(partial.content ?? '') + if (isFreeModeUnavailableError(output)) { + updater.setError(getFreeModeUnavailableErrorMessage(output)) + if (IS_FREEBUFF) { + markFreebuffSessionCountryBlocked( + getCountryBlockFromFreeModeError(output) ?? { + countryCode: 'UNKNOWN', + }, + ) + } + finalizeAfterError() + return + } + + const gateKind = getFreebuffGateErrorKind(output) + if (gateKind) { + handleFreebuffGateError(gateKind, updater) + finalizeAfterError() + return + } + + const freebuffRateLimitMessage = IS_FREEBUFF + ? getFreebuffRateLimitErrorMessage(output) + : null + if (freebuffRateLimitMessage) { + updater.setError(freebuffRateLimitMessage) + finalizeAfterError() + return + } + + // Pass the raw error message to setError (displayed in UserErrorBanner without additional wrapper formatting) + updater.setError(output.message ?? DEFAULT_RUN_OUTPUT_ERROR_MESSAGE) finalizeAfterError() return @@ -267,12 +436,14 @@ export const handleRunCompletion = (params: { invalidateActivityQuery(usageQueryKeys.current()) - setStreamStatus('idle') - if (resumeQueue) { - resumeQueue() - } - setCanProcessQueue(true) - updateChainInProgress(false) + finalizeQueueState({ + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + resumeQueue, + }) const timerResult = timerController.stop('success') if (agentMode === 'PLAN') { @@ -297,35 +468,38 @@ export const handleRunCompletion = (params: { export const handleRunError = (params: { error: unknown - aiMessageId: string timerController: SendMessageTimerController updater: BatchedMessageUpdater setIsRetrying: (value: boolean) => void setStreamStatus: (status: StreamStatus) => void setCanProcessQueue: (can: boolean) => void updateChainInProgress: (value: boolean) => void + isProcessingQueueRef?: MutableRefObject + isQueuePausedRef?: MutableRefObject }) => { const { error, - aiMessageId, timerController, updater, setIsRetrying, setStreamStatus, setCanProcessQueue, updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, } = params - const partial = createErrorMessage(error, aiMessageId) + const errorInfo = getErrorObject(error, { includeRawError: true }) - logger.error( - { error: getErrorObject(error, { includeRawError: true }) }, - 'SDK client.run() failed', - ) + logger.error({ error: errorInfo }, 'SDK client.run() failed') setIsRetrying(false) - setStreamStatus('idle') - setCanProcessQueue(true) - updateChainInProgress(false) + finalizeQueueState({ + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) timerController.stop('error') if (isOutOfCreditsError(error)) { @@ -335,15 +509,79 @@ export const handleRunError = (params: { return } - updater.updateAiMessage((msg) => { - const updatedContent = [msg.content, partial.content] - .filter(Boolean) - .join('\n\n') - return { - ...msg, - content: updatedContent, + if (isFreeModeUnavailableError(error)) { + updater.setError(getFreeModeUnavailableErrorMessage(error)) + if (IS_FREEBUFF) { + markFreebuffSessionCountryBlocked( + getCountryBlockFromFreeModeError(error) ?? { + countryCode: 'UNKNOWN', + }, + ) } - }) + return + } + + const gateKind = getFreebuffGateErrorKind(error) + if (gateKind) { + handleFreebuffGateError(gateKind, updater) + return + } + + const freebuffRateLimitMessage = IS_FREEBUFF + ? getFreebuffRateLimitErrorMessage(error) + : null + if (freebuffRateLimitMessage) { + updater.setError(freebuffRateLimitMessage) + return + } + + // Use setError for all errors so they display in UserErrorBanner consistently + const errorMessage = errorInfo.message || 'An unexpected error occurred' + updater.setError(errorMessage) +} - updater.markComplete() +/** + * Surface + recover from a waiting-room gate rejection. The server rejected + * the request because our seat is no longer valid; update local state so the + * UI reflects reality and we stop sending requests until we re-admit. + */ +function handleFreebuffGateError( + kind: ReturnType, + updater: BatchedMessageUpdater, +) { + switch (kind) { + case 'session_expired': + case 'waiting_room_required': + case 'session_model_mismatch': + // Our seat is gone mid-chat. Finalize the AI message so its streaming + // indicator stops — otherwise `isComplete` stays false and the message + // keeps rendering a blinking cursor forever, making the user think the + // agent is still working even though the SessionEndedBanner is visible + // and actionable. Also disposes the batched-updater flush interval. + updater.markComplete() + // Flip to `ended` instead of auto re-queuing: the Chat surface stays + // mounted so any in-flight agent work can finish under the server-side + // grace period, and the session-ended banner prompts the user to press + // Enter when they're ready to rejoin. + markFreebuffSessionEnded() + return + case 'waiting_room_queued': + updater.setError( + "You're still in the waiting room. Please wait for admission before sending messages.", + ) + // Re-sync without resetting chat — this is a "we'll wait", not a + // "let's start fresh". + refreshFreebuffSession().catch(() => {}) + return + case 'session_superseded': + updater.setError( + 'Another freebuff CLI took over this account. Close the other instance, then restart.', + ) + // Terminal state: stop polling and flip UI to a "please restart" screen + // so we don't silently fight the other instance for the seat. + markFreebuffSessionSuperseded() + return + default: + return + } } diff --git a/cli/src/hooks/use-activity-query.ts b/cli/src/hooks/use-activity-query.ts index 06db832cd6..971a9942a5 100644 --- a/cli/src/hooks/use-activity-query.ts +++ b/cli/src/hooks/use-activity-query.ts @@ -114,8 +114,20 @@ function getCacheEntry(key: string): CacheEntry | undefined { export function isEntryStale(key: string, staleTime: number): boolean { const entry = getCacheEntry(key) if (!entry) return true - if (entry.dataUpdatedAt === 0) return true - return staleTime === 0 || Date.now() - entry.dataUpdatedAt > staleTime + + // If we have successful data, use its timestamp for staleness + if (entry.dataUpdatedAt !== 0) { + return staleTime === 0 || Date.now() - entry.dataUpdatedAt > staleTime + } + + // No successful data - check if we have a recent error + // Use errorUpdatedAt to prevent rapid retries on persistent errors + if (entry.errorUpdatedAt !== null) { + return staleTime === 0 || Date.now() - entry.errorUpdatedAt > staleTime + } + + // No data and no error timestamp - entry is stale + return true } function setQueryFetching(key: string, fetching: boolean): void { @@ -170,10 +182,14 @@ function getGeneration(key: string) { return generations.get(key) ?? 0 } -function clearRetryState(key: string) { +function clearRetryTimeout(key: string) { const t = retryTimeouts.get(key) if (t) clearTimeout(t) retryTimeouts.delete(key) +} + +function clearRetryState(key: string) { + clearRetryTimeout(key) retryCounts.delete(key) } @@ -278,8 +294,6 @@ export function useActivityQuery( const error = cachedEntry?.error ?? null const dataUpdatedAt = cachedEntry?.dataUpdatedAt ?? 0 - const isStale = dataUpdatedAt === 0 || staleTime === 0 || Date.now() - dataUpdatedAt > staleTime - // Initial load = fetching with no successful data yet const isLoading = isFetching && (cachedEntry == null || dataUpdatedAt === 0) @@ -324,7 +338,10 @@ export function useActivityQuery( inFlight.delete(serializedKey) setQueryFetching(serializedKey, false) - clearRetryState(serializedKey) + // Only clear the previous timeout, NOT the retry count. + // Using clearRetryState here would reset retryCounts, causing infinite retries. + // (see: _retryTestHelpers.simulateFailedFetch mirrors this logic) + clearRetryTimeout(serializedKey) const t = setTimeout(() => { retryTimeouts.delete(serializedKey) // only retry if still mounted somewhere and key not deleted @@ -364,7 +381,6 @@ export function useActivityQuery( }, [enabled, serializedKey, retry]) const refetch = useCallback(async (): Promise => { - retryCounts.set(serializedKey, 0) clearRetryState(serializedKey) await doFetch() }, [doFetch, serializedKey]) @@ -396,11 +412,10 @@ export function useActivityQuery( if (!enabled) return const currentEntry = getCacheEntry(serializedKey) - const currentlyStale = - !currentEntry || - currentEntry.dataUpdatedAt === 0 || - staleTime === 0 || - Date.now() - currentEntry.dataUpdatedAt > staleTime + // Use isEntryStale for consistent staleness calculation that considers + // both dataUpdatedAt and errorUpdatedAt (prevents rapid refetch loops + // when endpoint returns persistent errors) + const currentlyStale = isEntryStale(serializedKey, staleTime) const shouldFetchOnMount = refetchOnMount === 'always' || @@ -564,3 +579,90 @@ export function resetActivityQueryCache(): void { snapshotMemo.clear() generations.clear() } + +/** + * Set an error-only cache entry (for testing). + * This simulates what happens when a fetch fails with no prior successful data. + */ +export function setErrorOnlyCacheEntry( + queryKey: readonly unknown[], + error: Error, + errorUpdatedAt?: number, +): void { + const key = serializeQueryKey(queryKey) + setCacheEntry(key, { + data: undefined, + dataUpdatedAt: 0, + error, + errorUpdatedAt: errorUpdatedAt ?? Date.now(), + }) +} + +/** + * Test helpers for verifying retry behavior. + * These expose internal retry state to allow unit testing the retry logic + * without needing a React renderer. + */ +export const _retryTestHelpers = { + getRetryCount(queryKey: readonly unknown[]): number { + return retryCounts.get(serializeQueryKey(queryKey)) ?? 0 + }, + setRetryCount(queryKey: readonly unknown[], count: number): void { + retryCounts.set(serializeQueryKey(queryKey), count) + }, + getRetryTimeout(queryKey: readonly unknown[]): ReturnType | undefined { + return retryTimeouts.get(serializeQueryKey(queryKey)) + }, + setRefCount(queryKey: readonly unknown[], count: number): void { + const key = serializeQueryKey(queryKey) + if (count === 0) cache.refCounts.delete(key) + else cache.refCounts.set(key, count) + }, + setFetching(queryKey: readonly unknown[], fetching: boolean): void { + setQueryFetching(serializeQueryKey(queryKey), fetching) + }, + getInFlight(queryKey: readonly unknown[]): boolean { + return inFlight.has(serializeQueryKey(queryKey)) + }, + /** + * Simulate the exact retry scheduling logic from doFetch's catch block. + * This reproduces the code path that caused the infinite retry loop bug. + * Returns whether a retry was scheduled (true) or retries were exhausted (false). + */ + simulateFailedFetch( + queryKey: readonly unknown[], + maxRetries: number, + ): { retryScheduled: boolean; retryCount: number } { + const key = serializeQueryKey(queryKey) + const currentRetries = retryCounts.get(key) ?? 0 + + if (currentRetries < maxRetries && (cache.refCounts.get(key) ?? 0) > 0) { + const next = currentRetries + 1 + retryCounts.set(key, next) + + inFlight.delete(key) + setQueryFetching(key, false) + + // This is the fixed line — uses clearRetryTimeout instead of clearRetryState + clearRetryTimeout(key) + + // Don't actually schedule a setTimeout in tests, just record the intent + return { retryScheduled: true, retryCount: next } + } + + retryCounts.set(key, 0) + + const existingEntry = getCacheEntry(key) + setCacheEntry(key, { + data: existingEntry?.data, + dataUpdatedAt: existingEntry?.dataUpdatedAt ?? 0, + error: new Error('Simulated fetch error'), + errorUpdatedAt: Date.now(), + }) + + inFlight.delete(key) + setQueryFetching(key, false) + + return { retryScheduled: false, retryCount: 0 } + }, +} diff --git a/cli/src/hooks/use-ask-user-bridge.ts b/cli/src/hooks/use-ask-user-bridge.ts index b36573765e..15ddac2eee 100644 --- a/cli/src/hooks/use-ask-user-bridge.ts +++ b/cli/src/hooks/use-ask-user-bridge.ts @@ -3,19 +3,66 @@ import { useEffect } from 'react' import { useChatStore } from '../state/chat-store' +import type { AskUserQuestion } from '../types/store' + +/** + * Patterns that indicate a "custom" or "other" catch-all option. + * These are redundant since the UI automatically provides a Custom text input. + */ +const REDUNDANT_OPTION_PATTERNS = [ + /^custom$/i, + /^other$/i, + /^none\s*(of\s*the\s*above)?$/i, + /^something\s*else$/i, + /^enter\s*(my\s*)?own$/i, + /^type\s*(my\s*)?own$/i, + /^write\s*(my\s*)?own$/i, +] + +/** + * Gets the label from an option, handling both string and object formats. + */ +function getOptionLabel(option: string | { label: string; description?: string }): string { + return typeof option === 'string' ? option : option.label +} + +/** + * Checks if an option label matches any of the redundant "custom/other" patterns. + */ +function isRedundantOption(option: string | { label: string; description?: string }): boolean { + const label = getOptionLabel(option).trim() + return REDUNDANT_OPTION_PATTERNS.some((pattern) => pattern.test(label)) +} + +/** + * Filters out redundant "Custom"/"Other" options from questions. + * The UI already provides a Custom text input, so these are unnecessary and confusing. + */ +function filterRedundantOptions(questions: AskUserQuestion[]): AskUserQuestion[] { + return questions.map((question) => { + const filteredOptions = question.options.filter((option) => !isRedundantOption(option)) + return { + ...question, + // Preserve the original array type (string[] or object[]) + options: filteredOptions as typeof question.options, + } + }) +} + export function useAskUserBridge() { const setAskUserState = useChatStore((state) => state.setAskUserState) - const setInputValue = useChatStore((state) => state.setInputValue) useEffect(() => { const unsubscribe = AskUserBridge.subscribe((request) => { if (request) { + // Filter out redundant "Custom"/"Other" options since UI provides its own + const filteredQuestions = filterRedundantOptions(request.questions) setAskUserState({ toolCallId: request.toolCallId, - questions: request.questions, + questions: filteredQuestions, // Initialize based on question type: multi-select → [], single-select → -1 - selectedAnswers: request.questions.map((q) => (q.multiSelect ? [] : -1)), - otherTexts: new Array(request.questions.length).fill(''), + selectedAnswers: filteredQuestions.map((q) => (q.multiSelect ? [] : -1)), + otherTexts: new Array(filteredQuestions.length).fill(''), }) } else { setAskUserState(null) @@ -32,14 +79,12 @@ export function useAskUserBridge() { otherText?: string }> ) => { - // Clear input value so previous prompt doesn't appear after form closes - setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + // Don't clear input value - preserve user's input from before the questionnaire AskUserBridge.submit({ answers }) } const skip = () => { - // Clear input value so previous prompt doesn't appear after form closes - setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + // Don't clear input value - preserve user's input from before the questionnaire AskUserBridge.submit({ skipped: true }) } diff --git a/cli/src/hooks/use-auth-state.ts b/cli/src/hooks/use-auth-state.ts index e800b3355f..5f5ef29d01 100644 --- a/cli/src/hooks/use-auth-state.ts +++ b/cli/src/hooks/use-auth-state.ts @@ -6,6 +6,7 @@ import { useLoginStore } from '../state/login-store' import { identifyUser, trackEvent } from '../utils/analytics' import { getUserCredentials } from '../utils/auth' import { resetCodebuffClient } from '../utils/codebuff-client' +import { IS_FREEBUFF } from '../utils/constants' import { loggerContext } from '../utils/logger' import type { MultilineInputHandle } from '../components/multiline-input' @@ -14,7 +15,7 @@ import type { User } from '../utils/auth' const setAuthLoggerContext = (params: { userId: string; email: string }) => { loggerContext.userId = params.userId loggerContext.userEmail = params.email - identifyUser(params.userId, { email: params.email }) + identifyUser(params.userId, { email: params.email, freebuff: IS_FREEBUFF }) } const clearAuthLoggerContext = () => { diff --git a/cli/src/hooks/use-chat-input.ts b/cli/src/hooks/use-chat-input.ts index 4ab7447a49..ba4234eb90 100644 --- a/cli/src/hooks/use-chat-input.ts +++ b/cli/src/hooks/use-chat-input.ts @@ -2,8 +2,9 @@ import { useCallback, useEffect, useRef } from 'react' import stringWidth from 'string-width' import { useChatStore } from '../state/chat-store' +import { IS_FREEBUFF } from '../utils/constants' -import type { InputValue } from '../state/chat-store' +import type { InputValue } from '../types/store' import type { AgentMode } from '../utils/constants' interface UseChatInputOptions { @@ -33,8 +34,9 @@ export const useChatInput = ({ const inputMode = useChatStore((state) => state.inputMode) // Estimate the collapsed toggle width as rendered by AgentModeToggle. - // In bash mode, compact height, or narrow width, we don't show the toggle, so no width needed. - const estimatedToggleWidth = inputMode !== 'default' || isCompactHeight || isNarrowWidth + // In Freebuff, the toggle is always hidden, so never reserve width for it. + // In non-Freebuff: hide in bash mode, compact height, or narrow width. + const estimatedToggleWidth = IS_FREEBUFF || inputMode !== 'default' || isCompactHeight || isNarrowWidth ? 0 : stringWidth(`< ${agentMode}`) + 6 // 2 padding + 2 borders + 2 gap @@ -71,6 +73,19 @@ export const useChatInput = ({ }, 0) }, [setAgentMode, setInputValue, onSubmitPrompt]) + const handleBuildLite = useCallback(() => { + setAgentMode('LITE') + setInputValue({ + text: BUILD_IT_TEXT, + cursorPosition: BUILD_IT_TEXT.length, + lastEditDueToNav: true, + }) + setTimeout(() => { + onSubmitPrompt(BUILD_IT_TEXT, 'LITE') + setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + }, 0) + }, [setAgentMode, setInputValue, onSubmitPrompt]) + useEffect(() => { if (initialPrompt && !hasAutoSubmittedRef.current) { hasAutoSubmittedRef.current = true @@ -86,5 +101,6 @@ export const useChatInput = ({ inputWidth, handleBuildFast, handleBuildMax, + handleBuildLite, } } diff --git a/cli/src/hooks/use-chat-keyboard.ts b/cli/src/hooks/use-chat-keyboard.ts index 26ac9ecd89..a2cc87daf9 100644 --- a/cli/src/hooks/use-chat-keyboard.ts +++ b/cli/src/hooks/use-chat-keyboard.ts @@ -1,14 +1,18 @@ +import { statSync } from 'fs' + import { useKeyboard } from '@opentui/react' import { useCallback, useRef } from 'react' -import { hasClipboardImage, readClipboardText, readClipboardImageFilePath, getImageFilePathFromText } from '../utils/clipboard-image' import { getProjectRoot } from '../project-files' import { reportActivity } from '../utils/activity-tracker' +import { hasClipboardImage, readClipboardText, readClipboardFilePath, getImageFilePathFromText } from '../utils/clipboard-image' +import { isImageFile } from '../utils/image-handler' import { resolveChatKeyboardAction, type ChatKeyboardState, type ChatKeyboardAction, } from '../utils/keyboard-actions' +import { markReturnKeySeen } from '../utils/terminal-enter-detection' import type { KeyEvent } from '@opentui/core' @@ -73,12 +77,16 @@ export type ChatKeyboardHandlers = { // Clipboard handlers onPasteImage: () => void onPasteImagePath: (imagePath: string) => void + onPasteFilePath: (filePath: string, isDirectory: boolean) => void onPasteText: (text: string) => void // Scroll handlers onScrollUp: () => void onScrollDown: () => void + // Toggle all handler + onToggleAll: () => void + // Out of credits handler onOpenBuyCredits: () => void } @@ -198,18 +206,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) @@ -239,6 +258,9 @@ function dispatchAction( case 'scroll-down': handlers.onScrollDown() return true + case 'toggle-all': + handlers.onToggleAll() + return true case 'open-buy-credits': handlers.onOpenBuyCredits() return true @@ -254,7 +276,7 @@ function dispatchAction( * Integrates priority-based action resolution with handlers. * * This hook handles: - * - Mode switching (bash, referral, etc.) + * - Mode switching (bash, etc.) * - Stream interruption * - Suggestion menu navigation (slash and mention menus) * - History navigation @@ -283,6 +305,10 @@ export function useChatKeyboard({ reportActivity() } + if (key.name === 'return' || key.name === 'enter') { + markReturnKeySeen() + } + const action = resolveChatKeyboardAction(key, state) const handled = dispatchAction(action, handlers) diff --git a/cli/src/hooks/use-chat-messages.ts b/cli/src/hooks/use-chat-messages.ts new file mode 100644 index 0000000000..bfb002fa5b --- /dev/null +++ b/cli/src/hooks/use-chat-messages.ts @@ -0,0 +1,254 @@ +/** + * Extracted chat messages hook. + * Handles message tree building, pagination, and collapse state management. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { setAllBlocksCollapsedState, hasAnyExpandedBlocks } from '../utils/collapse-helpers' +import { buildMessageTree } from '../utils/message-tree-utils' + +import type { ChatMessage, ContentBlock } from '../types/chat' + +/** Batch size for message pagination */ +const MESSAGE_BATCH_SIZE = 15 + +/** + * Options for useChatMessages hook. + */ +export interface UseChatMessagesOptions { + /** Current messages array from store */ + messages: ChatMessage[] + /** Setter for messages */ + setMessages: ( + value: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[]), + ) => void +} + +/** + * Return type for useChatMessages hook. + */ +export interface UseChatMessagesReturn { + /** Map of parent ID to child messages */ + messageTree: Map + /** Messages without a parent (root level) */ + topLevelMessages: ChatMessage[] + /** Paginated visible messages from top level */ + visibleTopLevelMessages: ChatMessage[] + /** Count of hidden messages due to pagination */ + hiddenMessageCount: number + /** Handler to toggle collapsed state of a block */ + handleCollapseToggle: (id: string) => void + /** Returns true if user is currently collapsing (to prevent auto-scroll) */ + isUserCollapsing: () => boolean + /** Handler to load more previous messages */ + handleLoadPreviousMessages: () => void + /** Handler to toggle all collapsed/expanded state in all AI responses */ + handleToggleAll: () => void +} + +/** + * Custom hook that encapsulates message handling logic. + * Extracts message tree building, pagination, and collapse management. + * + * @param options - Messages array and setter from store + * @returns Message tree, pagination state, and handlers + */ +export function useChatMessages({ + messages, + setMessages, +}: UseChatMessagesOptions): UseChatMessagesReturn { + // Message pagination state + const [visibleMessageCount, setVisibleMessageCount] = + useState(MESSAGE_BATCH_SIZE) + + // Reset visible message count when messages are cleared or conversation changes + useEffect(() => { + if (messages.length <= MESSAGE_BATCH_SIZE) { + setVisibleMessageCount(MESSAGE_BATCH_SIZE) + } + }, [messages.length]) + + // Ref to track user-initiated collapse (prevents auto-scroll during collapse) + const isUserCollapsingRef = useRef(false) + + /** + * Returns true if user is currently collapsing. + * Used by scroll management to prevent auto-scroll during collapse. + */ + const isUserCollapsing = useCallback(() => { + return isUserCollapsingRef.current + }, []) + + /** + * Toggles the collapsed state of a block or agent message. + * Handles both top-level agent messages and nested content blocks. + */ + const handleCollapseToggle = useCallback( + (id: string) => { + // Set flag to prevent auto-scroll during user-initiated collapse + isUserCollapsingRef.current = true + + // Find and toggle the block's isCollapsed property + setMessages((prevMessages) => { + return prevMessages.map((message) => { + // Handle agent variant messages + if (message.variant === 'agent' && message.id === id) { + const wasCollapsed = message.metadata?.isCollapsed ?? false + return { + ...message, + metadata: { + ...message.metadata, + isCollapsed: !wasCollapsed, + userOpened: wasCollapsed, // Mark as user-opened if expanding + }, + } + } + + // Handle blocks within messages + if (!message.blocks) return message + + const updateBlocksRecursively = ( + blocks: ContentBlock[], + ): ContentBlock[] => { + let foundTarget = false + const result = blocks.map((block) => { + // Handle thinking blocks - just match by thinkingId + if (block.type === 'text' && block.thinkingId === id) { + foundTarget = true + const isExpanded = block.thinkingCollapseState === 'expanded' + return { + ...block, + thinkingCollapseState: isExpanded ? 'preview' as const : 'expanded' as const, + userOpened: !isExpanded, // Mark as user-opened if expanding + } + } + + // Handle agent blocks + if (block.type === 'agent' && block.agentId === id) { + foundTarget = true + const wasCollapsed = block.isCollapsed ?? false + return { + ...block, + isCollapsed: !wasCollapsed, + userOpened: wasCollapsed, // Mark as user-opened if expanding + } + } + + // Handle tool blocks + if (block.type === 'tool' && block.toolCallId === id) { + foundTarget = true + const wasCollapsed = block.isCollapsed ?? false + return { + ...block, + isCollapsed: !wasCollapsed, + userOpened: wasCollapsed, // Mark as user-opened if expanding + } + } + + // Handle agent-list blocks + if (block.type === 'agent-list' && block.id === id) { + foundTarget = true + const wasCollapsed = block.isCollapsed ?? false + return { + ...block, + isCollapsed: !wasCollapsed, + userOpened: wasCollapsed, // Mark as user-opened if expanding + } + } + + // Recursively update nested blocks inside agent blocks + if (block.type === 'agent' && block.blocks) { + const updatedBlocks = updateBlocksRecursively(block.blocks) + // Only create new block if nested blocks actually changed + if (updatedBlocks !== block.blocks) { + foundTarget = true + return { + ...block, + blocks: updatedBlocks, + } + } + } + + return block + }) + + // Return original array reference if nothing changed + return foundTarget ? result : blocks + } + + return { + ...message, + blocks: updateBlocksRecursively(message.blocks), + } + }) + }) + + // Reset flag after state update completes. + // Uses setTimeout(0) to defer until after React's batched state updates + // have been applied, ensuring the flag stays true during the render cycle. + setTimeout(() => { + isUserCollapsingRef.current = false + }, 0) + }, + [setMessages], + ) + + /** + * Loads more previous messages by increasing the visible count. + */ + const handleLoadPreviousMessages = useCallback(() => { + setVisibleMessageCount((prev) => prev + MESSAGE_BATCH_SIZE) + }, []) + + /** + * Toggles all collapsible blocks in all AI responses. + * Primary action is to collapse all. Only expands if everything is already collapsed. + */ + const handleToggleAll = useCallback(() => { + isUserCollapsingRef.current = true + + setMessages((prevMessages) => { + // Primary action: collapse all open blocks + // Only expand if everything is already collapsed + const allCollapsed = !hasAnyExpandedBlocks(prevMessages) + const shouldCollapse = !allCollapsed + return setAllBlocksCollapsedState(prevMessages, shouldCollapse) + }) + + // Reset flag after state update completes. + // Uses setTimeout(0) to defer until after React's batched state updates + // have been applied, ensuring the flag stays true during the render cycle. + setTimeout(() => { + isUserCollapsingRef.current = false + }, 0) + }, [setMessages]) + + // Build message tree from flat messages array + const { tree: messageTree, topLevelMessages } = useMemo( + () => buildMessageTree(messages), + [messages], + ) + + // Compute visible messages slice (from the end) + const visibleTopLevelMessages = useMemo(() => { + if (topLevelMessages.length <= visibleMessageCount) { + return topLevelMessages + } + return topLevelMessages.slice(-visibleMessageCount) + }, [topLevelMessages, visibleMessageCount]) + + const hiddenMessageCount = + topLevelMessages.length - visibleTopLevelMessages.length + + return { + messageTree, + topLevelMessages, + visibleTopLevelMessages, + hiddenMessageCount, + handleCollapseToggle, + isUserCollapsing, + handleLoadPreviousMessages, + handleToggleAll, + } +} diff --git a/cli/src/hooks/use-chat-state.ts b/cli/src/hooks/use-chat-state.ts new file mode 100644 index 0000000000..7fb8625e0d --- /dev/null +++ b/cli/src/hooks/use-chat-state.ts @@ -0,0 +1,218 @@ +/** + * Extracted chat state management hook. + * Encapsulates Zustand store subscriptions, refs, and derived state. + */ + +import { useEffect, useMemo, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' + +import { useChatStore } from '../state/chat-store' + +import type { InputValue, PendingBashMessage } from '../types/store' +import type { ChatMessage } from '../types/chat' +import type { SendMessageFn } from '../types/contracts/send-message' +import type { AgentMode } from '../utils/constants' +import type { MutableRefObject } from 'react' + +/** + * Ref objects used to track state across renders. + * These maintain values that need to be accessed in callbacks without + * causing re-renders. + */ +export interface ChatStateRefs { + /** Tracks number of active agent streams */ + activeAgentStreamsRef: MutableRefObject + /** Tracks whether a chain of operations is in progress */ + isChainInProgressRef: MutableRefObject + /** Tracks set of active subagent IDs */ + activeSubagentsRef: MutableRefObject> + /** AbortController for canceling requests */ + abortControllerRef: MutableRefObject + /** Reference to sendMessage function for use in callbacks */ + sendMessageRef: MutableRefObject +} + +/** + * Return type for useChatState hook. + */ +export interface UseChatStateReturn { + // Input state + inputValue: string + cursorPosition: number + lastEditDueToNav: boolean + setInputValue: (value: InputValue | ((prev: InputValue) => InputValue)) => void + inputFocused: boolean + setInputFocused: (focused: boolean) => void + + // Suggestion menu state + slashSelectedIndex: number + setSlashSelectedIndex: (value: number | ((prev: number) => number)) => void + agentSelectedIndex: number + setAgentSelectedIndex: (value: number | ((prev: number) => number)) => void + + // Streaming/agent state (stabilized) + streamingAgents: Set + focusedAgentId: string | null + setFocusedAgentId: ( + value: string | null | ((prev: string | null) => string | null), + ) => void + activeSubagents: Set + isChainInProgress: boolean + + // Messages + messages: ChatMessage[] + setMessages: ( + value: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[]), + ) => void + + // Mode + agentMode: AgentMode + setAgentMode: (mode: AgentMode) => void + toggleAgentMode: () => void + + // Retry state + isRetrying: boolean + + // Pending bash messages + pendingBashMessages: PendingBashMessage[] + + // Refs + refs: ChatStateRefs +} + +/** + * Custom hook that encapsulates chat state management. + * Extracts state selectors, refs, and derived values from the main Chat component. + * + * @returns Chat state values, setters, refs + */ +export function useChatState(): UseChatStateReturn { + // Main store selector - uses useShallow to prevent unnecessary re-renders + const { + inputValue, + cursorPosition, + lastEditDueToNav, + setInputValue, + inputFocused, + setInputFocused, + slashSelectedIndex, + setSlashSelectedIndex, + agentSelectedIndex, + setAgentSelectedIndex, + streamingAgents: rawStreamingAgents, + focusedAgentId, + setFocusedAgentId, + messages, + setMessages, + activeSubagents, + isChainInProgress, + agentMode, + setAgentMode, + toggleAgentMode, + isRetrying, + } = useChatStore( + useShallow((store) => ({ + inputValue: store.inputValue, + cursorPosition: store.cursorPosition, + lastEditDueToNav: store.lastEditDueToNav, + setInputValue: store.setInputValue, + inputFocused: store.inputFocused, + setInputFocused: store.setInputFocused, + slashSelectedIndex: store.slashSelectedIndex, + setSlashSelectedIndex: store.setSlashSelectedIndex, + agentSelectedIndex: store.agentSelectedIndex, + setAgentSelectedIndex: store.setAgentSelectedIndex, + streamingAgents: store.streamingAgents, + focusedAgentId: store.focusedAgentId, + setFocusedAgentId: store.setFocusedAgentId, + messages: store.messages, + setMessages: store.setMessages, + activeSubagents: store.activeSubagents, + isChainInProgress: store.isChainInProgress, + agentMode: store.agentMode, + setAgentMode: store.setAgentMode, + toggleAgentMode: store.toggleAgentMode, + isRetrying: store.isRetrying, + })), + ) + + // Additional selector for pending bash messages (separate for performance) + const pendingBashMessages = useChatStore((state) => state.pendingBashMessages) + + // Stabilize streamingAgents reference - only create new Set when content changes + const streamingAgentsKey = useMemo( + () => Array.from(rawStreamingAgents).sort().join(','), + [rawStreamingAgents], + ) + const streamingAgents = useMemo( + () => rawStreamingAgents, + // eslint-disable-next-line react-hooks/exhaustive-deps + [streamingAgentsKey], + ) + + // Refs for tracking state across renders + const activeAgentStreamsRef = useRef(0) + const isChainInProgressRef = useRef(isChainInProgress) + const activeSubagentsRef = useRef>(activeSubagents) + const abortControllerRef = useRef(null) + const sendMessageRef = useRef(undefined) + + // Sync refs with state + useEffect(() => { + isChainInProgressRef.current = isChainInProgress + }, [isChainInProgress]) + + useEffect(() => { + activeSubagentsRef.current = activeSubagents + }, [activeSubagents]) + + // Assemble refs object + const refs: ChatStateRefs = { + activeAgentStreamsRef, + isChainInProgressRef, + activeSubagentsRef, + abortControllerRef, + sendMessageRef, + } + + return { + // Input state + inputValue, + cursorPosition, + lastEditDueToNav, + setInputValue, + inputFocused, + setInputFocused, + + // Suggestion menu state + slashSelectedIndex, + setSlashSelectedIndex, + agentSelectedIndex, + setAgentSelectedIndex, + + // Streaming/agent state (stabilized) + streamingAgents, + focusedAgentId, + setFocusedAgentId, + activeSubagents, + isChainInProgress, + + // Messages + messages, + setMessages, + + // Mode + agentMode, + setAgentMode, + toggleAgentMode, + + // Retry state + isRetrying, + + // Pending bash messages + pendingBashMessages, + + // Refs + refs, + } +} diff --git a/cli/src/hooks/use-chat-streaming.ts b/cli/src/hooks/use-chat-streaming.ts new file mode 100644 index 0000000000..b2d2fd5240 --- /dev/null +++ b/cli/src/hooks/use-chat-streaming.ts @@ -0,0 +1,235 @@ +/** + * Chat streaming hook - connection status, timer, queue management, and exit handling. + */ + +import { RECONNECTION_MESSAGE_DURATION_MS } from '@codebuff/sdk' +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect, useState, useTransition } from 'react' + + +import { authQueryKeys } from './use-auth-query' +import { useConnectionStatus } from './use-connection-status' +import { useElapsedTime } from './use-elapsed-time' +import { useExitHandler } from './use-exit-handler' +import { useMessageQueue, type QueuedMessage, type StreamStatus } from './use-message-queue' +import { useQueueControls } from './use-queue-controls' +import { useQueueUi } from './use-queue-ui' +import { useTimeout } from './use-timeout' +import { useChatStore } from '../state/chat-store' + +import type { ElapsedTimeTracker } from './use-elapsed-time' +import type { PendingAttachment } from '../types/store' +import type { SendMessageFn } from '../types/contracts/send-message' +import type { AgentMode } from '../utils/constants' +import type { MutableRefObject } from 'react' + +export interface UseChatStreamingOptions { + agentMode: AgentMode + inputValue: string + setInputValue: (value: { text: string; cursorPosition: number; lastEditDueToNav: boolean }) => void + terminalWidth: number + separatorWidth: number + isChainInProgressRef: MutableRefObject + activeAgentStreamsRef: MutableRefObject + sendMessageRef: MutableRefObject +} + +export interface UseChatStreamingReturn { + // Connection state + isConnected: boolean + showReconnectionMessage: boolean + + // Timer + mainAgentTimer: ElapsedTimeTracker + timerStartTime: number | null + + // Stream status + streamStatus: StreamStatus + isWaitingForResponse: boolean + isStreaming: boolean + setStreamStatus: (status: StreamStatus) => void + + // Queue management + queuedMessages: QueuedMessage[] + queuePaused: boolean + streamMessageIdRef: MutableRefObject + addToQueue: (message: string, attachments?: PendingAttachment[]) => void + stopStreaming: () => void + setCanProcessQueue: (value: boolean | ((prev: boolean) => boolean)) => void + pauseQueue: () => void + resumeQueue: () => void + clearQueue: () => QueuedMessage[] + isQueuePausedRef: MutableRefObject + isProcessingQueueRef: MutableRefObject + + // Queue UI + queuedCount: number + shouldShowQueuePreview: boolean + queuePreviewTitle: string | undefined + pausedQueueText: string | undefined + inputPlaceholder: string + + // Exit handling + handleCtrlC: () => true + ensureQueueActiveBeforeSubmit: () => boolean + nextCtrlCWillExit: boolean +} + +export function useChatStreaming({ + agentMode, + inputValue, + setInputValue, + terminalWidth, + separatorWidth, + isChainInProgressRef, + activeAgentStreamsRef, + sendMessageRef, +}: UseChatStreamingOptions): UseChatStreamingReturn { + const queryClient = useQueryClient() + const [, startUiTransition] = useTransition() + + // Reconnection state + const [showReconnectionMessage, setShowReconnectionMessage] = useState(false) + const reconnectionTimeout = useTimeout() + + // Reconnection handler + const handleReconnection = useCallback( + (isInitialConnection: boolean) => { + queryClient.invalidateQueries({ queryKey: authQueryKeys.all }) + + startUiTransition(() => { + if (!isInitialConnection) { + setShowReconnectionMessage(true) + reconnectionTimeout.setTimeout( + 'reconnection-message', + () => { + startUiTransition(() => { + setShowReconnectionMessage(false) + }) + }, + RECONNECTION_MESSAGE_DURATION_MS, + ) + } + }) + }, + [queryClient, reconnectionTimeout, startUiTransition], + ) + + // Connection status + const isConnected = useConnectionStatus(handleReconnection) + + // Timer + const mainAgentTimer = useElapsedTime() + const timerStartTime = mainAgentTimer.startTime + + // Pause/resume timer when ask_user tool becomes active/inactive + const askUserState = useChatStore((state) => state.askUserState) + useEffect(() => { + if (askUserState !== null) { + mainAgentTimer.pause() + } else if (mainAgentTimer.isPaused) { + mainAgentTimer.resume() + } + }, [askUserState, mainAgentTimer]) + + // Message queue + const { + queuedMessages, + streamStatus, + queuePaused, + streamMessageIdRef, + addToQueue, + stopStreaming, + setStreamStatus, + setCanProcessQueue, + pauseQueue, + resumeQueue, + clearQueue, + isQueuePausedRef, + isProcessingQueueRef, + } = useMessageQueue( + (message: QueuedMessage) => + sendMessageRef.current?.({ + content: message.content, + agentMode, + attachments: message.attachments, + }) ?? Promise.resolve(), + isChainInProgressRef, + activeAgentStreamsRef, + ) + + // Queue UI + const { + queuedCount, + shouldShowQueuePreview, + queuePreviewTitle, + pausedQueueText, + inputPlaceholder, + } = useQueueUi({ + queuePaused, + queuedMessages, + separatorWidth, + terminalWidth, + }) + + // Exit handling + const { handleCtrlC: baseHandleCtrlC, nextCtrlCWillExit } = useExitHandler({ + inputValue, + setInputValue, + }) + + // Queue controls + const { handleCtrlC, ensureQueueActiveBeforeSubmit } = useQueueControls({ + queuePaused, + queuedCount, + clearQueue, + resumeQueue, + inputHasText: Boolean(inputValue), + baseHandleCtrlC, + }) + + // Derived flags + const isWaitingForResponse = streamStatus === 'waiting' + const isStreaming = streamStatus !== 'idle' + + return { + // Connection state + isConnected, + showReconnectionMessage, + + // Timer + mainAgentTimer, + timerStartTime, + + // Stream status + streamStatus, + isWaitingForResponse, + isStreaming, + setStreamStatus, + + // Queue management + queuedMessages, + queuePaused, + streamMessageIdRef, + addToQueue, + stopStreaming, + setCanProcessQueue, + pauseQueue, + resumeQueue, + clearQueue, + isQueuePausedRef, + isProcessingQueueRef, + + // Queue UI + queuedCount, + shouldShowQueuePreview, + queuePreviewTitle, + pausedQueueText, + inputPlaceholder, + + // Exit handling + handleCtrlC, + ensureQueueActiveBeforeSubmit, + nextCtrlCWillExit, + } +} diff --git a/cli/src/hooks/use-chat-ui.ts b/cli/src/hooks/use-chat-ui.ts new file mode 100644 index 0000000000..1223067e35 --- /dev/null +++ b/cli/src/hooks/use-chat-ui.ts @@ -0,0 +1,131 @@ +/** + * Chat UI hook - scroll behavior, terminal dimensions, and theme. + */ + +import { useEffect, useMemo, useRef, useState } from 'react' + +import { useChatScrollbox } from './use-scroll-management' +import { useTerminalDimensions } from './use-terminal-dimensions' +import { useTerminalLayout } from './use-terminal-layout' +import { useTheme } from './use-theme' +import { createChatScrollAcceleration } from '../utils/chat-scroll-accel' +import { createMarkdownPalette } from '../utils/theme-system' + +import type { ChatMessage } from '../types/chat' +import type { ChatTheme } from '../types/theme-system' +import type { MarkdownPalette } from '../utils/markdown-renderer' +import type { ScrollBoxRenderable } from '@opentui/core' + +export interface UseChatUIOptions { + messages: ChatMessage[] + isUserCollapsing: () => boolean +} + +export interface UseChatUIReturn { + // Scroll management + scrollRef: React.RefObject + scrollToLatest: () => void + scrollUp: () => void + scrollDown: () => void + appliedScrollboxProps: Record + isAtBottom: boolean + hasOverflow: boolean + + // Terminal dimensions + terminalWidth: number + terminalHeight: number + separatorWidth: number + messageAvailableWidth: number + isCompactHeight: boolean + isNarrowWidth: boolean + + // Theme + theme: ChatTheme + markdownPalette: MarkdownPalette +} + +export function useChatUI({ + messages, + isUserCollapsing, +}: UseChatUIOptions): UseChatUIReturn { + const scrollRef = useRef(null) + const [hasOverflow, setHasOverflow] = useState(false) + const hasOverflowRef = useRef(false) + + // Terminal dimensions + const { separatorWidth, terminalWidth, terminalHeight } = + useTerminalDimensions() + const { height: heightLayout, width: widthLayout } = useTerminalLayout() + const isCompactHeight = heightLayout.is('xs') + const isNarrowWidth = widthLayout.is('xs') + const messageAvailableWidth = separatorWidth + + // Theme + const theme = useTheme() + const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme]) + + // Scroll management + const { scrollToLatest, scrollUp, scrollDown, scrollboxProps, isAtBottom } = + useChatScrollbox(scrollRef, messages, isUserCollapsing) + + // Check if content has overflowed and needs scrolling + useEffect(() => { + const scrollbox = scrollRef.current + if (!scrollbox) return + + const checkOverflow = () => { + const contentHeight = scrollbox.scrollHeight + const viewportHeight = scrollbox.viewport.height + const isOverflowing = contentHeight > viewportHeight + + if (hasOverflowRef.current !== isOverflowing) { + hasOverflowRef.current = isOverflowing + setHasOverflow(isOverflowing) + } + } + + checkOverflow() + scrollbox.verticalScrollBar.on('change', checkOverflow) + + return () => { + scrollbox.verticalScrollBar.off('change', checkOverflow) + } + }, []) + + // Inertial scroll acceleration + const inertialScrollAcceleration = useMemo( + () => createChatScrollAcceleration(), + [], + ) + + const appliedScrollboxProps = useMemo( + () => + inertialScrollAcceleration + ? { ...scrollboxProps, scrollAcceleration: inertialScrollAcceleration } + : scrollboxProps, + [scrollboxProps, inertialScrollAcceleration], + ) + + return { + // Scroll management + scrollRef, + scrollToLatest, + scrollUp, + scrollDown, + appliedScrollboxProps, + isAtBottom, + hasOverflow, + + // Terminal dimensions + terminalWidth, + terminalHeight, + separatorWidth, + messageAvailableWidth, + isCompactHeight, + isNarrowWidth, + + // Theme + theme, + markdownPalette, + } +} diff --git a/cli/src/hooks/use-claude-quota-query.ts b/cli/src/hooks/use-claude-quota-query.ts deleted file mode 100644 index 2834b5ee3e..0000000000 --- a/cli/src/hooks/use-claude-quota-query.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { getClaudeOAuthCredentials, isClaudeOAuthValid } from '@codebuff/sdk' - -import { useActivityQuery } from './use-activity-query' -import { logger as defaultLogger } from '../utils/logger' - -import type { Logger } from '@codebuff/common/types/contracts/logger' - -// Query keys for type-safe cache management -export const claudeQuotaQueryKeys = { - all: ['claude-quota'] as const, - current: () => [...claudeQuotaQueryKeys.all, 'current'] as const, -} - -/** - * Response from Anthropic OAuth usage endpoint - */ -export interface ClaudeQuotaWindow { - utilization: number // Percentage used (0-100) - resets_at: string | null // ISO timestamp when quota resets -} - -export interface ClaudeQuotaResponse { - five_hour: ClaudeQuotaWindow | null - seven_day: ClaudeQuotaWindow | null - seven_day_oauth_apps: ClaudeQuotaWindow | null - seven_day_opus: ClaudeQuotaWindow | null -} - -/** - * Parsed quota data for display - */ -export interface ClaudeQuotaData { - /** Remaining percentage for the 5-hour window (0-100) */ - fiveHourRemaining: number - /** When the 5-hour quota resets */ - fiveHourResetsAt: Date | null - /** Remaining percentage for the 7-day window (0-100) */ - sevenDayRemaining: number - /** When the 7-day quota resets */ - sevenDayResetsAt: Date | null -} - -/** - * Fetches Claude OAuth usage data from Anthropic API - */ -export async function fetchClaudeQuota( - accessToken: string, - logger: Logger = defaultLogger, -): Promise { - const response = await fetch('https://api.anthropic.com/api/oauth/usage', { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - // Required beta headers for OAuth endpoints (same as model requests) - 'anthropic-version': '2023-06-01', - 'anthropic-beta': 'oauth-2025-04-20,claude-code-20250219', - }, - }) - - if (!response.ok) { - logger.debug( - { status: response.status }, - 'Failed to fetch Claude quota data', - ) - throw new Error(`Failed to fetch Claude quota: ${response.status}`) - } - - const responseBody = await response.json() - const data = responseBody as ClaudeQuotaResponse - - // Parse the response into a more usable format - const fiveHour = data.five_hour - const sevenDay = data.seven_day - - return { - fiveHourRemaining: fiveHour ? Math.max(0, 100 - fiveHour.utilization) : 100, - fiveHourResetsAt: fiveHour?.resets_at ? new Date(fiveHour.resets_at) : null, - sevenDayRemaining: sevenDay ? Math.max(0, 100 - sevenDay.utilization) : 100, - sevenDayResetsAt: sevenDay?.resets_at ? new Date(sevenDay.resets_at) : null, - } -} - -export interface UseClaudeQuotaQueryDeps { - logger?: Logger - enabled?: boolean - /** Refetch interval in milliseconds */ - refetchInterval?: number | false - /** Refetch stale data when user becomes active after being idle */ - refetchOnActivity?: boolean - /** Pause polling when user is idle */ - pauseWhenIdle?: boolean - /** Time in ms to consider user idle (default: 30 seconds) */ - idleThreshold?: number -} - -/** - * Hook to fetch Claude OAuth quota data from Anthropic API - * Only fetches when Claude OAuth is connected and valid - * Uses the activity-aware query hook for terminal-specific optimizations - */ -export function useClaudeQuotaQuery(deps: UseClaudeQuotaQueryDeps = {}) { - const { - logger = defaultLogger, - enabled = true, - refetchInterval = 60 * 1000, - refetchOnActivity = true, - pauseWhenIdle = true, - idleThreshold = 30_000, - } = deps - - const isConnected = isClaudeOAuthValid() - - return useActivityQuery({ - queryKey: claudeQuotaQueryKeys.current(), - queryFn: () => { - // Get credentials inside queryFn to avoid stale closures - const credentials = getClaudeOAuthCredentials() - if (!credentials?.accessToken) { - throw new Error('No Claude OAuth credentials') - } - return fetchClaudeQuota(credentials.accessToken, logger) - }, - enabled: enabled && isConnected, - staleTime: 30 * 1000, // Consider data stale after 30 seconds - gcTime: 5 * 60 * 1000, // 5 minutes - retry: 1, // Only retry once on failure - refetchOnMount: true, - refetchInterval, - refetchOnActivity, - pauseWhenIdle, - idleThreshold, - }) -} diff --git a/cli/src/hooks/use-clipboard.ts b/cli/src/hooks/use-clipboard.ts index 38505be1db..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 { @@ -19,6 +21,7 @@ function formatDefaultClipboardMessage(text: string): string | null { export const useClipboard = () => { const renderer = useRenderer() const [statusMessage, setStatusMessage] = useState(null) + const [hasSelection, setHasSelection] = useState(false) const pendingCopyTimeoutRef = useRef | null>( null, ) @@ -29,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?.() @@ -43,6 +58,7 @@ export const useClipboard = () => { if (!cleanedText || cleanedText.trim().length === 0) { pendingSelectionRef.current = null + setHasSelection(false) if (pendingCopyTimeoutRef.current) { clearTimeout(pendingCopyTimeoutRef.current) pendingCopyTimeoutRef.current = null @@ -54,6 +70,9 @@ export const useClipboard = () => { return } + // Track that there's an active selection for visual feedback + setHasSelection(true) + pendingSelectionRef.current = cleanedText if (pendingCopyTimeoutRef.current) { @@ -72,9 +91,14 @@ export const useClipboard = () => { void copyTextToClipboard(pending, { successMessage, durationMs: 3000, - }).catch(() => { - // Errors are logged within copyTextToClipboard }) + .then(() => { + // Clear selection visual state after successful copy + setHasSelection(false) + }) + .catch(() => { + // Errors are logged within copyTextToClipboard + }) }, 250) } @@ -98,5 +122,6 @@ export const useClipboard = () => { return { statusMessage, + hasSelection, } } diff --git a/cli/src/hooks/use-connection-status.ts b/cli/src/hooks/use-connection-status.ts index d12b0887a0..41ad093867 100644 --- a/cli/src/hooks/use-connection-status.ts +++ b/cli/src/hooks/use-connection-status.ts @@ -100,16 +100,7 @@ export const useConnectionStatus = ( consecutiveSuccesses++ const newInterval = getNextInterval(consecutiveSuccesses) - // Log when interval changes if (newInterval !== currentInterval) { - logger.debug( - { - consecutiveSuccesses, - oldInterval: currentInterval, - newInterval, - }, - 'Health check interval increased', - ) currentInterval = newInterval } diff --git a/cli/src/hooks/use-exit-handler.ts b/cli/src/hooks/use-exit-handler.ts index 3bd02a7c5e..e0ab54ff0a 100644 --- a/cli/src/hooks/use-exit-handler.ts +++ b/cli/src/hooks/use-exit-handler.ts @@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { getCurrentChatId } from '../project-files' import { flushAnalytics } from '../utils/analytics' +import { IS_FREEBUFF } from '../utils/constants' +import { exitFreebuffCleanly } from '../utils/freebuff-exit' import { withTimeout } from '../utils/terminal-color-detection' -import type { InputValue } from '../state/chat-store' +import type { InputValue } from '../types/store' // Timeout for analytics flush during exit - don't block exit for too long const EXIT_FLUSH_TIMEOUT_MS = 1000 @@ -26,8 +28,9 @@ function setupExitMessageHandler() { if (chatId) { // This runs synchronously during the exit phase // OpenTUI has already cleaned up by this point + const cliName = IS_FREEBUFF ? 'freebuff' : 'codebuff' process.stdout.write( - `\nTo continue this session later, run:\ncodebuff --continue ${chatId}\n`, + `\nTo continue this session later, run:\n${cliName} --continue ${chatId}\n`, ) } } catch { @@ -36,6 +39,19 @@ function setupExitMessageHandler() { }) } +function exitCli(): void { + if (IS_FREEBUFF) { + void exitFreebuffCleanly() + return + } + + withTimeout(flushAnalytics(), EXIT_FLUSH_TIMEOUT_MS, undefined).finally( + () => { + process.exit(0) + }, + ) +} + export const useExitHandler = ({ inputValue, setInputValue, @@ -68,9 +84,7 @@ export const useExitHandler = ({ exitWarningTimeoutRef.current = null } - withTimeout(flushAnalytics(), EXIT_FLUSH_TIMEOUT_MS, undefined).then(() => { - process.exit(0) - }) + exitCli() return true }, [inputValue, setInputValue, nextCtrlCWillExit]) @@ -81,11 +95,7 @@ export const useExitHandler = ({ exitWarningTimeoutRef.current = null } - withTimeout(flushAnalytics(), EXIT_FLUSH_TIMEOUT_MS, undefined).finally( - () => { - process.exit(0) - }, - ) + exitCli() } process.on('SIGINT', handleSigint) diff --git a/cli/src/hooks/use-fetch-login-url.ts b/cli/src/hooks/use-fetch-login-url.ts index e9135b7213..dfcecde283 100644 --- a/cli/src/hooks/use-fetch-login-url.ts +++ b/cli/src/hooks/use-fetch-login-url.ts @@ -1,7 +1,7 @@ import { useMutation } from '@tanstack/react-query' -import open from 'open' +import { safeOpen } from '../utils/open-url' -import { WEBSITE_URL } from '../login/constants' +import { LOGIN_WEBSITE_URL } from '../login/constants' import { generateLoginUrl } from '../login/login-flow' import { logger } from '../utils/logger' @@ -32,7 +32,7 @@ export function useFetchLoginUrl({ logger, }, { - baseUrl: WEBSITE_URL, + baseUrl: LOGIN_WEBSITE_URL, fingerprintId, }, ) @@ -45,12 +45,7 @@ export function useFetchLoginUrl({ setHasOpenedBrowser(true) // Open browser after fetching URL - try { - await open(data.loginUrl) - } catch (err) { - logger.error(err, 'Failed to open browser') - // Don't show error, user can still click the URL - } + await safeOpen(data.loginUrl) }, onError: (err) => { setError(err instanceof Error ? err.message : 'Failed to get login URL') diff --git a/cli/src/hooks/use-fingerprint.ts b/cli/src/hooks/use-fingerprint.ts new file mode 100644 index 0000000000..518e5d6fec --- /dev/null +++ b/cli/src/hooks/use-fingerprint.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react' + +import { calculateFingerprint, generateFingerprintIdSync } from '../utils/fingerprint' +import { logger } from '../utils/logger' + +interface UseFingerprintResult { + fingerprintId: string + isEnhanced: boolean + isLoading: boolean +} + +/** + * React hook for generating a hardware-based fingerprint. + * + * Immediately provides a legacy fingerprint for responsiveness, + * then asynchronously generates an enhanced fingerprint if possible. + * + * The fingerprint is stable across re-renders (generated once on mount). + */ +export function useFingerprint(): UseFingerprintResult { + // Start with a sync legacy fingerprint for immediate availability + const [state, setState] = useState(() => ({ + fingerprintId: generateFingerprintIdSync(), + isEnhanced: false, + isLoading: true, + })) + + useEffect(() => { + let cancelled = false + + const generateEnhanced = async () => { + try { + const enhancedFingerprint = await calculateFingerprint() + if (!cancelled) { + setState({ + fingerprintId: enhancedFingerprint, + isEnhanced: enhancedFingerprint.startsWith('enhanced-'), + isLoading: false, + }) + } + } catch (error) { + logger.error(error, 'Failed to generate enhanced fingerprint') + if (!cancelled) { + // Keep the legacy fingerprint we already have + setState((prev) => ({ + ...prev, + isLoading: false, + })) + } + } + } + + generateEnhanced() + + return () => { + cancelled = true + } + }, []) + + return state +} diff --git a/cli/src/hooks/use-freebuff-ctrl-c-exit.ts b/cli/src/hooks/use-freebuff-ctrl-c-exit.ts new file mode 100644 index 0000000000..84dcb00bad --- /dev/null +++ b/cli/src/hooks/use-freebuff-ctrl-c-exit.ts @@ -0,0 +1,23 @@ +import { useKeyboard } from '@opentui/react' +import { useCallback } from 'react' + +import { exitFreebuffCleanly } from '../utils/freebuff-exit' + +import type { KeyEvent } from '@opentui/core' + +/** + * Bind Ctrl+C on a full-screen freebuff view to `exitFreebuffCleanly`. Stdin + * is in raw mode, so SIGINT never fires — the key arrives as a normal OpenTUI + * key event and we route it through the shared cleanup path (flush analytics, + * release the session seat, then process.exit). + */ +export function useFreebuffCtrlCExit(): void { + useKeyboard( + useCallback((key: KeyEvent) => { + if (key.ctrl && key.name === 'c') { + key.preventDefault?.() + exitFreebuffCleanly() + } + }, []), + ) +} diff --git a/cli/src/hooks/use-freebuff-session-progress.ts b/cli/src/hooks/use-freebuff-session-progress.ts new file mode 100644 index 0000000000..05932cb4a6 --- /dev/null +++ b/cli/src/hooks/use-freebuff-session-progress.ts @@ -0,0 +1,34 @@ +import { useNow } from './use-now' +import { IS_FREEBUFF } from '../utils/constants' + +import type { FreebuffSessionResponse } from '../types/freebuff-session' + +export interface FreebuffSessionProgress { + /** 0..1, fraction of the session remaining. 1 at admission, 0 at expiry. */ + fraction: number + remainingMs: number +} + +/** + * Computes a live progress value for the active freebuff session, ticking at + * 1Hz. Returns null outside of active state or in non-freebuff builds, so + * callers can short-circuit their rendering. + */ +export function useFreebuffSessionProgress( + session: FreebuffSessionResponse | null, +): FreebuffSessionProgress | null { + const expiresAtMs = + session?.status === 'active' ? Date.parse(session.expiresAt) : null + const admittedAtMs = + session?.status === 'active' ? Date.parse(session.admittedAt) : null + + const nowMs = useNow(1000, expiresAtMs !== null) + + if (!IS_FREEBUFF || !expiresAtMs || !admittedAtMs) return null + + const totalMs = expiresAtMs - admittedAtMs + if (totalMs <= 0) return null + const remainingMs = Math.max(0, expiresAtMs - nowMs) + const fraction = Math.max(0, Math.min(1, remainingMs / totalMs)) + return { fraction, remainingMs } +} diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts new file mode 100644 index 0000000000..d66fba5aaf --- /dev/null +++ b/cli/src/hooks/use-freebuff-session.ts @@ -0,0 +1,693 @@ +import { env } from '@codebuff/common/env' +import { + FALLBACK_FREEBUFF_MODEL_ID, + LIMITED_FREEBUFF_MODEL_ID, + resolveFreebuffModel, +} from '@codebuff/common/constants/freebuff-models' +import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' +import { useEffect } from 'react' + +import { + getSelectedFreebuffModel, + useFreebuffModelStore, +} from '../state/freebuff-model-store' +import { useFreebuffSessionStore } from '../state/freebuff-session-store' +import { getAuthTokenDetails } from '../utils/auth' +import { IS_FREEBUFF } from '../utils/constants' +import { + isFreebuffInstanceOwnedByDeadLocalProcess, + recordFreebuffInstanceOwner, +} from '../utils/freebuff-instance-owner' +import { logger } from '../utils/logger' +import { saveFreebuffModelPreference } from '../utils/settings' + +import type { FreebuffSessionResponse } from '../types/freebuff-session' +import type { + FreebuffCountryBlockReason, + FreebuffIpPrivacySignal, + FreebuffSessionServerResponse, +} from '@codebuff/common/types/freebuff-session' + +const POLL_INTERVAL_QUEUED_MS = 5_000 +const POLL_INTERVAL_ACTIVE_MS = 30_000 +const POLL_INTERVAL_ERROR_MS = 10_000 + +/** Header sent on GET so the server can detect when another CLI on the same + * account has rotated the id and respond with `{ status: 'superseded' }`. */ +const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id' + +/** Header sent on POST telling the server which model's queue to join. */ +const FREEBUFF_MODEL_HEADER = 'x-freebuff-model' + +/** Play the terminal bell so users get an audible notification on admission. */ +const playAdmissionSound = () => { + try { + process.stdout.write('\x07') + } catch { + // Silent fallback — some terminals/pipes disallow writing to stdout. + } +} + +const sessionEndpoint = (): string => { + const base = ( + env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'https://codebuff.com' + ).replace(/\/$/, '') + return `${base}/api/v1/freebuff/session` +} + +async function callSession( + method: 'POST' | 'GET' | 'DELETE', + token: string, + opts: { instanceId?: string; model?: string; signal?: AbortSignal } = {}, +): Promise { + const headers: Record = { Authorization: `Bearer ${token}` } + if (method === 'GET' && opts.instanceId) { + headers[FREEBUFF_INSTANCE_HEADER] = opts.instanceId + } + if (method === 'POST' && opts.model) { + headers[FREEBUFF_MODEL_HEADER] = opts.model + } + const resp = await fetch(sessionEndpoint(), { + method, + headers, + signal: opts.signal, + }) + // 404 = endpoint not deployed on this server (older web build). Treat as + // "waiting room disabled" so a newer CLI against an older server still + // works, rather than stranding users in a waiting room forever. + if (resp.status === 404) { + return { status: 'disabled' } + } + // 403 with a country_blocked or banned body is a terminal signal, not an + // error — the server rejects non-allowlist countries and banned accounts up + // front (see session _handlers.ts) so they don't wait through the queue only + // to be rejected at chat time. The 403 status (rather than 200) is + // deliberate: older CLIs that don't know these statuses treat them as a + // generic error and back off on the 10s error-retry cadence instead of + // tight-polling an unrecognized 200 body. + if (resp.status === 403) { + const body = (await resp + .json() + .catch(() => null)) as FreebuffSessionServerResponse | null + if ( + body && + (body.status === 'country_blocked' || body.status === 'banned') + ) { + return body + } + } + // 409 from POST means the selected model cannot be joined right now, either + // because an active session is locked to another model or because a + // Surface model-switch conflicts and temporary model availability closures + // as non-throw states. + if (resp.status === 409 && method === 'POST') { + const body = (await resp + .json() + .catch(() => null)) as FreebuffSessionServerResponse | null + if ( + body && + (body.status === 'model_locked' || body.status === 'model_unavailable') + ) { + return body + } + } + // 429 from POST is the per-model session-quota reject (e.g. too many DeepSeek + // sessions in the last 12h). Terminal for the current poll — the CLI shows + // a screen explaining the limit and when the user can try again. The 429 + // status (rather than 200) keeps older CLIs in their error path so they + // back off instead of tight-polling an unrecognized 200 body. + if (resp.status === 429 && method === 'POST') { + const body = (await resp + .json() + .catch(() => null)) as FreebuffSessionServerResponse | null + if (body && body.status === 'rate_limited') { + return body + } + } + if (!resp.ok) { + const text = await resp.text().catch(() => '') + throw new Error( + `freebuff session ${method} failed: ${resp.status} ${text.slice(0, 200)}`, + ) + } + return (await resp.json()) as FreebuffSessionServerResponse +} + +/** Picks the poll delay after a successful tick. Returns null when the state + * is terminal (no further polling). */ +function nextDelayMs(next: FreebuffSessionResponse): number | null { + switch (next.status) { + case 'queued': + return POLL_INTERVAL_QUEUED_MS + case 'active': + // Poll at the normal cadence, but ensure we land just after + // `expires_at` so the transition shows up promptly instead of leaving + // the countdown stuck at 0 for up to a full interval. + return Math.max( + 1_000, + Math.min(POLL_INTERVAL_ACTIVE_MS, next.remainingMs + 1_000), + ) + case 'ended': + // Inside the grace window we keep checking so the post-grace transition + // (server returns `none`, we synthesize ended-no-instanceId) is prompt. + return next.instanceId ? POLL_INTERVAL_ACTIVE_MS : null + case 'none': + case 'disabled': + case 'superseded': + case 'takeover_prompt': + case 'country_blocked': + case 'banned': + case 'model_locked': + case 'rate_limited': + case 'model_unavailable': + return null + } +} + +// --- Poll-loop control surface --------------------------------------------- +// +// The hook below registers a controller object here on mount; module-level +// imperative functions (restart / mark superseded / mark ended / etc.) talk +// to it without going through React. Non-React callers (chat-completions +// gate, exit paths) hit those functions directly. + +/** How the next tick should behave after a forced restart. + * - 'rejoin' → POST: claim/rotate a seat (used after explicit end-and-rejoin + * or when the chat gate kicks us back to the queue). + * - 'landing' → GET: drop to the model-picker (status 'none') so the user + * reconfirms a model before rejoining. */ +type RestartMode = 'rejoin' | 'landing' + +interface PollController { + /** Cancel the in-flight tick + timer and start a fresh one in `mode`. */ + restart: (mode: RestartMode) => Promise + apply: (next: FreebuffSessionResponse) => void + abort: () => void +} + +let controller: PollController | null = null + +/** Read the current instance id for outgoing chat requests. Includes `ended` + * so in-flight agent work can keep streaming during the server-side grace + * window (server keeps the row alive until `expires_at + grace`). */ +export function getFreebuffInstanceId(): string | undefined { + const current = useFreebuffSessionStore.getState().session + if (!current) return undefined + switch (current.status) { + case 'queued': + case 'active': + case 'ended': + return current.instanceId + default: + return undefined + } +} + +/** True when the session row represents a server-side slot the caller is + * holding (queued, active, or in the post-expiry grace window with a live + * instance id). DELETE only matters in those states; otherwise we'd fire a + * spurious request the server has nothing to act on. */ +function shouldReleaseSlot(current: FreebuffSessionResponse | null): boolean { + if (!current) return false + return ( + current.status === 'queued' || + current.status === 'active' || + (current.status === 'ended' && Boolean(current.instanceId)) + ) +} + +function toLandingSession( + current: FreebuffSessionResponse | null, +): Extract { + const accessTier = + current && 'accessTier' in current ? current.accessTier : undefined + const queueDepthByModel = + current && 'queueDepthByModel' in current + ? current.queueDepthByModel + : undefined + const rateLimitsByModel = getRateLimitsByModel(current) + const countryCode = + current && 'countryCode' in current ? current.countryCode : undefined + const countryBlockReason = + current && 'countryBlockReason' in current + ? current.countryBlockReason + : undefined + const ipPrivacySignals = + current && 'ipPrivacySignals' in current + ? current.ipPrivacySignals + : undefined + + return { + status: 'none', + ...(accessTier ? { accessTier } : {}), + ...(queueDepthByModel ? { queueDepthByModel } : {}), + ...(rateLimitsByModel ? { rateLimitsByModel } : {}), + ...(countryCode ? { countryCode } : {}), + ...(countryBlockReason ? { countryBlockReason } : {}), + ...(ipPrivacySignals ? { ipPrivacySignals } : {}), + } +} + +/** Best-effort DELETE of the caller's session row, gated on actually holding + * one. Used both by exit paths and any flow that wants the next POST to + * start clean (rejoin, return-to-landing). Always swallows errors — the + * server-side sweep is the backstop. */ +async function releaseFreebuffSlot(): Promise { + const current = useFreebuffSessionStore.getState().session + if (!shouldReleaseSlot(current)) return + const { token } = getAuthTokenDetails() + if (!token) return + try { + await callSession('DELETE', token) + } catch { + // swallow + } +} + +async function resetChatStore(): Promise { + const { useChatStore } = await import('../state/chat-store') + useChatStore.getState().reset() +} + +interface RestartOpts { + resetChat?: boolean + /** DELETE the held slot before restarting so the next POST starts clean. */ + releaseSlot?: boolean +} + +async function restartFreebuffSession( + mode: RestartMode, + opts: RestartOpts = {}, +): Promise { + if (!IS_FREEBUFF) return + // Halt the running poll loop before we touch local stores or DELETE the + // slot. Otherwise an in-flight GET could land mid-reset and overwrite + // state, or the next scheduled tick could fire between DELETE and + // restart() with stale assumptions. restart() re-aborts and re-arms + // below; the extra abort here is cheap. + controller?.abort() + if (opts.resetChat) await resetChatStore() + if (opts.releaseSlot) await releaseFreebuffSlot() + await controller?.restart(mode) +} + +/** + * Re-POST to the server (rejoining the queue / rotating the instance id). + * Pass `resetChat: true` to also wipe local chat history — used when + * rejoining after a session ended so the next admitted session starts fresh. + */ +export function refreshFreebuffSession( + opts: { resetChat?: boolean } = {}, +): Promise { + return restartFreebuffSession('rejoin', { resetChat: opts.resetChat }) +} + +/** + * Drop back to the pre-join landing state (model picker) instead of auto + * re-queuing. Used after a session ends: the user lands on the picker so + * they consciously choose a model and hit Enter to join, rather than being + * silently re-queued for whatever model they last used. + */ +export function returnToFreebuffLanding( + opts: { resetChat?: boolean } = {}, +): Promise { + return restartFreebuffSession('landing', { + resetChat: opts.resetChat, + releaseSlot: true, + }) +} + +/** Refresh picker-only metadata (quota and queue depths) while staying on the + * model selection screen. Used when a midnight-Pacific premium quota reset + * passes while the landing screen is open. */ +export function refreshFreebuffLandingMetadata(): Promise { + return restartFreebuffSession('landing') +} + +/** + * Join (or re-queue for) `model`. Dual-purpose: + * - First join: called from the pre-chat landing picker. The session starts + * at `none` (GET-only); this is the user's explicit commitment to enter. + * - Switch: called when the user picks a different model from within the + * waiting room. Server moves them to the back of the new model's queue. + * + * If the server has already admitted them on a different model, it responds + * with `model_locked`; the tick loop silently reverts the local selection to + * the locked model so the active session stays intact. Users who really want + * to switch can /end-session deliberately. + */ +export function joinFreebuffQueue(model: string): Promise { + if (!IS_FREEBUFF) return Promise.resolve() + // This is the only explicit user-pick path (called from the picker on + // click / Enter), so persistence belongs here — and ONLY here. Server- + // driven flips (`model_locked`, `model_unavailable`, takeover) go + // through `setSelectedModel` directly, which never writes to disk. + const resolved = resolveFreebuffModel(model) + useFreebuffModelStore.getState().setSelectedModel(resolved) + saveFreebuffModelPreference(resolved) + return restartFreebuffSession('rejoin') +} + +export function takeOverFreebuffSession(): Promise { + if (!IS_FREEBUFF) return Promise.resolve() + const current = useFreebuffSessionStore.getState().session + if (current?.status !== 'takeover_prompt') return Promise.resolve() + useFreebuffModelStore.getState().setSelectedModel(current.model) + return restartFreebuffSession('rejoin') +} + +/** + * Best-effort DELETE of the caller's session row. Used by exit paths that + * skip React unmount (process.exit on Ctrl+C) so the seat frees up quickly + * instead of waiting for the server-side expiry sweep. + */ +export async function endFreebuffSessionBestEffort(): Promise { + if (!IS_FREEBUFF) return + await releaseFreebuffSlot() +} + +export function markFreebuffSessionSuperseded(): void { + if (!IS_FREEBUFF) return + controller?.abort() + controller?.apply({ status: 'superseded' }) +} + +/** Flip into the terminal `country_blocked` state from outside the poll loop. + * Used when the chat-completions gate rejects on country even though the + * session-level country check did not catch the request first. + * Transitioning the session state here unmounts the Chat surface in favor of + * the waiting-room's country_blocked message, so the user can't keep typing + * and sending doomed requests. */ +export function markFreebuffSessionCountryBlocked(params: { + countryCode: string + countryBlockReason?: FreebuffCountryBlockReason + ipPrivacySignals?: FreebuffIpPrivacySignal[] +}): void { + if (!IS_FREEBUFF) return + controller?.abort() + controller?.apply({ status: 'country_blocked', ...params }) + // Best-effort DELETE so we don't hold a waiting-room seat on a session the + // server is already refusing to serve at chat time. + releaseFreebuffSlot().catch(() => {}) +} + +/** Flip into the local `ended` state without an instanceId (server has lost + * our row). The chat surface stays mounted with the rejoin banner. + * Preserves any `rateLimitsByModel` snapshot from the prior session so the + * banner can show today's premium-session count without an extra fetch. */ +export function markFreebuffSessionEnded(): void { + if (!IS_FREEBUFF) return + controller?.abort() + const current = useFreebuffSessionStore.getState().session + const rateLimitsByModel = getRateLimitsByModel(current) + controller?.apply({ + status: 'ended', + accessTier: + current && 'accessTier' in current ? current.accessTier : undefined, + rateLimitsByModel, + }) +} + +interface UseFreebuffSessionResult { + session: FreebuffSessionResponse | null + error: string | null +} + +/** + * Manages the freebuff waiting-room session lifecycle: + * - GET on mount to probe state (no auto-join; the user picks a model in + * the landing screen, which calls joinFreebuffQueue) + * - if the probe sees an existing seat, auto-takes-over when the prior + * local owner process is gone; otherwise asks before POSTing to rotate + * the instance id so any other CLI on the same account is superseded + * - polls GET while queued (fast) or active (slow) to keep state fresh + * - re-POSTs on explicit refresh (chat gate rejected us, user switched + * models, user rejoined after ending) + * - DELETE on unmount so the slot frees up for the next user + * - plays a bell on transition from queued → active + */ +export function useFreebuffSession(): UseFreebuffSessionResult { + const session = useFreebuffSessionStore((s) => s.session) + const error = useFreebuffSessionStore((s) => s.error) + + useEffect(() => { + const { setSession, setError } = useFreebuffSessionStore.getState() + + if (!IS_FREEBUFF) { + setSession({ status: 'disabled' }) + return + } + + const { token } = getAuthTokenDetails() + if (!token) { + logger.warn( + {}, + '[freebuff-session] No auth token; skipping waiting-room admission', + ) + setError('Not authenticated') + return + } + + let cancelled = false + let abortController = new AbortController() + let timer: ReturnType | null = null + let previousStatus: FreebuffSessionResponse['status'] | null = null + let restartGeneration = 0 + // Method for the NEXT tick. GET is read-only; POST claims/rotates a seat. + // Startup is GET (probe before committing). After any POST completes we + // flip back to GET. refresh() sets it to 'POST' for explicit join/rejoin; + // the startup takeover branch does the same when the probe finds a seat. + let nextMethod: 'GET' | 'POST' = 'GET' + + const apply = (next: FreebuffSessionResponse) => { + if (next.status === 'queued' || next.status === 'active') { + useFreebuffModelStore.getState().setSelectedModel(next.model) + recordFreebuffInstanceOwner(next.instanceId) + } else if (next.status === 'none' && next.accessTier === 'limited') { + useFreebuffModelStore + .getState() + .setSelectedModel(LIMITED_FREEBUFF_MODEL_ID) + } + setSession(next) + setError(null) + previousStatus = next.status + } + + const clearTimer = () => { + if (timer) { + clearTimeout(timer) + timer = null + } + } + + const schedule = (ms: number) => { + if (cancelled) return + clearTimer() + timer = setTimeout(tick, ms) + } + + const tick = async () => { + if (cancelled) return + const method = nextMethod + const instanceId = getFreebuffInstanceId() + const model = getSelectedFreebuffModel() + try { + const next = await callSession(method, token, { + signal: abortController.signal, + instanceId, + model, + }) + if (cancelled) return + // After any successful call, default back to GET polling. The + // takeover and model_locked branches below override this when they + // need another POST. + nextMethod = 'GET' + + // Race recovery: user picked a different model in the waiting room at + // the exact moment the server admitted them with the original model. + // Silently revert the local selection and re-tick so the next call + // (a GET) lands the actual active session. Users who really want to + // switch can /end-session deliberately. + if (next.status === 'model_locked') { + useFreebuffModelStore.getState().setSelectedModel(next.currentModel) + schedule(0) + return + } + if (next.status === 'model_unavailable') { + // Server says the requested model isn't available right now (e.g. + // legacy GLM 5.1 outside deployment hours). Flip to the + // always-available fallback for this run. In-memory only — + // `setSelectedModel` doesn't persist, so the user's saved preference + // is preserved for their next launch. + useFreebuffModelStore + .getState() + .setSelectedModel(FALLBACK_FREEBUFF_MODEL_ID) + // The unavailable response came from a POST attempt. Re-POST with + // the fallback model; a GET would only redisplay the old ended row + // and leave the restart banner stuck in its pending state. + nextMethod = 'POST' + schedule(0) + return + } + + // Startup takeover: the initial probe GET saw we already hold a seat + // (from a prior CLI instance). Stop here and ask before POSTing to + // rotate our instance id; otherwise opening a second freebuff would + // immediately supersede the first one. + // `previousStatus === null` fences this to the very first tick only. + // Pin the selected model to whatever the server thinks we're on so + // an explicit takeover preserves our queue position instead of + // switching queues. + if ( + method === 'GET' && + previousStatus === null && + (next.status === 'queued' || next.status === 'active') + ) { + useFreebuffModelStore.getState().setSelectedModel(next.model) + // A fast restart after Ctrl+C can observe the old server row before + // best-effort DELETE lands. If the row belongs to a dead local + // process, silently do the same POST as the Take over button. + if (isFreebuffInstanceOwnedByDeadLocalProcess(next.instanceId)) { + nextMethod = 'POST' + schedule(0) + return + } + apply({ status: 'takeover_prompt', model: next.model }) + return + } + + if (previousStatus === 'queued' && next.status === 'active') { + playAdmissionSound() + } + + // active|ended → none means we've passed the server's hard cutoff. + // Synthesize a no-instanceId ended state so the chat surface stays + // mounted with the Enter-to-rejoin banner instead of looping back + // through the waiting room. Carry forward whichever rate-limit + // snapshot we have — preferring the fresh `none` snapshot, falling + // back to whatever was on the prior active/ended row — so the + // banner's "N of M used today" line stays populated. + if ( + (previousStatus === 'active' || previousStatus === 'ended') && + next.status === 'none' + ) { + const current = useFreebuffSessionStore.getState().session + const rateLimitsByModel = + next.rateLimitsByModel ?? getRateLimitsByModel(current) + apply({ + status: 'ended', + accessTier: + next.accessTier ?? + (current && 'accessTier' in current + ? current.accessTier + : undefined), + rateLimitsByModel, + }) + return + } + + apply(next) + const delay = nextDelayMs(next) + if (delay !== null) schedule(delay) + } catch (err) { + if (cancelled || abortController.signal.aborted) return + const msg = err instanceof Error ? err.message : String(err) + logger.warn({ error: msg }, '[freebuff-session] fetch failed') + setError(msg) + schedule(POLL_INTERVAL_ERROR_MS) + } + } + + controller = { + restart: async (mode) => { + const generation = ++restartGeneration + clearTimer() + // Abort any in-flight fetch so it can't race us and overwrite state. + abortController.abort() + abortController = new AbortController() + // Reset previousStatus so the queued→active bell still fires after + // a forced restart, and so the active|ended → none synthesis below + // doesn't bounce a 'landing' restart straight back to 'ended'. + previousStatus = null + if (mode === 'landing') { + nextMethod = 'GET' + // Land on the picker immediately. We can't go through the normal + // tick/apply path because a server-side row that hasn't been + // swept yet would trip the startup-takeover branch into an + // auto-POST — the exact silent-rejoin this mode exists to + // prevent. But the picker still needs live queue depths and quota + // snapshots, so kick off a fire-and-forget GET and extract only + // picker metadata from the response, ignoring whatever status it + // claims. Polling resumes when the user commits to a model via + // joinFreebuffQueue. + const landingSession = toLandingSession( + useFreebuffSessionStore.getState().session, + ) + apply(landingSession) + const fetchController = abortController + callSession('GET', token, { signal: fetchController.signal }) + .then((response) => { + if ( + cancelled || + fetchController.signal.aborted || + generation !== restartGeneration + ) { + return + } + if (response.status === 'none' || response.status === 'queued') { + apply({ + status: 'none', + accessTier: + response.accessTier ?? landingSession.accessTier, + queueDepthByModel: + response.queueDepthByModel ?? + landingSession.queueDepthByModel, + rateLimitsByModel: + response.rateLimitsByModel ?? + landingSession.rateLimitsByModel, + countryCode: response.countryCode ?? landingSession.countryCode, + countryBlockReason: + response.countryBlockReason ?? + landingSession.countryBlockReason, + ipPrivacySignals: + response.ipPrivacySignals ?? + landingSession.ipPrivacySignals, + }) + } + }) + .catch(() => { + // Silent — blank hints are acceptable if the fetch fails. + }) + return + } + nextMethod = 'POST' + await tick() + }, + apply, + abort: () => { + clearTimer() + abortController.abort() + }, + } + + tick() + + return () => { + cancelled = true + abortController.abort() + clearTimer() + const current = useFreebuffSessionStore.getState().session + controller = null + + // Fire-and-forget DELETE. Only release if we actually held a slot so + // we don't generate spurious DELETEs (e.g. HMR before POST completes). + if (shouldReleaseSlot(current)) { + callSession('DELETE', token).catch(() => {}) + } + setSession(null) + setError(null) + } + }, []) + + return { session, error } +} diff --git a/cli/src/hooks/use-gravity-ad.ts b/cli/src/hooks/use-gravity-ad.ts index 648adbaa32..11491414c4 100644 --- a/cli/src/hooks/use-gravity-ad.ts +++ b/cli/src/hooks/use-gravity-ad.ts @@ -1,16 +1,24 @@ -import { Message, WEBSITE_URL } from '@codebuff/sdk' -import { useCallback, useEffect, useRef, useState } from 'react' +import { WEBSITE_URL } from '@codebuff/sdk' +import { useEffect, useRef, useState } from 'react' +import { useTerminalLayout } from './use-terminal-layout' import { getAdsEnabled } from '../commands/ads' import { useChatStore } from '../state/chat-store' -import { subscribeToActivity } from '../utils/activity-tracker' +import { isUserActive, subscribeToActivity } from '../utils/activity-tracker' import { getAuthToken } from '../utils/auth' +import { IS_FREEBUFF } from '../utils/constants' +import { getCliEnv } from '../utils/env' import { logger } from '../utils/logger' +import type { Message } from '@codebuff/sdk' + const AD_ROTATION_INTERVAL_MS = 60 * 1000 // 60 seconds per ad -const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then stop +const MAX_ADS_AFTER_ACTIVITY = 3 // Show up to 3 ads after last activity, then pause fetching new ads +const ACTIVITY_THRESHOLD_MS = 30_000 // 30 seconds idle threshold for fetching new ads +const MAX_AD_CACHE_SIZE = 50 // Maximum number of ads to keep in cache +const ZEROCLICK_IMPRESSIONS_URL = 'https://zeroclick.dev/api/v2/impressions' -// Ad response type (matches Gravity API response, credits added after impression) +// Ad response type (normalized shape across providers; credits added after impression) export type AdResponse = { adText: string title: string @@ -19,12 +27,53 @@ export type AdResponse = { favicon: string clickUrl: string impUrl: string + provider?: AdProvider + impressionIds?: string[] credits?: number // Set after impression is recorded (in cents) } +/** + * Which upstream ad network to query. The server maps each provider onto the + * same normalized response shape, so the rest of the hook is provider-agnostic. + */ +export type AdProvider = 'gravity' | 'carbon' | 'zeroclick' +export type AdSurface = 'waiting_room' + export type GravityAdState = { - ad: AdResponse | null + ads: AdResponse[] | null isLoading: boolean + recordClick: (ad: AdResponse) => void + recordImpression: (ad: AdResponse) => void +} + +// Consolidated controller state for the ad rotation logic +type GravityController = { + choiceCache: AdResponse[][] // Cache of choice ad sets (each entry is 4 ads) + choiceCacheIndex: number + impressionsFired: Set + adsShownSinceActivity: number + tickInFlight: boolean +} + +// Pure helper: add a choice ad set to the choice cache +function addToChoiceCache(ctrl: GravityController, ads: AdResponse[]): void { + // ZeroClick offer responses must not be stored for later display. Keep them + // out of the rotation cache and only render them for the live request. + if (ads.some((ad) => ad.provider === 'zeroclick')) return + + // Deduplicate by checking if any set has the same first impUrl + const key = ads[0]?.impUrl + if (key && ctrl.choiceCache.some((set) => set[0]?.impUrl === key)) return + if (ctrl.choiceCache.length >= MAX_AD_CACHE_SIZE) ctrl.choiceCache.shift() + ctrl.choiceCache.push(ads) +} + +// Pure helper: get the next cached choice ad set +function nextFromChoiceCache(ctrl: GravityController): AdResponse[] | null { + if (ctrl.choiceCache.length === 0) return null + const set = ctrl.choiceCache[ctrl.choiceCacheIndex % ctrl.choiceCache.length]! + ctrl.choiceCacheIndex = (ctrl.choiceCacheIndex + 1) % ctrl.choiceCache.length + return set } /** @@ -33,81 +82,196 @@ export type GravityAdState = { * Behavior: * - Ads only start after the user sends their first message * - Ads rotate every 60 seconds - * - After 3 ads without user activity, rotation stops - * - Any user activity resets the counter and resumes rotation + * - After 3 ads without user activity, stops fetching new ads but continues cycling cached ads + * - Any user activity resets the counter and resumes fetching new ads * * Activity is tracked via the global activity-tracker module. */ -export const useGravityAd = (): GravityAdState => { - const [ad, setAd] = useState(null) +export const useGravityAd = (options?: { + enabled?: boolean + /** Skip the "wait for first user message" gate. Used by the freebuff + * waiting room, which has no conversation but still needs ads. */ + forceStart?: boolean + /** Primary ad network to query. Defaults to Gravity. */ + provider?: AdProvider + /** Backup ad network to try when the primary returns no fill or errors. */ + fallbackProvider?: AdProvider + /** Product surface requesting the ad. The server maps this to placements. */ + surface?: AdSurface +}): GravityAdState => { + const enabled = options?.enabled ?? true + const forceStart = options?.forceStart ?? false + const provider: AdProvider = options?.provider ?? 'gravity' + const fallbackProvider = options?.fallbackProvider + const surface = options?.surface + const [ads, setAds] = useState(null) const [isLoading, setIsLoading] = useState(false) - const [isActive, setIsActive] = useState(false) - const impressionFiredRef = useRef>(new Set()) - - // Counter: how many ads shown since last user activity - const adsShownRef = useRef(0) - - // Is rotation currently paused (shown 3 ads without activity)? - const isPausedRef = useRef(false) - // Rotation timer - const rotationTimerRef = useRef | null>(null) - - // Fire impression via web API when ad changes (grants credits) - useEffect(() => { - if (isActive && ad?.impUrl && !impressionFiredRef.current.has(ad.impUrl)) { - const currentImpUrl = ad.impUrl - impressionFiredRef.current.add(currentImpUrl) + // Check if terminal height is too small to show ads + const { terminalHeight } = useTerminalLayout() + const isVeryCompactHeight = terminalHeight <= 17 + + // Freebuff always shows ads even on compact screens (ads are mandatory there). + const isFreeMode = IS_FREEBUFF + + // Skip ads on very compact screens unless we're in Freebuff (where ads are mandatory) + // Also skip if explicitly disabled (e.g. user has a subscription) + const shouldHideAds = !enabled || (isVeryCompactHeight && !isFreeMode) + + // Use Zustand selector instead of manual subscription - only rerenders when value changes + const hasUserMessagedStore = useChatStore((s) => + s.messages.some((m) => m.variant === 'user'), + ) + // forceStart lets callers (e.g. the waiting room) opt out of the + // "wait for the first user message" gate. + const shouldStart = forceStart || hasUserMessagedStore + + // Single consolidated controller ref + const ctrlRef = useRef({ + choiceCache: [], + choiceCacheIndex: 0, + impressionsFired: new Set(), + adsShownSinceActivity: 0, + tickInFlight: false, + }) + + // Ref for the tick function (avoids useCallback dependency issues) + const tickRef = useRef<() => void>(() => {}) + + // Ref to track whether ads should be hidden for use in async code + const shouldHideAdsRef = useRef(shouldHideAds) + shouldHideAdsRef.current = shouldHideAds + + // Fire impression and update credits (called when showing an ad) + const recordImpressionOnce = (ad: AdResponse): void => { + // Don't record impressions when ads should be hidden + if (shouldHideAdsRef.current) return + + const ctrl = ctrlRef.current + const { impUrl } = ad + if (ctrl.impressionsFired.has(impUrl)) return + ctrl.impressionsFired.add(impUrl) + + const recordLocalImpression = async (): Promise => { const authToken = getAuthToken() if (!authToken) { - logger.warn('[gravity] No auth token, skipping impression recording') + logger.warn('[ads] No auth token, skipping local impression recording') return } - fetch(`${WEBSITE_URL}/api/v1/ads/impression`, { + // Include mode in request - Freebuff should not grant credits (no balance concept). + const agentMode = useChatStore.getState().agentMode + + const res = await fetch(`${WEBSITE_URL}/api/v1/ads/impression`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, + 'User-Agent': getCliAdRequestUserAgent(), }, body: JSON.stringify({ - impUrl: currentImpUrl, + impUrl, + mode: agentMode, }), }) - .then((res) => res.json()) - .then((data) => { - if (data.creditsGranted > 0) { - logger.info( - { creditsGranted: data.creditsGranted }, - '[gravity] Ad impression credits granted', - ) - setAd((currentAd) => - currentAd?.impUrl === currentImpUrl - ? { ...currentAd, credits: data.creditsGranted } - : currentAd, + + if (!res.ok) { + logger.debug( + { status: res.status }, + '[ads] Failed to record local ad impression', + ) + return + } + + const data = await res.json() + if (data.creditsGranted > 0) { + logger.info( + { creditsGranted: data.creditsGranted }, + '[ads] Ad impression credits granted', + ) + // Also update credits in visible ads + setAds((cur) => { + if (!cur) return cur + return cur.map((a) => + a.impUrl === impUrl ? { ...a, credits: data.creditsGranted } : a, + ) + }) + } + } + + if (ad.provider === 'zeroclick' && ad.impressionIds?.length) { + void (async () => { + try { + const res = await fetch(ZEROCLICK_IMPRESSIONS_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: ad.impressionIds }), + }) + + if (!res.ok) { + logger.debug( + { status: res.status }, + '[ads] Failed to record ZeroClick impression', ) + return } + } catch (err) { + logger.debug({ err }, '[ads] Failed to record ZeroClick impression') + return + } + + recordLocalImpression().catch((err) => { + logger.debug({ err }, '[ads] Failed to record local ad impression') }) - .catch((err) => { - logger.debug({ err }, '[gravity] Failed to record ad impression') - }) + })() + return } - }, [ad, isActive]) - const clearTimer = useCallback(() => { - if (rotationTimerRef.current) { - clearTimeout(rotationTimerRef.current) - rotationTimerRef.current = null + recordLocalImpression().catch((err) => { + logger.debug({ err }, '[ads] Failed to record ad impression') + }) + } + + const recordClick = (ad: AdResponse): void => { + const authToken = getAuthToken() + if (!authToken) { + logger.warn('[ads] No auth token, skipping ad click recording') + return } - }, []) + + void fetch(`${WEBSITE_URL}/api/v1/ads/click`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + 'User-Agent': getCliAdRequestUserAgent(), + }, + body: JSON.stringify({ impUrl: ad.impUrl, surface: surface ?? 'chat' }), + }) + .then((res) => { + if (!res.ok) { + logger.debug( + { status: res.status }, + '[ads] Failed to record ad click', + ) + } + }) + .catch((err) => { + logger.debug({ err }, '[ads] Failed to record ad click') + }) + } + + type FetchAdResult = { ads: AdResponse[] } | null // Fetch an ad via web API - const fetchAd = useCallback(async (): Promise => { + const fetchAd = async (): Promise => { + // Don't fetch ads when they should be hidden + if (shouldHideAdsRef.current) return null if (!getAdsEnabled()) return null const authToken = getAuthToken() if (!authToken) { - logger.warn('[gravity] No auth token available') + logger.warn('[ads] No auth token available') return null } @@ -141,141 +305,145 @@ export const useGravityAd = (): GravityAdState => { } } - try { - const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authToken}`, - }, - body: JSON.stringify({ - messages: adMessages, - sessionId: useChatStore.getState().chatSessionId, - device: getDeviceInfo(), - }), - }) + const providersToTry = + fallbackProvider && fallbackProvider !== provider + ? [provider, fallbackProvider] + : [provider] + + for (const providerToTry of providersToTry) { + try { + const response = await fetch(`${WEBSITE_URL}/api/v1/ads`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authToken}`, + 'User-Agent': getCliAdRequestUserAgent(), + }, + body: JSON.stringify({ + provider: providerToTry, + messages: adMessages, + sessionId: useChatStore.getState().chatSessionId, + device: getDeviceInfo(), + ...(surface ? { surface } : {}), + // Carbon requires a real browser-ish useragent for targeting/fraud + // detection. Gravity ignores it. We source one centrally so every + // provider that needs it sees the same value. + userAgent: getAdUserAgent(), + }), + }) - if (!response.ok) { - logger.warn( - { status: response.status, response: await response.json() }, - '[gravity] Web API returned error', + if (!response.ok) { + logger.warn( + { + provider: providerToTry, + status: response.status, + response: await response.json(), + }, + '[ads] Web API returned error', + ) + continue + } + + const data = await response.json() + + if (Array.isArray(data.ads) && data.ads.length > 0) { + return { + ads: (data.ads as AdResponse[]).map((ad) => ({ + ...ad, + provider: data.provider ?? providerToTry, + })), + } + } + } catch (err) { + logger.error( + { err, provider: providerToTry }, + '[ads] Failed to fetch ad', ) - return null } - - const data = await response.json() - const ad = data.ad as AdResponse | null - - logger.info( - { ad, request: { messages: adMessages } }, - '[gravity] Received ad response', - ) - return ad - } catch (err) { - logger.error({ err }, '[gravity] Failed to fetch ad') - return null - } - }, []) - - // Schedule ad rotation - const scheduleRotation = useCallback(() => { - clearTimer() - - if (!getAdsEnabled() || isPausedRef.current) { - logger.debug( - { isPaused: isPausedRef.current }, - '[gravity] Not scheduling rotation', - ) - return } - rotationTimerRef.current = setTimeout(async () => { - adsShownRef.current += 1 - - if (adsShownRef.current >= MAX_ADS_AFTER_ACTIVITY) { - isPausedRef.current = true - return - } + return null + } - const newAd = await fetchAd() - if (newAd) { - setAd(newAd) + // Update tick function (uses ref to avoid useCallback dependency issues) + tickRef.current = () => { + void (async () => { + const ctrl = ctrlRef.current + if (ctrl.tickInFlight) return + ctrl.tickInFlight = true + + try { + if (!getAdsEnabled()) return + + // Derive "can fetch new ads" from counter and activity (no separate paused ref needed) + const canFetchNew = + ctrl.adsShownSinceActivity < MAX_ADS_AFTER_ACTIVITY && + isUserActive(ACTIVITY_THRESHOLD_MS) + + const result = canFetchNew ? await fetchAd() : null + + if (result) { + addToChoiceCache(ctrl, result.ads) + ctrl.adsShownSinceActivity += 1 + setAds(result.ads) + } else { + // Fall back to cached ads + const cachedSet = nextFromChoiceCache(ctrl) + if (cachedSet) { + ctrl.adsShownSinceActivity += 1 + setAds(cachedSet) + } else { + setAds((cur) => (cur?.[0]?.provider === 'zeroclick' ? null : cur)) + } + } + } finally { + ctrl.tickInFlight = false } + })() + } - scheduleRotation() - }, AD_ROTATION_INTERVAL_MS) - }, [clearTimer, fetchAd]) - - // Handle activity from the global activity tracker - const handleActivity = useCallback(() => { - const wasPaused = isPausedRef.current - adsShownRef.current = 0 - - if (wasPaused) { - isPausedRef.current = false - scheduleRotation() - } - }, [scheduleRotation]) - - // Subscribe to global activity tracker + // Reset ads shown counter on user activity useEffect(() => { if (!getAdsEnabled()) return - - const unsubscribe = subscribeToActivity(handleActivity) - return unsubscribe - }, [handleActivity]) - - // Subscribe to UI messages to detect first user message - // We use UI messages (not runState.messageHistory) because UI messages - // update immediately when the user sends a message, allowing us to fetch - // ads sooner rather than waiting for the assistant to respond - useEffect(() => { - if (isActive || !getAdsEnabled()) { - return - } - - // Check initial state - const initialMessages = useChatStore.getState().messages - if (initialMessages.some((msg) => msg.variant === 'user')) { - setIsActive(true) - return - } - - const unsubscribe = useChatStore.subscribe((state) => { - const hasUserMessage = state.messages.some( - (msg) => msg.variant === 'user', - ) - - if (hasUserMessage) { - unsubscribe() - setIsActive(true) - } + return subscribeToActivity(() => { + ctrlRef.current.adsShownSinceActivity = 0 }) + }, []) - return unsubscribe - }, [isActive]) - - // Fetch first ad and start rotation when becoming active + // Start rotation when user sends first message (or immediately if forced). useEffect(() => { - if (!isActive) return + if (!shouldStart || !getAdsEnabled() || shouldHideAds) return setIsLoading(true) - fetchAd().then((firstAd) => { - if (firstAd) { - setAd(firstAd) + + // Fetch first ad immediately + void (async () => { + const result = await fetchAd() + if (result) { + const ctrl = ctrlRef.current + addToChoiceCache(ctrl, result.ads) + setAds(result.ads) + ctrl.adsShownSinceActivity = 1 } - // Always start rotation, even if first fetch returned null - scheduleRotation() setIsLoading(false) - }) - }, [isActive, fetchAd, scheduleRotation]) + })() - // Cleanup timer on unmount - useEffect(() => { - return () => clearTimer() - }, [clearTimer]) + // Start interval for rotation (consistent 60s intervals) + const id = setInterval(() => tickRef.current(), AD_ROTATION_INTERVAL_MS) - return { ad: isActive ? ad : null, isLoading } + return () => { + clearInterval(id) + } + }, [shouldStart, shouldHideAds, provider, fallbackProvider, surface]) + + // Don't return ads when ads should be hidden + const visible = shouldStart && !shouldHideAds + return { + ads: visible ? ads : null, + isLoading, + recordClick, + recordImpression: recordImpressionOnce, + } } type AdMessage = { role: 'user' | 'assistant'; content: string } @@ -332,3 +500,28 @@ function getDeviceInfo(): DeviceInfo { return { os, timezone, locale } } + +/** + * Useragent string passed to ad providers. Carbon (BuySellAds) requires a + * plausible browser useragent for targeting and fraud screening. We send a + * stable desktop Chrome-on-{os} UA per platform so targeting is consistent + * across users on the same platform without sharing anything identifying. + * + * Chrome version needs bumping periodically — stale UAs look bot-ish to ad + * networks. Last bumped: 2026-04-21. Revisit roughly every 6 months. + */ +const AD_CHROME_VERSION = '124.0.0.0' +function getAdUserAgent(): string { + const osUA: Record = { + darwin: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`, + win32: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`, + linux: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${AD_CHROME_VERSION} Safari/537.36`, + } + return osUA[process.platform] ?? osUA.linux +} + +function getCliAdRequestUserAgent(): string { + const product = IS_FREEBUFF ? 'Freebuff-CLI' : 'Codebuff-CLI' + const version = getCliEnv().CODEBUFF_CLI_VERSION ?? 'dev' + return `${product}/${version}` +} diff --git a/cli/src/hooks/use-grid-layout.ts b/cli/src/hooks/use-grid-layout.ts new file mode 100644 index 0000000000..f8514e6f79 --- /dev/null +++ b/cli/src/hooks/use-grid-layout.ts @@ -0,0 +1,73 @@ +import { useMemo } from 'react' + +import { computeSmartColumns, MIN_COLUMN_WIDTH } from '../utils/layout-helpers' + +/** + * Terminal column width thresholds for responsive grid layout. + * These are character counts (not pixels) representing terminal width breakpoints: + * - Below 100 cols: 1 column (narrow terminal) + * - 100-149 cols: up to 2 columns (medium terminal) + * - 150-199 cols: up to 3 columns (large terminal) + * - 200+ cols: up to 4 columns (extra large terminal) + */ +export const WIDTH_MD_THRESHOLD = 100 +export const WIDTH_LG_THRESHOLD = 150 +export const WIDTH_XL_THRESHOLD = 200 + +/** Ordered thresholds for determining max columns based on terminal width */ +const WIDTH_THRESHOLDS = [WIDTH_MD_THRESHOLD, WIDTH_LG_THRESHOLD, WIDTH_XL_THRESHOLD] as const + +export interface GridLayoutResult { + columns: number + columnWidth: number + columnGroups: T[][] +} + +/** Gap between columns in multi-column layout */ +const COLUMN_GAP = 1 + +export function computeGridLayout( + items: T[], + availableWidth: number, +): GridLayoutResult { + // Force single column for very narrow terminals where multi-column wouldn't fit + const minWidthForTwoColumns = MIN_COLUMN_WIDTH * 2 + COLUMN_GAP + if (availableWidth < minWidthForTwoColumns) { + return { + columns: 1, + columnWidth: Math.max(1, availableWidth), + columnGroups: [items], + } + } + + // Determine max columns from width thresholds + const maxColumns = WIDTH_THRESHOLDS.filter(t => availableWidth >= t).length + 1 + + const columns = computeSmartColumns(items.length, maxColumns) + + let columnWidth: number + if (columns === 1) { + columnWidth = availableWidth + } else { + const totalGap = columns - 1 + const rawWidth = Math.floor((availableWidth - totalGap) / columns) + columnWidth = Math.max(MIN_COLUMN_WIDTH, rawWidth) + } + + const columnGroups: T[][] = Array.from({ length: columns }, () => []) + items.forEach((item, idx) => { + columnGroups[idx % columns].push(item) + }) + + return { columns, columnWidth, columnGroups } +} + +export function useGridLayout( + items: T[], + availableWidth: number, +): GridLayoutResult { + return useMemo( + () => computeGridLayout(items, availableWidth), + [items, availableWidth], + ) +} diff --git a/cli/src/hooks/use-input-history.ts b/cli/src/hooks/use-input-history.ts index 259818cb3d..e8fadec1fe 100644 --- a/cli/src/hooks/use-input-history.ts +++ b/cli/src/hooks/use-input-history.ts @@ -5,7 +5,7 @@ import { saveMessageHistory, } from '../utils/message-history' -import type { InputValue } from '../state/chat-store' +import type { InputValue } from '../types/store' import type { InputMode } from '../utils/input-modes' /** @@ -39,6 +39,7 @@ export const useInputHistory = ( const currentDraftRef = useRef('') const currentDraftModeRef = useRef('default') const isInitializedRef = useRef(false) + const isNavigatingRef = useRef(false) // Load history from disk on mount useEffect(() => { @@ -49,6 +50,18 @@ export const useInputHistory = ( } }, []) + const resetHistoryNavigation = useCallback(() => { + historyIndexRef.current = -1 + currentDraftRef.current = '' + currentDraftModeRef.current = 'default' + }, []) + + useEffect(() => { + if (!isNavigatingRef.current) { + resetHistoryNavigation() + } + }, [inputMode, resetHistoryNavigation]) + const saveToHistory = useCallback((message: string) => { // Re-read from disk to pick up messages from other terminals const diskHistory = loadMessageHistory() @@ -66,6 +79,8 @@ export const useInputHistory = ( const history = messageHistoryRef.current if (history.length === 0) return + isNavigatingRef.current = true + if (historyIndexRef.current === -1) { // Save current draft and mode before navigating currentDraftRef.current = @@ -77,7 +92,10 @@ export const useInputHistory = ( } const historyMessage = history[historyIndexRef.current] - if (historyMessage === undefined) return + if (historyMessage === undefined) { + isNavigatingRef.current = false + return + } const { mode, displayText } = parseHistoryItem(historyMessage) @@ -91,6 +109,10 @@ export const useInputHistory = ( cursorPosition: displayText.length, lastEditDueToNav: true, }) + + setTimeout(() => { + isNavigatingRef.current = false + }, 0) }, [inputValue, inputMode, setInputValue, setInputMode]) const navigateDown = useCallback(() => { @@ -98,10 +120,15 @@ export const useInputHistory = ( if (history.length === 0) return if (historyIndexRef.current === -1) return + isNavigatingRef.current = true + if (historyIndexRef.current < history.length - 1) { historyIndexRef.current += 1 const historyMessage = history[historyIndexRef.current] - if (historyMessage === undefined) return + if (historyMessage === undefined) { + isNavigatingRef.current = false + return + } const { mode, displayText } = parseHistoryItem(historyMessage) @@ -136,7 +163,11 @@ export const useInputHistory = ( lastEditDueToNav: true, }) } + + setTimeout(() => { + isNavigatingRef.current = false + }, 0) }, [inputMode, setInputValue, setInputMode]) - return { saveToHistory, navigateUp, navigateDown } + return { saveToHistory, navigateUp, navigateDown, resetHistoryNavigation } } diff --git a/cli/src/hooks/use-login-keyboard-handlers.ts b/cli/src/hooks/use-login-keyboard-handlers.ts index 64012f63af..5d7d9cded9 100644 --- a/cli/src/hooks/use-login-keyboard-handlers.ts +++ b/cli/src/hooks/use-login-keyboard-handlers.ts @@ -8,7 +8,7 @@ interface UseLoginKeyboardHandlersParams { hasOpenedBrowser: boolean loading: boolean onFetchLoginUrl: () => void - onCopyUrl: (url: string) => void + onCopyUrl: (url: string) => Promise | void } /** @@ -65,7 +65,9 @@ export function useLoginKeyboardHandlers({ key.preventDefault() } - onCopyUrl(loginUrl) + // Fire-and-forget the async copy function with .catch() to prevent + // unhandled promise rejections if the implementation changes + void Promise.resolve(onCopyUrl(loginUrl)).catch(() => {}) } }, [loginUrl, hasOpenedBrowser, loading, onCopyUrl, onFetchLoginUrl], diff --git a/cli/src/hooks/use-login-polling.ts b/cli/src/hooks/use-login-polling.ts index 3f7a69eadb..2aa409eaca 100644 --- a/cli/src/hooks/use-login-polling.ts +++ b/cli/src/hooks/use-login-polling.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' -import { WEBSITE_URL } from '../login/constants' +import { LOGIN_WEBSITE_URL } from '../login/constants' import { pollLoginStatus } from '../login/login-flow' import { logger } from '../utils/logger' @@ -8,7 +8,7 @@ import type { User } from '../utils/auth' interface UseLoginPollingParams { loginUrl: string | null - fingerprintId: string + fingerprintId: string | null fingerprintHash: string | null expiresAt: string | null isWaitingForEnter: boolean @@ -49,7 +49,10 @@ export function useLoginPolling({ }, [onError]) useEffect(() => { - if (!loginUrl || !fingerprintHash || !expiresAt || !isWaitingForEnter) { + // fingerprintHash only becomes non-null after the login-URL mutation + // succeeds, and that path always sets fingerprintId first — so gating + // on fingerprintHash implicitly gates on fingerprintId. + if (!loginUrl || !fingerprintId || !fingerprintHash || !expiresAt || !isWaitingForEnter) { return } @@ -66,8 +69,8 @@ export function useLoginPolling({ logger, }, { - baseUrl: WEBSITE_URL, - fingerprintId, + baseUrl: LOGIN_WEBSITE_URL, + fingerprintId: fingerprintId!, fingerprintHash, expiresAt, shouldContinue: () => active, diff --git a/cli/src/hooks/use-logo.tsx b/cli/src/hooks/use-logo.tsx index 9dffa4ec47..3d4974664a 100644 --- a/cli/src/hooks/use-logo.tsx +++ b/cli/src/hooks/use-logo.tsx @@ -2,6 +2,7 @@ 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' interface UseLogoOptions { /** @@ -25,6 +26,12 @@ interface UseLogoOptions { * Block color for solid block characters (white for dark mode, black for light mode) */ blockColor?: string + /** + * Optional vertical budget (in rows) for the logo. When fewer than the + * ASCII art's 6 lines are available, the hook downgrades to the single-line + * text variant so callers on short terminals don't have to special-case it. + */ + maxHeight?: number } interface LogoResult { @@ -60,16 +67,23 @@ export const useLogo = ({ textColor, accentColor = '#9EFC62', blockColor = '#ffffff', + maxHeight, }: UseLogoOptions): LogoResult => { + // The ASCII art (full and small) is 6 lines tall. If the caller can't spare + // that many rows, collapse straight to the single-line text variant. + const ASCII_LOGO_LINES = 6 const rawLogoString = useMemo(() => { + if (maxHeight != null && maxHeight < ASCII_LOGO_LINES) { + return IS_FREEBUFF ? 'FREEBUFF' : 'CODEBUFF' + } if (availableWidth >= 70) return LOGO if (availableWidth >= 20) return LOGO_SMALL - return 'CODEBUFF' - }, [availableWidth]) + return IS_FREEBUFF ? 'FREEBUFF' : 'CODEBUFF' + }, [availableWidth, maxHeight]) // Format text block for plain text contexts (chat messages, etc.) const textBlock = useMemo(() => { - if (rawLogoString === 'CODEBUFF') { + if (rawLogoString === 'CODEBUFF' || rawLogoString === 'FREEBUFF') { return '' // Don't show ASCII art for text-only variant in plain text contexts } // Parse and format for plain text display @@ -81,9 +95,16 @@ export const useLogo = ({ // Format component for React contexts (login modal, etc.) const component = useMemo(() => { // Text-only variant for very narrow widths - if (rawLogoString === 'CODEBUFF') { - // Show shorter "Codebuff" for very narrow widths (< 30), otherwise "Codebuff CLI" - const displayText = availableWidth < 30 ? 'Codebuff' : 'Codebuff CLI' + if (rawLogoString === 'CODEBUFF' || rawLogoString === 'FREEBUFF') { + const brandName = IS_FREEBUFF ? 'Freebuff' : 'Codebuff' + // When we collapsed to text purely to fit a short terminal (not because + // the terminal is narrow), keep it to the bare brand name — "Freebuff + // CLI" reads as filler in that already-cramped space. + const forcedByHeight = maxHeight != null && maxHeight < ASCII_LOGO_LINES + const displayText = + availableWidth < 30 || forcedByHeight + ? brandName + : `${brandName} CLI` return ( @@ -134,7 +155,7 @@ export const useLogo = ({ ))} ) - }, [rawLogoString, availableWidth, applySheenToChar, textColor, accentColor, blockColor]) + }, [rawLogoString, availableWidth, applySheenToChar, textColor, accentColor, blockColor, maxHeight]) return { component, textBlock } } diff --git a/cli/src/hooks/use-message-queue.ts b/cli/src/hooks/use-message-queue.ts index 4250edc31a..cf6a5a7de0 100644 --- a/cli/src/hooks/use-message-queue.ts +++ b/cli/src/hooks/use-message-queue.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { logger } from '../utils/logger' -import type { PendingAttachment } from '../state/chat-store' +import type { PendingAttachment } from '../types/store' export type StreamStatus = 'idle' | 'waiting' | 'streaming' @@ -11,29 +11,35 @@ export type QueuedMessage = { attachments: PendingAttachment[] } +// Watchdog timeout duration: 60 seconds +const QUEUE_WATCHDOG_TIMEOUT_MS = 60 * 1000 + export const useMessageQueue = ( - sendMessage: (message: QueuedMessage) => void, + sendMessage: (message: QueuedMessage) => Promise, isChainInProgressRef: React.MutableRefObject, activeAgentStreamsRef: React.MutableRefObject, ) => { const [queuedMessages, setQueuedMessages] = useState([]) const [streamStatus, setStreamStatus] = useState('idle') const [canProcessQueue, setCanProcessQueue] = useState(true) - const [queuePaused, setQueuePaused] = useState(false) + // Separate state for user-initiated pause to ensure re-renders when pause status changes + const [queuePausedState, setQueuePausedState] = useState(false) + // Keep a ref so clearQueue can return the current queue synchronously. const queuedMessagesRef = useRef([]) const streamTimeoutRef = useRef | null>(null) const streamIntervalRef = useRef | null>(null) const streamMessageIdRef = useRef(null) + const isProcessingQueueRef = useRef(false) + // User-initiated pause state (separate from system-busy state) const isQueuePausedRef = useRef(false) + // Watchdog timer to recover from stuck queue processing lock + const watchdogTimeoutRef = useRef | null>(null) - useEffect(() => { - queuedMessagesRef.current = queuedMessages - }, [queuedMessages]) - - useEffect(() => { - isQueuePausedRef.current = queuePaused - }, [queuePaused]) + // queuePaused reflects whether the user has explicitly paused the queue + // (not whether the system is temporarily busy processing) + // Use state instead of ref to ensure components re-render when pause status changes + const queuePaused = queuePausedState const clearStreaming = useCallback(() => { if (streamTimeoutRef.current) { @@ -52,23 +58,34 @@ export const useMessageQueue = ( useEffect(() => { return () => { clearStreaming() + // Clean up watchdog timer on unmount + if (watchdogTimeoutRef.current) { + clearTimeout(watchdogTimeoutRef.current) + watchdogTimeoutRef.current = null + } } }, [clearStreaming]) - useEffect(() => { + const processNextMessage = useCallback(() => { const queuedList = queuedMessagesRef.current const queueLength = queuedList.length - if (queueLength === 0) return + if (queueLength === 0) { + return + } - // Log why queue is blocked (only when there are messages waiting) - if (!canProcessQueue || queuePaused) { + // Check if user has explicitly paused the queue + if (isQueuePausedRef.current) { logger.debug( - { queueLength, canProcessQueue, queuePaused }, - '[message-queue] Queue blocked: canProcessQueue or paused', + { queueLength }, + '[message-queue] Queue blocked: user paused', ) return } + + if (!canProcessQueue) { + return + } if (streamStatus !== 'idle') { logger.debug( { queueLength, streamStatus }, @@ -98,50 +115,114 @@ export const useMessageQueue = ( return } + if (isProcessingQueueRef.current) { + logger.debug( + { queueLength }, + '[message-queue] Queue blocked: already processing', + ) + return + } + logger.info( { queueLength }, '[message-queue] Processing next message from queue', ) - const timeoutId = setTimeout(() => { - const nextMessage = queuedList[0] - const remainingMessages = queuedList.slice(1) + isProcessingQueueRef.current = true + + // Start watchdog timer to recover from stuck processing lock + if (watchdogTimeoutRef.current) { + clearTimeout(watchdogTimeoutRef.current) + } + watchdogTimeoutRef.current = setTimeout(() => { + if (isProcessingQueueRef.current) { + logger.warn( + { stuckDurationMs: QUEUE_WATCHDOG_TIMEOUT_MS }, + '[message-queue] Watchdog: isProcessingQueueRef stuck for too long, forcing reset', + ) + isProcessingQueueRef.current = false + // Also reset canProcessQueue to allow queue to resume (unless user-paused) + setCanProcessQueue(!isQueuePausedRef.current) + } + watchdogTimeoutRef.current = null + }, QUEUE_WATCHDOG_TIMEOUT_MS) + + // Read the message to process from the ref BEFORE calling setState. + // We must NOT assign to outer variables inside functional setState callbacks + // because React can call those callbacks multiple times in concurrent mode, + // which would cause messages to be skipped. + const messageToProcess = queuedMessagesRef.current[0] + + if (!messageToProcess) { + isProcessingQueueRef.current = false + // Clear watchdog timer on early return + if (watchdogTimeoutRef.current) { + clearTimeout(watchdogTimeoutRef.current) + watchdogTimeoutRef.current = null + } + return + } + + // Now remove the message from the queue + setQueuedMessages((prev) => { + if (prev.length === 0) { + return prev + } + const remainingMessages = prev.slice(1) queuedMessagesRef.current = remainingMessages - setQueuedMessages(remainingMessages) - sendMessage(nextMessage) - }, 100) + return remainingMessages + }) - return () => clearTimeout(timeoutId) + sendMessage(messageToProcess) + .catch((err: unknown) => { + logger.warn( + { error: err }, + '[message-queue] sendMessage promise rejected', + ) + }) + .finally(() => { + isProcessingQueueRef.current = false + // Clear watchdog timer when processing completes normally + if (watchdogTimeoutRef.current) { + clearTimeout(watchdogTimeoutRef.current) + watchdogTimeoutRef.current = null + } + logger.debug('[message-queue] Processing lock released') + }) }, [ canProcessQueue, - queuePaused, streamStatus, sendMessage, isChainInProgressRef, activeAgentStreamsRef, ]) + useEffect(() => { + processNextMessage() + }, [canProcessQueue, streamStatus, queuedMessages.length, processNextMessage, isChainInProgressRef]) + const addToQueue = useCallback( (message: string, attachments: PendingAttachment[] = []) => { const queuedMessage = { content: message, attachments } - const newQueue = [...queuedMessagesRef.current, queuedMessage] - queuedMessagesRef.current = newQueue - setQueuedMessages(newQueue) - logger.info( - { newQueueLength: newQueue.length, messageLength: message.length }, - '[message-queue] Message added to queue', - ) + // Use functional setState to ensure atomic updates during rapid calls. + setQueuedMessages((prev) => { + const newQueue = [...prev, queuedMessage] + queuedMessagesRef.current = newQueue + return newQueue + }) }, [], ) const pauseQueue = useCallback(() => { - setQueuePaused(true) + isQueuePausedRef.current = true + setQueuePausedState(true) setCanProcessQueue(false) }, []) const resumeQueue = useCallback(() => { - setQueuePaused(false) + isQueuePausedRef.current = false + setQueuePausedState(false) setCanProcessQueue(true) }, []) @@ -159,8 +240,8 @@ export const useMessageQueue = ( const stopStreaming = useCallback(() => { setStreamStatus('idle') - setCanProcessQueue(!queuePaused) - }, [queuePaused]) + setCanProcessQueue(!isQueuePausedRef.current) + }, []) return { queuedMessages, @@ -178,5 +259,6 @@ export const useMessageQueue = ( resumeQueue, clearQueue, isQueuePausedRef, + isProcessingQueueRef, } } diff --git a/cli/src/hooks/use-now.ts b/cli/src/hooks/use-now.ts new file mode 100644 index 0000000000..03b7f33a87 --- /dev/null +++ b/cli/src/hooks/use-now.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react' + +/** + * Returns `Date.now()`, refreshed at the given interval. Pass `enabled: false` + * to freeze the timer (and cancel the interval). Multiple components can call + * this independently; setIntervals are cheap and React batches the resulting + * renders. + * + * Intended for short-lived UI countdowns like the freebuff session timer or + * elapsed-in-queue display. + */ +export function useNow(intervalMs: number, enabled = true): number { + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + if (!enabled) return + const id = setInterval(() => setNow(Date.now()), intervalMs) + return () => clearInterval(id) + }, [intervalMs, enabled]) + return now +} diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 042b26c209..b66e046fa0 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -3,10 +3,12 @@ import { useCallback, useEffect, useRef } from 'react' import { setCurrentChatId } from '../project-files' import { createStreamController } from './stream-state' import { useChatStore } from '../state/chat-store' +import { getFreebuffInstanceId } from './use-freebuff-session' import { getCodebuffClient } from '../utils/codebuff-client' -import { AGENT_MODE_TO_ID } from '../utils/constants' +import { AGENT_MODE_TO_COST_MODE, IS_FREEBUFF } from '../utils/constants' import { createEventHandlerState } from '../utils/create-event-handler-state' import { createRunConfig } from '../utils/create-run-config' +import { getAgentIdForMode } from '../utils/freebuff-agent-selection' import { loadAgentDefinitions } from '../utils/local-agent-registry' import { logger } from '../utils/logger' import { @@ -24,24 +26,23 @@ import { handleRunCompletion, handleRunError, prepareUserMessage as prepareUserMessageHelper, + resetEarlyReturnState, setupStreamingContext, } from './helpers/send-message' import { NETWORK_ERROR_ID } from '../utils/validation-error-helpers' +import { yieldToEventLoop } from '../utils/yield-to-event-loop' import type { ElapsedTimeTracker } from './use-elapsed-time' import type { StreamStatus } from './use-message-queue' -import type { PendingAttachment } from '../state/chat-store' +import type { PendingAttachment } from '../types/store' import type { ChatMessage } from '../types/chat' import type { SendMessageFn } from '../types/contracts/send-message' import type { AgentMode } from '../utils/constants' import type { SendMessageTimerEvent } from '../utils/send-message-timer' import type { AgentDefinition, MessageContent, RunState } from '@codebuff/sdk' +import { isCoveredBySubscription } from '../utils/subscription' -// Main chat send hook: orchestrates prep, streaming, and completion. -const yieldToEventLoop = () => - new Promise((resolve) => { - setTimeout(resolve, 0) - }) +import type { SubscriptionResponse } from './use-subscription-query' interface UseSendMessageOptions { inputRef: React.MutableRefObject @@ -59,9 +60,11 @@ interface UseSendMessageOptions { scrollToLatest: () => void onTimerEvent?: (event: SendMessageTimerEvent) => void isQueuePausedRef?: React.MutableRefObject + isProcessingQueueRef?: React.MutableRefObject resumeQueue?: () => void continueChat: boolean continueChatId?: string + subscriptionData?: SubscriptionResponse | null } // Choose the agent definition by explicit selection or mode-based fallback. @@ -75,7 +78,7 @@ const resolveAgent = ( ? agentDefinitions.find((definition) => definition.id === agentId) : undefined - return selectedAgentDefinition ?? agentId ?? AGENT_MODE_TO_ID[agentMode] + return selectedAgentDefinition ?? agentId ?? getAgentIdForMode(agentMode) } // Respect bash context, but avoid sending empty prompts when only images are attached. @@ -108,9 +111,11 @@ export const useSendMessage = ({ scrollToLatest, onTimerEvent = () => {}, isQueuePausedRef, + isProcessingQueueRef, resumeQueue, continueChat, continueChatId, + subscriptionData, }: UseSendMessageOptions): { sendMessage: SendMessageFn clearMessages: () => void @@ -130,7 +135,9 @@ export const useSendMessage = ({ setRunState, setIsRetrying, } = useChatStore.getState() - const previousRunStateRef = useRef(null) + const previousRunStateRef = useRef( + useChatStore.getState().runState, + ) // Memoize stream controller to maintain referential stability across renders const streamRefsRef = useRef( async ({ content, agentMode, postUserMessage, attachments }) => { + // CRITICAL: Set chain in progress immediately (synchronously) before any async work. + // This ensures the router can detect that we're busy and queue subsequent messages. + // Set the ref directly first to guarantee immediate visibility to other code paths, + // then call updateChainInProgress to also update React state for re-renders. + isChainInProgressRef.current = true + updateChainInProgress(true) + setCanProcessQueue(false) + if (agentMode !== 'PLAN') { setHasReceivedPlanResponse(false) } @@ -237,17 +251,41 @@ export const useSendMessage = ({ setIsRetrying(false) // Prepare user message (bash context, images, text attachments, mode divider) - const { - userMessageId, - messageContent, - bashContextForPrompt, - finalContent, - } = await prepareUserMessage({ - content, - agentMode, - postUserMessage, - attachments, - }) + let userMessageId: string + let messageContent: MessageContent[] | undefined + let bashContextForPrompt: string | undefined + let finalContent: string + + try { + const prepared = await prepareUserMessage({ + content, + agentMode, + postUserMessage, + attachments, + }) + userMessageId = prepared.userMessageId + messageContent = prepared.messageContent + bashContextForPrompt = prepared.bashContextForPrompt + finalContent = prepared.finalContent + } catch (error) { + logger.error( + { error }, + '[send-message] prepareUserMessage failed with exception', + ) + setMessages((prev) => [ + ...prev, + createErrorChatMessage( + '⚠️ Failed to prepare message. Please try again.', + ), + ]) + resetEarlyReturnState({ + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) + return + } // Validate before sending (e.g., agent config checks) try { @@ -261,11 +299,12 @@ export const useSendMessage = ({ const errorsToAttach = validationResult.errors.length === 0 ? [ - { - id: NETWORK_ERROR_ID, - message: - 'Agent validation failed. This may be due to a network issue or temporary server problem. Please try again.', - }, + // Hide this for now, as validate endpoint may be flaky and we don't want to bother users. + // { + // id: NETWORK_ERROR_ID, + // message: + // 'Agent validation failed. This may be due to a network issue or temporary server problem. Please try again.', + // }, ] : validationResult.errors @@ -280,6 +319,12 @@ export const useSendMessage = ({ } }), ) + resetEarlyReturnState({ + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) return } } catch (error) { @@ -297,6 +342,12 @@ export const useSendMessage = ({ await yieldToEventLoop() setTimeout(() => scrollToLatest(), 0) + resetEarlyReturnState({ + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) return } @@ -313,6 +364,22 @@ export const useSendMessage = ({ {}, '[send-message] No Codebuff client available. Please ensure you are authenticated.', ) + // Show error to user instead of silently failing + const brandName = IS_FREEBUFF ? 'Freebuff' : 'Codebuff' + setMessages((prev) => [ + ...prev, + createErrorChatMessage( + `⚠️ Unable to connect to ${brandName}. Please check your authentication and try again.`, + ), + ]) + await yieldToEventLoop() + setTimeout(() => scrollToLatest(), 0) + resetEarlyReturnState({ + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) return } @@ -320,8 +387,6 @@ export const useSendMessage = ({ const aiMessageId = generateAiMessageId() const aiMessage = createAiMessageShell(aiMessageId) - setMessages((prev) => autoCollapsePreviousMessages(prev, aiMessageId)) - const { updater, hasReceivedContentRef, abortController } = setupStreamingContext({ aiMessageId, @@ -332,13 +397,21 @@ export const useSendMessage = ({ setStreamStatus, setCanProcessQueue, isQueuePausedRef, + isProcessingQueueRef, updateChainInProgress, setIsRetrying, + setStreamingAgents, }) setStreamStatus('waiting') - setMessages((prev) => [...prev, aiMessage]) - setCanProcessQueue(false) - updateChainInProgress(true) + // Combine auto-collapse and AI message addition into single atomic update + // to prevent flicker from intermediate render states + setMessages((prev) => [ + ...autoCollapsePreviousMessages(prev, aiMessageId), + aiMessage, + ]) + // Note: updateChainInProgress(true) and setCanProcessQueue(false) are already + // called at the start of sendMessage to ensure they happen synchronously + // before any async work, so the router can correctly detect busy state. let actualCredits: number | undefined // Execute SDK run with streaming handlers @@ -369,10 +442,15 @@ export const useSendMessage = ({ setIsRetrying, onTotalCost: (cost: number) => { actualCredits = cost - addSessionCredits(cost) + // Only add to session credits if not covered by subscription + // (subscription credits are shown separately in the UI) + if (!isCoveredBySubscription(subscriptionData)) { + addSessionCredits(cost) + } }, }) + const freebuffInstanceId = getFreebuffInstanceId() const runConfig = createRunConfig({ logger, agent: resolvedAgent, @@ -382,9 +460,17 @@ export const useSendMessage = ({ agentDefinitions, eventHandlerState, signal: abortController.signal, + costMode: AGENT_MODE_TO_COST_MODE[agentMode], + extraCodebuffMetadata: + IS_FREEBUFF && freebuffInstanceId + ? { freebuff_instance_id: freebuffInstanceId } + : undefined, }) - logger.info({ runConfig }, '[send-message] Sending message with sdk run config') + logger.info( + { runConfig }, + '[send-message] Sending message with sdk run config', + ) const runState = await client.run(runConfig) // Finalize: persist state and mark complete @@ -403,39 +489,57 @@ export const useSendMessage = ({ timerController, updater, aiMessageId, - streamRefs, + wasAbortedByUser: abortController.signal.aborted, setStreamStatus, setCanProcessQueue, updateChainInProgress, setHasReceivedPlanResponse, resumeQueue, + isProcessingQueueRef, + isQueuePausedRef, }) } catch (error) { - handleRunError({ - error, - aiMessageId, - timerController, - updater, - setIsRetrying, - setStreamStatus, - setCanProcessQueue, - updateChainInProgress, - }) + // If this run was aborted, the abort handler already handled cleanup. + // Don't run error handling to avoid interfering with any new run that + // may have started. Uses per-run abortController.signal (not shared + // streamRefs) so a newer run's reset() can't clear this flag. + if (!abortController.signal.aborted) { + handleRunError({ + error, + timerController, + updater, + setIsRetrying, + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) + } else { + logger.debug({ error }, '[send-message] Ignoring error after abort') + } } finally { - // Defensive reset: ensure chain state is always cleared even if handlers throw. - // This prevents the system from getting stuck in "chain in progress" state. - if (isChainInProgressRef.current) { - logger.warn( - {}, - '[send-message] Chain still in progress after try/catch, forcing reset', - ) - updateChainInProgress(false) - setStreamStatus('idle') - setCanProcessQueue(!isQueuePausedRef?.current) + // If this run was aborted, the abort handler already released the chain lock + // and queue processing state. Don't touch shared state here to avoid + // interfering with any new run that may have started after the abort. + // Uses per-run abortController.signal (not shared streamRefs) so a newer + // run's reset() can't clear this flag. + if (!abortController.signal.aborted) { + if (isChainInProgressRef.current) { + logger.warn( + {}, + '[send-message] Chain still in progress after try/catch, forcing reset', + ) + updateChainInProgress(false) + setStreamStatus('idle') + setCanProcessQueue(!isQueuePausedRef?.current) + } + // Safety net: ensure lock is always released even if handleRunCompletion/handleRunError + // didn't run (e.g., due to unexpected early return). Redundant releases are safe (idempotent). + if (isProcessingQueueRef) { + isProcessingQueueRef.current = false + } } - // Ensure the batched updater's flush interval is always cleaned up, - // even if handleRunCompletion or handleRunError throw unexpectedly. - // dispose() is safe to call multiple times. updater.dispose() } }, @@ -444,6 +548,8 @@ export const useSendMessage = ({ addSessionCredits, agentId, inputRef, + isChainInProgressRef, + isProcessingQueueRef, isQueuePausedRef, mainAgentTimer, onBeforeMessageSend, diff --git a/cli/src/hooks/use-subscription-query.ts b/cli/src/hooks/use-subscription-query.ts new file mode 100644 index 0000000000..f27b5d832a --- /dev/null +++ b/cli/src/hooks/use-subscription-query.ts @@ -0,0 +1,71 @@ +import { useActivityQuery } from './use-activity-query' +import { getAuthToken } from '../utils/auth' +import { IS_FREEBUFF } from '../utils/constants' +import { getApiClient } from '../utils/codebuff-api' +import { logger as defaultLogger } from '../utils/logger' + +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { SubscriptionResponse } from '@codebuff/common/types/subscription' + +export type { SubscriptionResponse } + +export const subscriptionQueryKeys = { + all: ['subscription'] as const, + current: () => [...subscriptionQueryKeys.all, 'current'] as const, +} + +export async function fetchSubscriptionData( + logger: Logger = defaultLogger, +): Promise { + const client = getApiClient() + const response = await client.get( + '/api/user/subscription', + { includeCookie: true }, + ) + + if (!response.ok) { + logger.debug( + { status: response.status }, + 'Failed to fetch subscription data', + ) + throw new Error(`Failed to fetch subscription: ${response.status}`) + } + + return response.data! +} + +export interface UseSubscriptionQueryDeps { + logger?: Logger + enabled?: boolean + refetchInterval?: number | false + refetchOnActivity?: boolean + pauseWhenIdle?: boolean + idleThreshold?: number +} + +export function useSubscriptionQuery(deps: UseSubscriptionQueryDeps = {}) { + const { + logger = defaultLogger, + enabled = true, + refetchInterval = 60 * 1000, + refetchOnActivity = true, + pauseWhenIdle = true, + idleThreshold = 30_000, + } = deps + + const authToken = getAuthToken() + + return useActivityQuery({ + queryKey: subscriptionQueryKeys.current(), + queryFn: () => fetchSubscriptionData(logger), + enabled: enabled && !!authToken && !IS_FREEBUFF, + staleTime: 30 * 1000, + gcTime: 5 * 60 * 1000, + retry: 1, + refetchOnMount: true, + refetchInterval, + refetchOnActivity, + pauseWhenIdle, + idleThreshold, + }) +} diff --git a/cli/src/hooks/use-suggestion-engine.ts b/cli/src/hooks/use-suggestion-engine.ts index da0d8fc50d..ed1054cd32 100644 --- a/cli/src/hooks/use-suggestion-engine.ts +++ b/cli/src/hooks/use-suggestion-engine.ts @@ -1,8 +1,9 @@ import { promises as fs } from 'fs' import { - getAllFilePaths, + getAllPathsWithDirectories, getProjectFileTree, + type PathInfo, } from '@codebuff/common/project-file-tree' import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' @@ -70,9 +71,9 @@ interface MentionParseResult { atIndex: number } -// Helper to check if a position is inside quotes -const isInsideQuotes = (text: string, position: number): boolean => { - let inSingleQuote = false +// Helper to check if a position is inside string delimiters (double quotes or backticks only) +// Single quotes are excluded because they're commonly used as apostrophes (don't, it's, etc.) +export const isInsideStringDelimiters = (text: string, position: number): boolean => { let inDoubleQuote = false let inBacktick = false @@ -91,27 +92,25 @@ const isInsideQuotes = (text: string, position: number): boolean => { const isEscaped = numBackslashes % 2 === 1 if (!isEscaped) { - if (char === "'" && !inDoubleQuote && !inBacktick) { - inSingleQuote = !inSingleQuote - } else if (char === '"' && !inSingleQuote && !inBacktick) { + if (char === '"' && !inBacktick) { inDoubleQuote = !inDoubleQuote - } else if (char === '`' && !inSingleQuote && !inDoubleQuote) { + } else if (char === '`' && !inDoubleQuote) { inBacktick = !inBacktick } } } - return inSingleQuote || inDoubleQuote || inBacktick + return inDoubleQuote || inBacktick } -const parseAtInLine = (line: string): MentionParseResult => { +export const parseAtInLine = (line: string): MentionParseResult => { const atIndex = line.lastIndexOf('@') if (atIndex === -1) { return { active: false, query: '', atIndex: -1 } } - // Check if @ is inside quotes - if (isInsideQuotes(line, atIndex)) { + // Check if @ is inside string delimiters + if (isInsideStringDelimiters(line, atIndex)) { return { active: false, query: '', atIndex: -1 } } @@ -271,11 +270,13 @@ export type MatchedAgentInfo = Prettify< export type MatchedFileInfo = Prettify<{ filePath: string + isDirectory: boolean pathHighlightIndices?: number[] | null + matchScore?: number }> -const flattenFileTree = (nodes: FileTreeNode[]): string[] => - getAllFilePaths(nodes) +const flattenFileTree = (nodes: FileTreeNode[]): PathInfo[] => + getAllPathsWithDirectories(nodes) const getFileName = (filePath: string): string => { const lastSlash = filePath.lastIndexOf('/') @@ -299,8 +300,72 @@ const createPushUnique = ( } } +/** + * Fuzzy match: matches characters in order, allowing gaps. + * Returns highlight indices if matched, null if not. + * Also returns a score (lower is better) based on match quality. + */ +const fuzzyMatch = ( + text: string, + query: string, +): { indices: number[]; score: number } | null => { + const textLower = text.toLowerCase() + const queryLower = query.toLowerCase() + const indices: number[] = [] + let textIdx = 0 + let lastMatchIdx = -1 + let gaps = 0 + let consecutiveMatches = 0 + let maxConsecutive = 0 + + for (let queryIdx = 0; queryIdx < queryLower.length; queryIdx++) { + const char = queryLower[queryIdx] + let found = false + + while (textIdx < textLower.length) { + if (textLower[textIdx] === char) { + // Prefer matches at word boundaries (after / or at start) + if (lastMatchIdx >= 0 && textIdx > lastMatchIdx + 1) { + gaps += textIdx - lastMatchIdx - 1 + consecutiveMatches = 1 + } else { + consecutiveMatches++ + maxConsecutive = Math.max(maxConsecutive, consecutiveMatches) + } + indices.push(textIdx) + lastMatchIdx = textIdx + textIdx++ + found = true + break + } + textIdx++ + } + + if (!found) return null + } + + // Capture final consecutive run + maxConsecutive = Math.max(maxConsecutive, consecutiveMatches) + + // Score: lower is better + // - Fewer gaps = better + // - Longer consecutive matches = better + // - Matches at word boundaries (after /) = better + const boundaryBonus = indices.filter( + (idx) => idx === 0 || text[idx - 1] === '/' + ).length + + const score = + gaps * 10 - + maxConsecutive * 5 - + boundaryBonus * 15 + + (indices[0] ?? 0) // Prefer matches that start earlier + + return { indices, score } +} + const filterFileMatches = ( - filePaths: string[], + pathInfos: PathInfo[], query: string, ): MatchedFileInfo[] => { if (!query) { @@ -320,49 +385,26 @@ const filterFileMatches = ( const querySegments = normalized.split('/') const hasSlashes = querySegments.length > 1 - // Helper to calculate the longest contiguous match length in the file path - const calculateContiguousMatchLength = (filePath: string): number => { - const pathLower = filePath.toLowerCase() - let maxContiguousLength = 0 - - // Try to find the longest contiguous substring that matches the query pattern - for (let i = 0; i < pathLower.length; i++) { - let matchLength = 0 - let queryIdx = 0 - let pathIdx = i - - // Try to match as many characters as possible from this position - while (pathIdx < pathLower.length && queryIdx < normalized.length) { - if (pathLower[pathIdx] === normalized[queryIdx]) { - matchLength++ - queryIdx++ - pathIdx++ - } else { - break - } - } - - maxContiguousLength = Math.max(maxContiguousLength, matchLength) - } - - return maxContiguousLength - } - - // Helper to match path segments - const matchPathSegments = (filePath: string): number[] | null => { + // Helper to match path segments (for queries with /) + const matchPathSegments = (filePath: string): { indices: number[]; score: number } | null => { const pathLower = filePath.toLowerCase() const highlightIndices: number[] = [] let searchStart = 0 + let totalGaps = 0 for (const segment of querySegments) { if (!segment) continue - + const segmentIndex = pathLower.indexOf(segment, searchStart) if (segmentIndex === -1) { return null } - // Add highlight indices for this segment + // Count gaps between segments + if (searchStart > 0) { + totalGaps += segmentIndex - searchStart + } + for (let i = 0; i < segment.length; i++) { highlightIndices.push(segmentIndex + i) } @@ -370,88 +412,83 @@ const filterFileMatches = ( searchStart = segmentIndex + segment.length } - return highlightIndices + const score = totalGaps * 5 + filePath.length + return { indices: highlightIndices, score } } - if (hasSlashes) { - // Slash-separated path matching - for (const filePath of filePaths) { - const highlightIndices = matchPathSegments(filePath) - if (highlightIndices) { - pushUnique(matches, { - filePath, - pathHighlightIndices: highlightIndices, - }) - } + for (const { path: filePath, isDirectory } of pathInfos) { + if (seen.has(filePath)) continue + + const fileName = getFileName(filePath) + const fileNameLower = fileName.toLowerCase() + const pathLower = filePath.toLowerCase() + + let matchResult: { indices: number[]; score: number } | null = null + + if (hasSlashes) { + // Try path segment matching first + matchResult = matchPathSegments(filePath) } - // Sort by contiguous match length (longest first) - matches.sort((a, b) => { - const aLength = calculateContiguousMatchLength(a.filePath) - const bLength = calculateContiguousMatchLength(b.filePath) - return bLength - aLength - }) - } else { - // Original logic for non-slash queries - - // Prefix of file name - for (const filePath of filePaths) { - const fileName = getFileName(filePath) - const fileNameLower = fileName.toLowerCase() - - if (fileNameLower.startsWith(normalized)) { - pushUnique(matches, { - filePath, - pathHighlightIndices: createHighlightIndices( - filePath.lastIndexOf(fileName), - filePath.lastIndexOf(fileName) + normalized.length, - ), - }) - continue + if (!matchResult) { + // Try exact prefix of full path (highest priority) + if (pathLower.startsWith(normalized)) { + matchResult = { + indices: createHighlightIndices(0, normalized.length), + score: -1000 + filePath.length, // Very high priority + } } - - const path = filePath.toLowerCase() - if (path.startsWith(normalized)) { - pushUnique(matches, { - filePath, - pathHighlightIndices: createHighlightIndices(0, normalized.length), - }) + // Try prefix of filename + else if (fileNameLower.startsWith(normalized)) { + const fileNameStart = filePath.lastIndexOf(fileName) + matchResult = { + indices: createHighlightIndices(fileNameStart, fileNameStart + normalized.length), + score: -500 + filePath.length, // High priority + } + } + // Try substring match in path + else if (pathLower.includes(normalized)) { + const idx = pathLower.indexOf(normalized) + matchResult = { + indices: createHighlightIndices(idx, idx + normalized.length), + score: -100 + idx + filePath.length, + } + } + // Try fuzzy match as fallback + else { + matchResult = fuzzyMatch(filePath, normalized) } } - // Substring of file name or path - for (const filePath of filePaths) { - if (seen.has(filePath)) continue - const path = filePath.toLowerCase() - const fileName = getFileName(filePath) - const fileNameLower = fileName.toLowerCase() - - const fileNameIndex = fileNameLower.indexOf(normalized) - if (fileNameIndex !== -1) { - const actualFileNameStart = filePath.lastIndexOf(fileName) - pushUnique(matches, { - filePath, - pathHighlightIndices: createHighlightIndices( - actualFileNameStart + fileNameIndex, - actualFileNameStart + fileNameIndex + normalized.length, - ), - }) - continue - } + if (matchResult) { + // Adjust score: prefer shorter paths + const lengthPenalty = filePath.length * 2 + + // Give bonus for exact directory matches (query matches the full path) + // e.g. "cli" should prioritize "cli/" directory over "cli/package.json" + const isExactMatch = pathLower === normalized + const isExactDirMatch = isDirectory && isExactMatch + const exactMatchBonus = isExactDirMatch ? -500 : 0 + + // Only penalize directories when they're not an exact or prefix match + // This ensures "cli/" appears before "cli/src/file.ts" when searching "cli" + const isPrefixMatch = pathLower.startsWith(normalized) + const dirPenalty = isDirectory && !isPrefixMatch ? 50 : 0 + + const finalScore = matchResult.score + lengthPenalty + dirPenalty + exactMatchBonus - const pathIndex = path.indexOf(normalized) - if (pathIndex !== -1) { - pushUnique(matches, { - filePath, - pathHighlightIndices: createHighlightIndices( - pathIndex, - pathIndex + normalized.length, - ), - }) - } + pushUnique(matches, { + filePath, + isDirectory, + pathHighlightIndices: matchResult.indices, + matchScore: finalScore, + }) } } + // Sort by score (lower is better) + matches.sort((a, b) => (a.matchScore ?? 0) - (b.matchScore ?? 0)) + return matches } @@ -566,7 +603,7 @@ export const useSuggestionEngine = ({ new Map(), ) const fileRefreshIdRef = useRef(0) - const [filePaths, setFilePaths] = useState(() => + const [filePaths, setFilePaths] = useState(() => flattenFileTree(fileTree), ) @@ -703,10 +740,10 @@ export const useSuggestionEngine = ({ const agentSuggestionItems = useMemo(() => { return agentMatches.map((agent) => ({ id: agent.id, - label: agent.displayName, - labelHighlightIndices: agent.nameHighlightIndices, - description: agent.id, - descriptionHighlightIndices: agent.idHighlightIndices, + label: agent.id, + labelHighlightIndices: agent.idHighlightIndices, + description: '', + descriptionHighlightIndices: null, })) }, [agentMatches]) @@ -714,17 +751,20 @@ export const useSuggestionEngine = ({ return fileMatches.map((file) => { const fileName = getFileName(file.filePath) const isRootLevel = !file.filePath.includes('/') + // Show directories with trailing / in the label + const displayLabel = file.isDirectory ? `${fileName}/` : fileName + const displayPath = file.isDirectory ? `${file.filePath}/` : file.filePath return { id: file.filePath, - label: fileName, + label: displayLabel, labelHighlightIndices: file.pathHighlightIndices ? file.pathHighlightIndices.map((idx) => { const fileNameStart = file.filePath.lastIndexOf(fileName) return idx >= fileNameStart ? idx - fileNameStart : -1 }).filter((idx) => idx >= 0) : null, - description: isRootLevel ? '.' : file.filePath, + description: isRootLevel ? '.' : displayPath, descriptionHighlightIndices: isRootLevel ? null : file.pathHighlightIndices, } }) diff --git a/cli/src/hooks/use-theme.tsx b/cli/src/hooks/use-theme.tsx index 010f29b6d1..57f8144a30 100644 --- a/cli/src/hooks/use-theme.tsx +++ b/cli/src/hooks/use-theme.tsx @@ -6,8 +6,8 @@ import { create } from 'zustand' -import { themeConfig, buildTheme } from '../utils/theme-config' import { getCliEnv } from '../utils/env' +import { themeConfig, buildTheme } from '../utils/theme-config' import { chatThemes, cloneChatTheme, diff --git a/cli/src/hooks/use-update-preference.ts b/cli/src/hooks/use-update-preference.ts new file mode 100644 index 0000000000..7c72f304bb --- /dev/null +++ b/cli/src/hooks/use-update-preference.ts @@ -0,0 +1,66 @@ +import { useCallback, useState } from 'react' + +import { + getActivityQueryData, + invalidateActivityQuery, + setActivityQueryData, +} from './use-activity-query' +import { subscriptionQueryKeys } from './use-subscription-query' +import { showClipboardMessage } from '../utils/clipboard' +import { getApiClient } from '../utils/codebuff-api' +import { logger } from '../utils/logger' + +import type { SubscriptionResponse } from '@codebuff/common/types/subscription' + +interface UpdatePreferenceParams { + fallbackToALaCarte?: boolean +} + +export function useUpdatePreference() { + const [isPending, setIsPending] = useState(false) + + const mutate = useCallback(async (params: UpdatePreferenceParams) => { + const queryKey = subscriptionQueryKeys.current() + + // Snapshot the previous value for rollback + const previousData = getActivityQueryData(queryKey) + + // Optimistically update to the new value + if (previousData && params.fallbackToALaCarte !== undefined) { + setActivityQueryData(queryKey, { + ...previousData, + fallbackToALaCarte: params.fallbackToALaCarte, + }) + } + + setIsPending(true) + + try { + const client = getApiClient() + const response = await client.patch<{ success: boolean; error?: string }>( + '/api/user/preferences', + params as Record, + { includeCookie: true }, + ) + + if (!response.ok) { + const errorMessage = response.error || 'Failed to update preference' + throw new Error(errorMessage) + } + + // Invalidate to refetch fresh data from server + invalidateActivityQuery(queryKey) + } catch (err) { + // Rollback to previous value on error + if (previousData) { + setActivityQueryData(queryKey, previousData) + } + logger.error({ err }, 'Failed to update preference') + showClipboardMessage('Failed to update preference', { durationMs: 3000 }) + } finally { + setIsPending(false) + } + }, []) + + return { mutate, isPending } +} diff --git a/cli/src/hooks/use-usage-monitor.ts b/cli/src/hooks/use-usage-monitor.ts index 28a2165e21..ad98460101 100644 --- a/cli/src/hooks/use-usage-monitor.ts +++ b/cli/src/hooks/use-usage-monitor.ts @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import { useUsageQuery } from './use-usage-query' +import { IS_FREEBUFF } from '../utils/constants' import { useChatStore } from '../state/chat-store' import { getAuthToken } from '../utils/auth' import { shouldAutoShowBanner } from '../utils/usage-banner-state' @@ -19,9 +20,11 @@ export function useUsageMonitor() { const lastWarnedThresholdRef = useRef(null) // Query usage data - this will refetch when invalidated after message completion - const { data: usageData } = useUsageQuery({ enabled: true }) + const { data: usageData } = useUsageQuery({ enabled: !IS_FREEBUFF }) useEffect(() => { + if (IS_FREEBUFF) return + // Only show after user has sent at least one message (to avoid overwhelming on app start) if (sessionCreditsUsed === 0) { return diff --git a/cli/src/hooks/use-user-details-query.ts b/cli/src/hooks/use-user-details-query.ts index 4c3f335ae9..fa5f7524c2 100644 --- a/cli/src/hooks/use-user-details-query.ts +++ b/cli/src/hooks/use-user-details-query.ts @@ -37,12 +37,13 @@ export async function fetchUserDetails({ logger = defaultLogger, apiClient: providedApiClient, }: FetchUserDetailsParams): Promise | null> { - const apiClient = - providedApiClient ?? - (() => { - setApiClientAuthToken(authToken) - return getApiClient() - })() + let apiClient: CodebuffApiClient + if (providedApiClient) { + apiClient = providedApiClient + } else { + setApiClientAuthToken(authToken) + apiClient = getApiClient() + } const response = await apiClient.me(fields) diff --git a/cli/src/hooks/use-why-did-you-update.ts b/cli/src/hooks/use-why-did-you-update.ts index 3d1b0a3c2b..d567e5b983 100644 --- a/cli/src/hooks/use-why-did-you-update.ts +++ b/cli/src/hooks/use-why-did-you-update.ts @@ -1,7 +1,6 @@ import { useEffect, useRef } from 'react' import { getCliEnv } from '../utils/env' - import { logger } from '../utils/logger' /** diff --git a/cli/src/index.tsx b/cli/src/index.tsx index 0798df1b8e..302ccaeac6 100644 --- a/cli/src/index.tsx +++ b/cli/src/index.tsx @@ -1,5 +1,12 @@ #!/usr/bin/env bun +// Embed tree-sitter.wasm into the bun-compile binary at a bunfs path the runtime +// can find. Without this, web-tree-sitter resolves the wasm via require.resolve, +// which (since 0.25.10's split exports map) returns the build-time absolute path +// of tree-sitter.cjs and fails on user machines. Must run before the SDK / code-map +// import chain triggers Parser.init. +import './pre-init/tree-sitter-wasm' + import fs from 'fs' import { createRequire } from 'module' import os from 'os' @@ -20,17 +27,21 @@ import React from 'react' import { App } from './app' import { handlePublish } from './commands/publish' +import { runPlainLogin } from './login/plain-login' import { initializeApp } from './init/init-app' import { getProjectRoot, setProjectRoot } from './project-files' -import { initAnalytics, trackEvent } from './utils/analytics' -import { getAuthTokenDetails } from './utils/auth' +import { trackEvent } from './utils/analytics' +import { getAuthToken, getAuthTokenDetails } from './utils/auth' import { resetCodebuffClient } from './utils/codebuff-client' +import { setApiClientAuthToken } from './utils/codebuff-api' +import { IS_FREEBUFF } from './utils/constants' import { getCliEnv } from './utils/env' import { initializeAgentRegistry } from './utils/local-agent-registry' import { clearLogFile, logger } from './utils/logger' import { shouldShowProjectPicker } from './utils/project-picker' import { saveRecentProject } from './utils/recent-projects' -import { installProcessCleanupHandlers } from './utils/renderer-cleanup' +import { installProcessCleanupHandlers, TERMINAL_RESET_SEQUENCES } from './utils/renderer-cleanup' +import { initializeSkillRegistry } from './utils/skill-registry' import { detectTerminalTheme } from './utils/terminal-color-detection' import { setOscDetectedTheme } from './utils/theme-system' @@ -62,7 +73,7 @@ function loadPackageVersion(): string { // Without this, refetchInterval won't work because TanStack Query thinks the app is "unfocused" focusManager.setEventListener(() => { // No-op: no event listeners in CLI environment (no window focus/visibility events) - return () => {} + return () => { } }) focusManager.setFocused(true) @@ -97,30 +108,52 @@ type ParsedArgs = { function parseArgs(): ParsedArgs { const program = new Command() - program - .name('codebuff') - .description('Codebuff CLI - AI-powered coding assistant') - .version(loadPackageVersion(), '-v, --version', 'Print the CLI version') - .option( - '--agent ', - 'Run a specific agent id (skips loading local .agents overrides)', - ) - .option('--clear-logs', 'Remove any existing CLI log files before starting') - .option( - '--continue [conversation-id]', - 'Continue from a previous conversation (optionally specify a conversation id)', - ) - .option( - '--cwd ', - 'Set the working directory (default: current directory)', - ) - .option('--lite', 'Start in LITE mode') - .option('--max', 'Start in MAX mode') - .option('--plan', 'Start in PLAN mode') - .helpOption('-h, --help', 'Show this help message') - .argument('[prompt...]', 'Initial prompt to send to the agent') - .allowExcessArguments(true) - .parse(process.argv) + if (IS_FREEBUFF) { + // Freebuff: simplified CLI - no prompt args, no agent override, no clear-logs + program + .name('freebuff') + .description('Freebuff - Free AI coding assistant') + .version(loadPackageVersion(), '-v, --version', 'Print the CLI version') + .option( + '--continue [conversation-id]', + 'Continue from a previous conversation (optionally specify a conversation id)', + ) + .option( + '--cwd ', + 'Set the working directory (default: current directory)', + ) + .addHelpText('after', '\nCommands:\n login Log in to your account') + .helpOption('-h, --help', 'Show this help message') + .parse(process.argv) + } else { + // Codebuff: full CLI with all options + program + .name('codebuff') + .description('Codebuff CLI - AI-powered coding assistant') + .version(loadPackageVersion(), '-v, --version', 'Print the CLI version') + .option( + '--agent ', + 'Run a specific agent id (skips loading local .agents overrides)', + ) + .option('--clear-logs', 'Remove any existing CLI log files before starting') + .option( + '--continue [conversation-id]', + 'Continue from a previous conversation (optionally specify a conversation id)', + ) + .option( + '--cwd ', + 'Set the working directory (default: current directory)', + ) + .option('--lite', 'Start in LITE mode') + .option('--free', 'Start in LITE mode (deprecated alias)') + .option('--max', 'Start in MAX mode') + .option('--plan', 'Start in PLAN mode') + .addHelpText('after', '\nCommands:\n login Log in to your account\n publish Publish agents to the registry') + .helpOption('-h, --help', 'Show this help message') + .argument('[prompt...]', 'Initial prompt to send to the agent') + .allowExcessArguments(true) + .parse(process.argv) + } const options = program.opts() const args = program.args @@ -128,10 +161,15 @@ function parseArgs(): ParsedArgs { const continueFlag = options.continue // Determine initial mode from flags (last flag wins if multiple specified) + // Freebuff always uses LITE mode let initialMode: AgentMode | undefined - if (options.lite) initialMode = 'LITE' - if (options.max) initialMode = 'MAX' - if (options.plan) initialMode = 'PLAN' + if (IS_FREEBUFF) { + initialMode = 'LITE' + } else { + if (options.free || options.lite) initialMode = 'LITE' + if (options.max) initialMode = 'MAX' + if (options.plan) initialMode = 'PLAN' + } return { initialPrompt: args.length > 0 ? args.join(' ') : null, @@ -148,6 +186,82 @@ function parseArgs(): ParsedArgs { } async function main(): Promise { + // CI gate: ` --smoke-tree-sitter` proves the embedded wasm boots + // through Parser.init end-to-end. Has to live BEFORE commander.parse() — + // an earlier attempt put this in a pre-init module with top-level await, + // and on Windows that didn't actually pause module evaluation (commander + // still ran first and rejected the unknown flag). + if (process.argv.includes('--smoke-tree-sitter')) { + const wasmBinary = ( + globalThis as { __CODEBUFF_TREE_SITTER_WASM_BINARY__?: Uint8Array } + ).__CODEBUFF_TREE_SITTER_WASM_BINARY__ + const wasmPath = ( + globalThis as { __CODEBUFF_TREE_SITTER_WASM_PATH__?: string } + ).__CODEBUFF_TREE_SITTER_WASM_PATH__ + + // Diagnostic dump so CI logs (and bug reports) show exactly what + // the runtime saw when smoke fails. process.execPath, the + // siblingPath we expect, and what's actually in that directory. + const fs = await import('fs') + const path = await import('path') + const execDir = path.dirname(process.execPath) + const siblingPath = path.join(execDir, 'tree-sitter.wasm') + let dirListing: string[] = [] + try { + dirListing = fs.readdirSync(execDir) + } catch (err) { + dirListing = [``] + } + console.error( + `[smoke diag] execPath=${process.execPath}\n` + + `[smoke diag] execDir=${execDir}\n` + + `[smoke diag] siblingPath=${siblingPath}\n` + + `[smoke diag] siblingExists=${fs.existsSync(siblingPath)}\n` + + `[smoke diag] dir contents (${dirListing.length}): ${dirListing.slice(0, 30).join(', ')}\n` + + `[smoke diag] globalThis wasmPath=${wasmPath ?? ''}\n` + + `[smoke diag] globalThis wasmBinary bytes=${wasmBinary?.byteLength ?? 0}\n`, + ) + + try { + const { Parser } = await import('web-tree-sitter') + // Pick the best wasm source available, falling back to the + // sibling-of-execPath lookup if pre-init couldn't reach it. By + // main() time process.execPath has stabilized to the disk path + // even on Windows, where it was the bunfs path during pre-init. + let effectiveBinary = wasmBinary + let effectivePath = wasmPath + if (!effectiveBinary && !effectivePath && fs.existsSync(siblingPath)) { + effectivePath = siblingPath + effectiveBinary = new Uint8Array(fs.readFileSync(siblingPath)) + } + + if (effectiveBinary) { + await Parser.init({ wasmBinary: effectiveBinary }) + // Marker grepped by cli/scripts/smoke-binary.ts — keep this exact text. + console.log( + `tree-sitter smoke ok (wasmBinary, ${effectiveBinary.byteLength} bytes)`, + ) + } else if (effectivePath) { + await Parser.init({ + locateFile: (name: string) => + name === 'tree-sitter.wasm' ? effectivePath! : name, + }) + console.log(`tree-sitter smoke ok (locateFile, path=${effectivePath})`) + } else { + console.error( + 'tree-sitter smoke FAIL: no wasm available — pre-init published ' + + 'nothing and the sibling-of-execPath fallback also missed. See ' + + 'the diag above for paths.', + ) + process.exit(1) + } + process.exit(0) + } catch (err) { + console.error('tree-sitter smoke FAIL:', err) + process.exit(1) + } + } + // Run OSC theme detection BEFORE anything else. // This MUST happen before OpenTUI starts because OSC responses come through stdin, // and OpenTUI also listens to stdin. Running detection here ensures stdin is clean. @@ -172,23 +286,48 @@ async function main(): Promise { initialMode, } = parseArgs() - const isPublishCommand = process.argv.includes('publish') - const hasAgentOverride = Boolean(agent && agent.trim().length > 0) + const isLoginCommand = process.argv[2] === 'login' + const isPublishCommand = process.argv[2] === 'publish' + const hasAgentOverride = Boolean(agent?.trim()) await initializeApp({ cwd }) + // Set the auth token for the API client + setApiClientAuthToken(getAuthToken()) + + // Handle login command before rendering the app + if (isLoginCommand) { + await runPlainLogin() + return + } + // Show project picker only when user starts at the home directory or an ancestor const projectRoot = getProjectRoot() const homeDir = os.homedir() const startCwd = process.cwd() const showProjectPicker = shouldShowProjectPicker(startCwd, homeDir) + // Requires analytics to be initialized, which is done in initializeApp + trackEvent(AnalyticsEvent.APP_LAUNCHED, { + version: loadPackageVersion(), + platform: process.platform, + arch: process.arch, + hasInitialPrompt: Boolean(initialPrompt), + hasAgentOverride: hasAgentOverride, + continueChat, + initialMode: initialMode ?? 'DEFAULT', + isFreeBuff: IS_FREEBUFF, + }) + // Initialize agent registry (loads user agents via SDK). // When --agent is provided, skip local .agents to avoid overrides. if (isPublishCommand || !hasAgentOverride) { await initializeAgentRegistry() } + // Initialize skill registry (loads skills from .agents/skills) + await initializeSkillRegistry() + // Handle publish command before rendering the app if (isPublishCommand) { const publishIndex = process.argv.indexOf('publish') @@ -214,25 +353,6 @@ async function main(): Promise { } } - // Initialize analytics - try { - initAnalytics() - - // Track app launch event - trackEvent(AnalyticsEvent.APP_LAUNCHED, { - version: loadPackageVersion(), - platform: process.platform, - arch: process.arch, - hasInitialPrompt: Boolean(initialPrompt), - hasAgentOverride: hasAgentOverride, - continueChat, - initialMode: initialMode ?? 'DEFAULT', - }) - } catch (error) { - // Analytics initialization is optional - don't fail the app if it errors - logger.debug(error, 'Failed to initialize analytics') - } - if (clearLogs) { clearLogFile() } @@ -269,7 +389,6 @@ async function main(): Promise { projectRoot: root, fs: fs.promises, }) - logger.info({ tree }, 'Loaded file tree') setFileTree(tree) } } catch (error) { @@ -284,7 +403,6 @@ async function main(): Promise { // Callback for when user selects a new project from the picker const handleProjectChange = React.useCallback( async (newProjectPath: string) => { - const previousPath = process.cwd() // Change process working directory process.chdir(newProjectPath) @@ -328,10 +446,43 @@ async function main(): Promise { ) } + // Install early error handlers BEFORE renderer creation. + // If the renderer crashes during init, these ensure the error is visible + // by exiting the alternate screen buffer before printing the error. + const earlyFatalHandler = (error: unknown) => { + try { + if (process.stdin.isTTY && process.stdin.setRawMode) { + process.stdin.setRawMode(false) + } + } catch { + // stdin may be closed + } + try { + if (process.stdout.isTTY) { + process.stdout.write(TERMINAL_RESET_SEQUENCES) + } + } catch { + // stdout may be closed + } + try { + console.error('Fatal error during startup:', error) + } catch { + // stderr may be closed + } + process.exit(1) + } + process.on('uncaughtException', earlyFatalHandler) + process.on('unhandledRejection', earlyFatalHandler) + const renderer = await createCliRenderer({ backgroundColor: 'transparent', exitOnCtrlC: false, + screenMode: 'alternate-screen', }) + + // Remove early handlers — proper cleanup handlers (with renderer access) take over + process.removeListener('uncaughtException', earlyFatalHandler) + process.removeListener('unhandledRejection', earlyFatalHandler) installProcessCleanupHandlers(renderer) createRoot(renderer).render( diff --git a/cli/src/init/__tests__/init-direnv.test.ts b/cli/src/init/__tests__/init-direnv.test.ts new file mode 100644 index 0000000000..9c5342b80e --- /dev/null +++ b/cli/src/init/__tests__/init-direnv.test.ts @@ -0,0 +1,526 @@ +import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test' +import type { SpawnSyncReturns } from 'child_process' +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { + findEnvrcDirectory, + isDirenvAvailable, + getDirenvExport, + initializeDirenv, +} from '../init-direnv' + +mock.module('../utils/logger', () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, +})) + +describe('init-direnv', () => { + describe('findEnvrcDirectory', () => { + let tempDir: string + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'direnv-test-')) + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + test('returns null when no .envrc exists', () => { + const subDir = path.join(tempDir, 'project', 'src') + fs.mkdirSync(subDir, { recursive: true }) + + const result = findEnvrcDirectory(subDir) + expect(result).toBeNull() + }) + + test('finds .envrc in the current directory', () => { + fs.writeFileSync(path.join(tempDir, '.envrc'), 'export FOO=bar') + + const result = findEnvrcDirectory(tempDir) + expect(result).toBe(tempDir) + }) + + test('finds .envrc in a parent directory', () => { + const subDir = path.join(tempDir, 'project', 'src', 'components') + fs.mkdirSync(subDir, { recursive: true }) + fs.writeFileSync(path.join(tempDir, '.envrc'), 'export FOO=bar') + + const result = findEnvrcDirectory(subDir) + expect(result).toBe(tempDir) + }) + + test('finds .envrc in an intermediate parent directory', () => { + const projectDir = path.join(tempDir, 'project') + const subDir = path.join(projectDir, 'src', 'components') + fs.mkdirSync(subDir, { recursive: true }) + fs.writeFileSync(path.join(projectDir, '.envrc'), 'export FOO=bar') + + const result = findEnvrcDirectory(subDir) + expect(result).toBe(projectDir) + }) + + test('stops searching at git root when no .envrc found', () => { + const projectDir = path.join(tempDir, 'project') + const subDir = path.join(projectDir, 'src') + fs.mkdirSync(subDir, { recursive: true }) + fs.mkdirSync(path.join(tempDir, '.git')) + + const result = findEnvrcDirectory(subDir) + expect(result).toBeNull() + }) + + test('finds .envrc at git root', () => { + const projectDir = path.join(tempDir, 'project') + const subDir = path.join(projectDir, 'src') + fs.mkdirSync(subDir, { recursive: true }) + fs.mkdirSync(path.join(tempDir, '.git')) + fs.writeFileSync(path.join(tempDir, '.envrc'), 'export FOO=bar') + + const result = findEnvrcDirectory(subDir) + expect(result).toBe(tempDir) + }) + + test('does not search above git root', () => { + const repoDir = path.join(tempDir, 'repo') + const srcDir = path.join(repoDir, 'src') + fs.mkdirSync(srcDir, { recursive: true }) + fs.mkdirSync(path.join(repoDir, '.git')) + fs.writeFileSync(path.join(tempDir, '.envrc'), 'export FOO=bar') + + const result = findEnvrcDirectory(srcDir) + expect(result).toBeNull() + }) + + test('finds .envrc in nested git repo (submodule scenario)', () => { + const submoduleDir = path.join(tempDir, 'packages', 'submodule') + const srcDir = path.join(submoduleDir, 'src') + fs.mkdirSync(srcDir, { recursive: true }) + fs.mkdirSync(path.join(tempDir, '.git')) + fs.mkdirSync(path.join(submoduleDir, '.git')) + fs.writeFileSync(path.join(submoduleDir, '.envrc'), 'export FOO=bar') + + const result = findEnvrcDirectory(srcDir) + expect(result).toBe(submoduleDir) + }) + + test('prefers closer .envrc over farther one', () => { + const projectDir = path.join(tempDir, 'project') + const subDir = path.join(projectDir, 'src') + fs.mkdirSync(subDir, { recursive: true }) + fs.writeFileSync(path.join(tempDir, '.envrc'), 'export ROOT=true') + fs.writeFileSync(path.join(projectDir, '.envrc'), 'export PROJECT=true') + + const result = findEnvrcDirectory(subDir) + expect(result).toBe(projectDir) + }) + + test('handles non-existent start directory gracefully', () => { + const nonExistent = path.join(tempDir, 'does', 'not', 'exist') + const result = findEnvrcDirectory(nonExistent) + expect(result).toBeNull() + }) + + test('handles unreadable directory gracefully', () => { + const restrictedDir = path.join(tempDir, 'restricted') + fs.mkdirSync(restrictedDir) + + if (os.platform() === 'win32' || process.getuid?.() === 0) return + + fs.chmodSync(restrictedDir, 0o000) + try { + const result = findEnvrcDirectory(restrictedDir) + expect(result).toBeNull() + } finally { + fs.chmodSync(restrictedDir, 0o755) + } + }) + + test('resolves relative paths', () => { + fs.writeFileSync(path.join(tempDir, '.envrc'), 'export FOO=bar') + + const originalCwd = process.cwd() + try { + process.chdir(tempDir) + const result = findEnvrcDirectory('.') + expect(result).toBe(fs.realpathSync(tempDir)) + } finally { + process.chdir(originalCwd) + } + }) + + test('handles symlinked directories', () => { + const actualDir = path.join(tempDir, 'actual') + fs.mkdirSync(actualDir) + fs.writeFileSync(path.join(actualDir, '.envrc'), 'export FOO=bar') + + const linkDir = path.join(tempDir, 'link') + fs.symlinkSync(actualDir, linkDir) + + const result = findEnvrcDirectory(linkDir) + expect(result).not.toBeNull() + }) + }) + + describe('isDirenvAvailable', () => { + test('returns boolean', () => { + const result = isDirenvAvailable() + expect(typeof result).toBe('boolean') + }) + + test('returns false on Windows', () => { + const result = isDirenvAvailable() + expect(typeof result).toBe('boolean') + if (os.platform() === 'win32') { + expect(result).toBe(false) + } + }) + + test('returns consistent results on repeated calls', () => { + const result1 = isDirenvAvailable() + const result2 = isDirenvAvailable() + const result3 = isDirenvAvailable() + + expect(result1).toBe(result2) + expect(result2).toBe(result3) + }) + }) + + describe('getDirenvExport', () => { + let tempDir: string + let spawnSyncSpy: ReturnType + let childProcess: typeof import('child_process') + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'direnv-export-test-')) + fs.writeFileSync(path.join(tempDir, '.envrc'), 'export FOO=bar') + childProcess = await import('child_process') + spawnSyncSpy = spyOn(childProcess, 'spawnSync') + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + spawnSyncSpy.mockRestore() + }) + + test('returns parsed env vars on successful export', () => { + spawnSyncSpy.mockReturnValue({ + status: 0, + stdout: JSON.stringify({ DATABASE_URL: 'postgres://localhost', API_KEY: 'secret' }), + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns) + + const result = getDirenvExport(tempDir) + + expect(result).toEqual({ + DATABASE_URL: 'postgres://localhost', + API_KEY: 'secret', + }) + }) + + test('returns null values for unset variables', () => { + spawnSyncSpy.mockReturnValue({ + status: 0, + stdout: JSON.stringify({ KEEP: 'value', REMOVE: null }), + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns) + + const result = getDirenvExport(tempDir) + + expect(result).toEqual({ + KEEP: 'value', + REMOVE: null, + }) + }) + + test('returns null when direnv command fails (non-zero exit)', () => { + spawnSyncSpy.mockReturnValue({ + status: 1, + stdout: '', + stderr: 'direnv: error something went wrong', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns) + + const result = getDirenvExport(tempDir) + + expect(result).toBeNull() + }) + + test('returns null and warns when .envrc is blocked', () => { + spawnSyncSpy.mockReturnValue({ + status: 1, + stdout: '', + stderr: 'direnv: error /path/to/.envrc is blocked. Run `direnv allow` to approve its content', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns) + + const result = getDirenvExport(tempDir) + + expect(result).toBeNull() + }) + + test('returns null when stdout is empty (no env changes)', () => { + spawnSyncSpy.mockReturnValue({ + status: 0, + stdout: '', + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns) + + const result = getDirenvExport(tempDir) + + expect(result).toBeNull() + }) + + test('returns null when stdout is only whitespace', () => { + spawnSyncSpy.mockReturnValue({ + status: 0, + stdout: ' \n\t ', + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns) + + const result = getDirenvExport(tempDir) + + expect(result).toBeNull() + }) + + test('returns null when JSON output is invalid', () => { + spawnSyncSpy.mockReturnValue({ + status: 0, + stdout: 'not valid json {{{', + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns) + + const result = getDirenvExport(tempDir) + + expect(result).toBeNull() + }) + + test('returns null when spawnSync throws', () => { + spawnSyncSpy.mockImplementation(() => { + throw new Error('spawn failed') + }) + + const result = getDirenvExport(tempDir) + + expect(result).toBeNull() + }) + + test('passes correct arguments to spawnSync', () => { + spawnSyncSpy.mockReturnValue({ + status: 0, + stdout: '{}', + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns) + + getDirenvExport(tempDir) + + expect(spawnSyncSpy).toHaveBeenCalledWith('direnv', ['export', 'json'], { + cwd: tempDir, + encoding: 'utf-8', + timeout: 10000, + env: expect.objectContaining({ DIRENV_LOG_FORMAT: '' }), + }) + }) + }) + + describe('initializeDirenv', () => { + let tempDir: string + let spawnSyncSpy: ReturnType + let childProcess: typeof import('child_process') + let originalEnv: NodeJS.ProcessEnv + let originalCwd: string + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'direnv-init-test-')) + originalEnv = { ...process.env } + originalCwd = process.cwd() + childProcess = await import('child_process') + spawnSyncSpy = spyOn(childProcess, 'spawnSync') + }) + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key] + } + } + for (const [key, value] of Object.entries(originalEnv)) { + process.env[key] = value + } + process.chdir(originalCwd) + fs.rmSync(tempDir, { recursive: true, force: true }) + spawnSyncSpy.mockRestore() + }) + + test('sets environment variables from direnv export', () => { + fs.writeFileSync(path.join(tempDir, '.envrc'), 'export TEST_VAR=test_value') + process.chdir(tempDir) + + spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'sh' && args?.[1]?.includes('command -v direnv')) { + return { + status: 0, + stdout: '/usr/local/bin/direnv', + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns + } + if (cmd === 'direnv' && args?.[0] === 'export') { + return { + status: 0, + stdout: JSON.stringify({ TEST_VAR: 'test_value' }), + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns + } + return { status: 1, stdout: '', stderr: '', pid: 0, output: [], signal: null } as SpawnSyncReturns + }) + + initializeDirenv() + + expect(process.env.TEST_VAR).toBe('test_value') + }) + + test('unsets environment variables when direnv returns null', () => { + fs.writeFileSync(path.join(tempDir, '.envrc'), 'unset OLD_VAR') + process.chdir(tempDir) + process.env.OLD_VAR = 'should_be_removed' + + spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'sh' && args?.[1]?.includes('command -v direnv')) { + return { + status: 0, + stdout: '/usr/local/bin/direnv', + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns + } + if (cmd === 'direnv' && args?.[0] === 'export') { + return { + status: 0, + stdout: JSON.stringify({ OLD_VAR: null }), + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns + } + return { status: 1, stdout: '', stderr: '', pid: 0, output: [], signal: null } as SpawnSyncReturns + }) + + initializeDirenv() + + expect(process.env.OLD_VAR).toBeUndefined() + }) + + test('does nothing when direnv is not available', () => { + fs.writeFileSync(path.join(tempDir, '.envrc'), 'export SHOULD_NOT_SET=value') + process.chdir(tempDir) + + spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'sh' && args?.[1]?.includes('command -v direnv')) { + return { + status: 1, + stdout: '', + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns + } + throw new Error('direnv should not be called when not available') + }) + + initializeDirenv() + + expect(process.env.SHOULD_NOT_SET).toBeUndefined() + }) + + test('does nothing when no .envrc exists', () => { + process.chdir(tempDir) + + spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'sh' && args?.[1]?.includes('command -v direnv')) { + return { + status: 0, + stdout: '/usr/local/bin/direnv', + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns + } + throw new Error('direnv should not be called when no .envrc') + }) + + initializeDirenv() + }) + + test('does nothing when direnv export fails', () => { + fs.writeFileSync(path.join(tempDir, '.envrc'), 'export SHOULD_NOT_SET=value') + process.chdir(tempDir) + + spawnSyncSpy.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'sh' && args?.[1]?.includes('command -v direnv')) { + return { + status: 0, + stdout: '/usr/local/bin/direnv', + stderr: '', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns + } + if (cmd === 'direnv' && args?.[0] === 'export') { + return { + status: 1, + stdout: '', + stderr: 'error', + pid: 1234, + output: [], + signal: null, + } as SpawnSyncReturns + } + return { status: 1, stdout: '', stderr: '', pid: 0, output: [], signal: null } as SpawnSyncReturns + }) + + initializeDirenv() + + expect(process.env.SHOULD_NOT_SET).toBeUndefined() + }) + }) +}) diff --git a/cli/src/init/init-app.ts b/cli/src/init/init-app.ts index 936e3b4b5e..17ecc61810 100644 --- a/cli/src/init/init-app.ts +++ b/cli/src/init/init-app.ts @@ -1,14 +1,17 @@ -import { enableMapSet } from 'immer' - +import { CHATGPT_OAUTH_ENABLED } from '@codebuff/common/constants/chatgpt-oauth' import { - getClaudeOAuthCredentials, - getValidClaudeOAuthCredentials, + getChatGptOAuthCredentials, + getValidChatGptOAuthCredentials, } from '@codebuff/sdk' +import { enableMapSet } from 'immer' import { initializeThemeStore } from '../hooks/use-theme' import { setProjectRoot } from '../project-files' import { initTimestampFormatter } from '../utils/helpers' import { enableManualThemeRefresh } from '../utils/theme-system' +import { initAnalytics } from '../utils/analytics' +import { getFingerprintId } from '../utils/fingerprint' +import { initializeDirenv } from './init-direnv' export async function initializeApp(params: { cwd?: string }): Promise { if (params.cwd) { @@ -17,17 +20,33 @@ export async function initializeApp(params: { cwd?: string }): Promise { const baseCwd = process.cwd() setProjectRoot(baseCwd) + // Initialize analytics before direnv, because direnv uses the logger + // which calls trackEvent — analytics must be ready first. + try { + initAnalytics() + } catch (error) { + console.debug('Failed to initialize analytics:', error) + } + + // Initialize direnv environment before anything else + initializeDirenv() + enableMapSet() initializeThemeStore() enableManualThemeRefresh() initTimestampFormatter() - // Refresh Claude OAuth credentials in the background if they exist - // This ensures the subscription status is up-to-date on startup - const claudeCredentials = getClaudeOAuthCredentials() - if (claudeCredentials) { - void getValidClaudeOAuthCredentials().catch(() => { - // Silently ignore refresh errors - will be retried on next API call - }) + // Compute the hardware-based fingerprint in the background so it's ready + // by the time the user finishes reading the login prompt. + void getFingerprintId() + + // Refresh ChatGPT OAuth credentials in the background if they exist + if (CHATGPT_OAUTH_ENABLED) { + const chatGptCredentials = getChatGptOAuthCredentials() + if (chatGptCredentials) { + getValidChatGptOAuthCredentials().catch(() => { + // Best-effort background refresh. + }) + } } } diff --git a/cli/src/init/init-direnv.ts b/cli/src/init/init-direnv.ts new file mode 100644 index 0000000000..aa8a113d1d --- /dev/null +++ b/cli/src/init/init-direnv.ts @@ -0,0 +1,133 @@ +/** + * Direnv initialization - loads environment variables from .envrc at CLI startup. + */ + +import { spawnSync } from 'child_process' +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { logger } from '../utils/logger' + +/** + * Search up the directory tree for .envrc, stopping at git root. + * @internal + */ +export function findEnvrcDirectory(startDir: string): string | null { + let currentDir = path.resolve(startDir) + const root = path.parse(currentDir).root + + while (currentDir !== root) { + // Read directory entries once and check for both .envrc and .git + let entries: string[] + try { + entries = fs.readdirSync(currentDir) + } catch { + // Directory not readable - stop searching + break + } + + const hasEnvrc = entries.includes('.envrc') + const hasGit = entries.includes('.git') + + if (hasEnvrc) { + return currentDir + } + + // If this is a git root and no .envrc found, stop searching + if (hasGit) { + break + } + + const parentDir = path.dirname(currentDir) + if (parentDir === currentDir) break + currentDir = parentDir + } + + return null +} + +/** @internal */ +export function isDirenvAvailable(): boolean { + if (os.platform() === 'win32') { + return false + } + + try { + const result = spawnSync('sh', ['-c', 'command -v direnv'], { + encoding: 'utf-8', + timeout: 2000, + }) + return result.status === 0 && result.stdout.trim().length > 0 + } catch { + return false + } +} + +/** @internal */ +export function getDirenvExport(envrcDir: string): Record | null { + try { + const result = spawnSync('direnv', ['export', 'json'], { + cwd: envrcDir, + encoding: 'utf-8', + timeout: 10000, + env: { ...process.env, DIRENV_LOG_FORMAT: '' }, + }) + + if (result.status !== 0) { + if (result.stderr?.includes('is blocked')) { + logger.warn( + 'direnv: .envrc is blocked. Run `direnv allow` to enable.', + ) + } + return null + } + + const output = result.stdout.trim() + if (!output) { + return null + } + + const envVars = JSON.parse(output) as Record + return envVars + } catch (error) { + logger.debug( + { error: error instanceof Error ? error.message : String(error) }, + 'Failed to run direnv export', + ) + return null + } +} + +/** Load direnv environment into process.env. Safe to call even if direnv is not installed. */ +export function initializeDirenv(): void { + if (!isDirenvAvailable()) { + return + } + + const envrcDir = findEnvrcDirectory(process.cwd()) + if (!envrcDir) { + return + } + + const envVars = getDirenvExport(envrcDir) + if (!envVars) { + return + } + let appliedCount = 0 + for (const [key, value] of Object.entries(envVars)) { + if (value === null) { + delete process.env[key] + } else { + process.env[key] = value + } + appliedCount++ + } + + if (appliedCount > 0) { + logger.debug( + { envrcDir, variableCount: appliedCount }, + 'Loaded environment variables from direnv', + ) + } +} diff --git a/cli/src/login/constants.ts b/cli/src/login/constants.ts index f60b6bc2b5..7328230b8f 100644 --- a/cli/src/login/constants.ts +++ b/cli/src/login/constants.ts @@ -1,10 +1,16 @@ -import { env } from '@codebuff/common/env' +import { env, IS_DEV } from '@codebuff/common/env' + +import { IS_FREEBUFF } from '../utils/constants' // Get the website URL from environment or use default export const WEBSITE_URL = env.NEXT_PUBLIC_CODEBUFF_APP_URL +// Freebuff login flow uses the freebuff web app instead of codebuff.com +const FREEBUFF_WEB_URL = IS_DEV ? 'http://localhost:3002' : 'https://freebuff.com' +export const LOGIN_WEBSITE_URL = IS_FREEBUFF ? FREEBUFF_WEB_URL : WEBSITE_URL + // Codebuff ASCII Logo - compact version for 80-width terminals -export const LOGO = ` +const LOGO_CODEBUFF = ` ██████╗ ██████╗ ██████╗ ███████╗██████╗ ██╗ ██╗███████╗███████╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗██║ ██║██╔════╝██╔════╝ ██║ ██║ ██║██║ ██║█████╗ ██████╔╝██║ ██║█████╗ █████╗ @@ -13,7 +19,7 @@ export const LOGO = ` ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ` -export const LOGO_SMALL = ` +const LOGO_SMALL_CODEBUFF = ` ██████╗ ██████╗ ██╔════╝ ██╔══██╗ ██║ ██████╔╝ @@ -22,6 +28,28 @@ export const LOGO_SMALL = ` ╚═════╝ ╚═════╝ ` +// Freebuff ASCII Logo +const LOGO_FREEBUFF = ` + ███████╗██████╗ ███████╗███████╗██████╗ ██╗ ██╗███████╗███████╗ + ██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗██║ ██║██╔════╝██╔════╝ + █████╗ ██████╔╝█████╗ █████╗ ██████╔╝██║ ██║█████╗ █████╗ + ██╔══╝ ██╔══██╗██╔══╝ ██╔══╝ ██╔══██╗██║ ██║██╔══╝ ██╔══╝ + ██║ ██║ ██║███████╗███████╗██████╔╝╚██████╔╝██║ ██║ + ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝ +` + +const LOGO_SMALL_FREEBUFF = ` + ███████╗██████╗ + ██╔════╝██╔══██╗ + █████╗ ██████╔╝ + ██╔══╝ ██╔══██╗ + ██║ ██████╔╝ + ╚═╝ ╚═════╝ +` + +export const LOGO = IS_FREEBUFF ? LOGO_FREEBUFF : LOGO_CODEBUFF +export const LOGO_SMALL = IS_FREEBUFF ? LOGO_SMALL_FREEBUFF : LOGO_SMALL_CODEBUFF + // Shadow/border characters that receive the sheen animation effect export const SHADOW_CHARS = new Set([ '╚', diff --git a/cli/src/login/plain-login.ts b/cli/src/login/plain-login.ts new file mode 100644 index 0000000000..9f2803b644 --- /dev/null +++ b/cli/src/login/plain-login.ts @@ -0,0 +1,85 @@ +import { cyan, green, red, yellow, bold } from 'picocolors' + +import { LOGIN_WEBSITE_URL } from './constants' +import { generateLoginUrl, pollLoginStatus } from './login-flow' +import { saveUserCredentials } from '../utils/auth' +import { IS_FREEBUFF } from '../utils/constants' +import { getFingerprintId } from '../utils/fingerprint' +import { logger } from '../utils/logger' + +import type { User } from '../utils/auth' + +/** + * Plain-text login flow that runs outside the TUI. + * Prints the login URL as plain text so the user can select and copy it + * using normal terminal text selection (Cmd+C / Ctrl+Shift+C). + * + * This is the escape hatch for remote/SSH environments where the TUI's + * clipboard and browser integration don't work. + */ +export async function runPlainLogin(): Promise { + const fingerprintId = await getFingerprintId() + + console.log() + console.log(bold(IS_FREEBUFF ? 'Freebuff Login' : 'Codebuff Login')) + console.log() + console.log('Generating login URL...') + + let loginData + try { + loginData = await generateLoginUrl( + { logger }, + { baseUrl: LOGIN_WEBSITE_URL, fingerprintId }, + ) + } catch (error) { + console.error( + red( + `Failed to generate login URL: ${ + error instanceof Error ? error.message : String(error) + }`, + ), + ) + process.exit(1) + } + + console.log() + console.log('Open this URL in your browser to log in:') + console.log() + console.log(cyan(loginData.loginUrl)) + console.log() + console.log(yellow('Please open the URL above manually to complete login.')) + console.log() + console.log('Waiting for login...') + + const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + + const result = await pollLoginStatus( + { sleep, logger }, + { + baseUrl: LOGIN_WEBSITE_URL, + fingerprintId, + fingerprintHash: loginData.fingerprintHash, + expiresAt: loginData.expiresAt, + }, + ) + + if (result.status === 'success') { + const user = result.user as User + saveUserCredentials(user) + console.log() + console.log(green(`✓ Logged in as ${user.name} (${user.email})`)) + console.log() + const cliName = IS_FREEBUFF ? 'freebuff' : 'codebuff' + console.log('You can now run ' + cyan(cliName) + ' to start.') + process.exit(0) + } else if (result.status === 'timeout') { + console.error(red('Login timed out. Please try again.')) + process.exit(1) + } else { + console.error(red('Login was aborted.')) + process.exit(1) + } +} diff --git a/cli/src/login/utils.ts b/cli/src/login/utils.ts index 354f6a920b..2063dd2c77 100644 --- a/cli/src/login/utils.ts +++ b/cli/src/login/utils.ts @@ -54,13 +54,6 @@ export function formatUrl(url: string, maxWidth?: number): string[] { return lines } -/** - * Generates a unique fingerprint ID for CLI authentication - */ -export function generateFingerprintId(): string { - return `codebuff-cli-${Math.random().toString(36).substring(2, 15)}` -} - /** * Determines the color for a character based on its position relative to the sheen * Block characters use blockColor, shadow/border characters animate to accent green diff --git a/cli/src/native/ripgrep.ts b/cli/src/native/ripgrep.ts index 8f16ccc5be..6ecdf84299 100644 --- a/cli/src/native/ripgrep.ts +++ b/cli/src/native/ripgrep.ts @@ -1,9 +1,9 @@ import path from 'path' -import { getCliEnv } from '../utils/env' import { getBundledRgPath } from '@codebuff/sdk' import { spawnSync } from 'bun' +import { getCliEnv } from '../utils/env' import { logger } from '../utils/logger' const getRipgrepPath = async (): Promise => { diff --git a/cli/src/pre-init/tree-sitter-wasm.ts b/cli/src/pre-init/tree-sitter-wasm.ts new file mode 100644 index 0000000000..3d2409d191 --- /dev/null +++ b/cli/src/pre-init/tree-sitter-wasm.ts @@ -0,0 +1,96 @@ +// Find tree-sitter.wasm so the SDK's tree-sitter parser singleton can load +// it at runtime. Must be the very first import in `index.tsx`: subsequent +// imports (the SDK / code-map) eagerly construct the parser, and its init +// reads what we publish here on `globalThis` and via the env var. +// +// Final approach after several attempts to embed the wasm into the bun +// --compile binary all failed on Windows (the bytes ended up in the +// binary, but every JS-level retrieval mechanism — `with { type: 'file' }` +// import binding, base64 string literals, chunked base64 in a generated +// module, function-export wrappers — was either tree-shaken, transformed +// by the minifier, or otherwise stripped): +// +// ship tree-sitter.wasm as a sibling file next to the binary. +// +// It's 200KB, the npm tarball already contains the binary; adding one +// more file is trivial. The build script copies the wasm into `cli/bin/` +// after compile, the release workflow tarballs both, and the freebuff / +// codebuff downloader extracts both into the same directory. At runtime, +// `process.execPath` plus a relative file lookup gets us the wasm with +// zero bundler involvement. + +import { existsSync, readFileSync } from 'fs' +import { dirname, isAbsolute, join, resolve } from 'path' + +// Where to look for the sibling tree-sitter.wasm. We can't just use +// `dirname(process.execPath)`: at pre-init time inside a bun --compile +// binary on Windows, `process.execPath` returns the *bunfs* internal +// path (`B:\~BUN\root\.exe`) rather than the on-disk path of +// the .exe the user invoked. By the time main() runs it switches to +// the disk path, but pre-init has long since bailed out. +// +// Try several sources in order; the first whose sibling .wasm exists +// wins. argv[0] is normally the path the binary was invoked with — +// always a real disk path, never bunfs. execPath is kept as a fallback +// for environments where argv[0] is something exotic. +const candidates = ( + [process.argv[0], process.execPath] as Array +) + .filter((p): p is string => typeof p === 'string' && p.length > 0) + .map((p) => (isAbsolute(p) ? p : resolve(p))) + .map((p) => join(dirname(p), 'tree-sitter.wasm')) + +const siblingPath = candidates.find((p) => existsSync(p)) + +// Pre-init diagnostic — only fires when --smoke-tree-sitter is set so we +// don't spam every run. We need to see what argv[0] / execPath looked +// like at this exact phase on Windows: the round-7 main() diag showed +// disk paths, but pre-init silently bailed, meaning module-init time +// gives different values. argv[0] alone wasn't enough to fix it. +if (process.argv.includes('--smoke-tree-sitter')) { + console.error( + `[pre-init diag] argv[0]=${process.argv[0]}\n` + + `[pre-init diag] execPath=${process.execPath}\n` + + `[pre-init diag] candidates=${JSON.stringify(candidates)}\n` + + `[pre-init diag] resolved siblingPath=${siblingPath ?? ''}\n`, + ) +} + +if (siblingPath) { + // Tell init-node.ts (in code-map / the SDK bundle) where the wasm + // is. The locateFile callback there will hand this path to + // emscripten, which fs.readFile's it. + process.env.CODEBUFF_TREE_SITTER_WASM_PATH = siblingPath + + // Also publish on globalThis so the smoke handler in index.tsx can + // read it without touching process.env (which is gated by the env + // architecture check outside the allowlisted pre-init files). + ;( + globalThis as { __CODEBUFF_TREE_SITTER_WASM_PATH__?: string } + ).__CODEBUFF_TREE_SITTER_WASM_PATH__ = siblingPath + + // Also try the synchronous-bytes path: hand the bytes straight to + // Parser.init({ wasmBinary }) so the SDK doesn't need to round-trip + // through emscripten's path resolution. Both channels feed the same + // tree-sitter init; whichever one trips first wins. + try { + const buf = readFileSync(siblingPath) + ;( + globalThis as { __CODEBUFF_TREE_SITTER_WASM_BINARY__?: Uint8Array } + ).__CODEBUFF_TREE_SITTER_WASM_BINARY__ = new Uint8Array( + buf.buffer, + buf.byteOffset, + buf.byteLength, + ) + } catch (err) { + console.error( + '[tree-sitter pre-init] readFileSync failed for sibling wasm at', + siblingPath, + '—', + err instanceof Error ? err.message : String(err), + ) + } +} + +// `--smoke-tree-sitter` is the deterministic CI gate. The handler lives at +// the top of main() in cli/src/index.tsx (before parseArgs). diff --git a/cli/src/state/__tests__/feedback-store.test.ts b/cli/src/state/__tests__/feedback-store.test.ts index a2484b1c52..88d15695ea 100644 --- a/cli/src/state/__tests__/feedback-store.test.ts +++ b/cli/src/state/__tests__/feedback-store.test.ts @@ -32,18 +32,46 @@ describe('FeedbackStore', () => { expect(state.feedbackMode).toBe(true) expect(state.feedbackMessageId).toBeNull() }) + + it('should generate a clientFeedbackId UUID on open', () => { + const store = useFeedbackStore.getState() + + store.openFeedbackForMessage('message-123') + + const state = useFeedbackStore.getState() + expect(state.clientFeedbackId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ) + }) }) describe('closeFeedback', () => { - it('should close feedback mode', () => { + it('should close feedback mode and clear clientFeedbackId', () => { const store = useFeedbackStore.getState() store.openFeedbackForMessage('message-123') + expect(useFeedbackStore.getState().clientFeedbackId).not.toBeNull() store.closeFeedback() const state = useFeedbackStore.getState() expect(state.feedbackMode).toBe(false) expect(state.feedbackMessageId).toBeNull() + expect(state.clientFeedbackId).toBeNull() + }) + + it('should reset feedbackText, feedbackCursor, and feedbackCategory', () => { + const store = useFeedbackStore.getState() + store.openFeedbackForMessage('message-123') + store.setFeedbackText('some feedback text') + store.setFeedbackCursor(10) + store.setFeedbackCategory('bad_result') + + store.closeFeedback() + + const state = useFeedbackStore.getState() + expect(state.feedbackText).toBe('') + expect(state.feedbackCursor).toBe(0) + expect(state.feedbackCategory).toBe('other') }) }) @@ -66,6 +94,22 @@ describe('FeedbackStore', () => { expect(useFeedbackStore.getState().feedbackCategory).toBe('good_result') }) + + it('should preserve category when only clearing text and cursor', () => { + const store = useFeedbackStore.getState() + store.openFeedbackForMessage('message-123') + store.setFeedbackCategory('bad_result') + store.setFeedbackText('some feedback text') + store.setFeedbackCursor(10) + + store.setFeedbackText('') + store.setFeedbackCursor(0) + + const state = useFeedbackStore.getState() + expect(state.feedbackText).toBe('') + expect(state.feedbackCursor).toBe(0) + expect(state.feedbackCategory).toBe('bad_result') + }) }) describe('input save and restore', () => { @@ -126,16 +170,35 @@ describe('FeedbackStore', () => { expect(state.feedbackCursor).toBe(0) expect(state.feedbackCategory).toBe('other') expect(state.feedbackMessageId).toBeNull() + expect(state.clientFeedbackId).toBeNull() expect(state.messagesWithFeedback.has('message-456')).toBe(true) }) }) + describe('isSubmitting', () => { + it('should default to false', () => { + const state = useFeedbackStore.getState() + expect(state.isSubmitting).toBe(false) + }) + + it('should update via setIsSubmitting', () => { + const store = useFeedbackStore.getState() + + store.setIsSubmitting(true) + expect(useFeedbackStore.getState().isSubmitting).toBe(true) + + store.setIsSubmitting(false) + expect(useFeedbackStore.getState().isSubmitting).toBe(false) + }) + }) + describe('reset', () => { it('should reset entire store to initial state', () => { const store = useFeedbackStore.getState() store.openFeedbackForMessage('message-123') store.setFeedbackText('Some text') + store.setIsSubmitting(true) store.markMessageFeedbackSubmitted('message-456', 'good_result') store.saveCurrentInput('Saved input', 10) @@ -147,6 +210,8 @@ describe('FeedbackStore', () => { expect(state.feedbackText).toBe('') expect(state.feedbackCursor).toBe(0) expect(state.feedbackCategory).toBe('other') + expect(state.isSubmitting).toBe(false) + expect(state.clientFeedbackId).toBeNull() expect(state.savedInputValue).toBe('') expect(state.savedCursorPosition).toBe(0) expect(state.messagesWithFeedback.size).toBe(0) diff --git a/cli/src/state/chat-store.ts b/cli/src/state/chat-store.ts index 1d97459428..759dce8e43 100644 --- a/cli/src/state/chat-store.ts +++ b/cli/src/state/chat-store.ts @@ -2,7 +2,7 @@ import { castDraft } from 'immer' import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' -import { AGENT_MODES } from '../utils/constants' +import { AGENT_MODES, IS_FREEBUFF } from '../utils/constants' import { clamp } from '../utils/math' import { loadModePreference, saveModePreference } from '../utils/settings' @@ -11,106 +11,44 @@ import type { AgentMode } from '../utils/constants' import type { InputMode } from '../utils/input-modes' import type { RunState } from '@codebuff/sdk' -/** Types of banners that can appear at the top of the chat */ -export type TopBannerType = 'homeDir' | 'gitRoot' | null - -export type InputValue = { - text: string - cursorPosition: number - lastEditDueToNav: boolean -} - -export type AskUserQuestion = { - question: string - header?: string - options: - | string[] - | Array<{ - label: string - description?: string - }> - multiSelect?: boolean - validation?: { - maxLength?: number - minLength?: number - pattern?: string - patternError?: string - } -} - -export type AnswerState = number | number[] - -export type AskUserState = { - toolCallId: string - questions: AskUserQuestion[] - selectedAnswers: AnswerState[] // Single-select: number (-1 = not answered), Multi-select: number[] - otherTexts: string[] // Custom text input for each question (empty string if not used) -} | null - -export type PendingImageStatus = 'processing' | 'ready' | 'error' - -/** Image attachment with processed data */ -export type PendingImageAttachment = { - kind: 'image' - path: string - filename: string - status: PendingImageStatus - size?: number - width?: number - height?: number - note?: string // Display note: "compressed" | error message - processedImage?: { - base64: string - mediaType: string - } -} - -/** Text attachment (large pasted text) */ -export type PendingTextAttachment = { - kind: 'text' - id: string - content: string - preview: string // First ~100 chars for display - charCount: number -} - -/** Unified attachment type with discriminator */ -export type PendingAttachment = PendingImageAttachment | PendingTextAttachment - -/** @deprecated Use PendingImageAttachment instead */ -export type PendingImage = PendingImageAttachment - -export type PendingBashMessage = { - id: string - command: string - stdout: string - stderr: string - exitCode: number - /** Whether the command is still running */ - isRunning: boolean - startTime?: number - cwd?: string - /** Whether the message was already added to UI chat history (non-ghost mode) */ - addedToHistory?: boolean -} - -export type SuggestedFollowup = { - prompt: string - label?: string +// Import types from the types/store module to avoid circular dependencies +import type { + TopBannerType, + InputValue, + AskUserQuestion, + AnswerState, + AskUserState, + PendingImageStatus, + PendingImageAttachment, + PendingTextAttachment, + PendingFileAttachment, + PendingAttachment, + PendingImage, + PendingBashMessage, + SuggestedFollowup, + SuggestedFollowupsState, + ClickedFollowupsMap, +} from '../types/store' + +// Re-export types from the types/store module to maintain backwards compatibility +export type { + TopBannerType, + InputValue, + AskUserQuestion, + AnswerState, + AskUserState, + PendingImageStatus, + PendingImageAttachment, + PendingTextAttachment, + PendingFileAttachment, + PendingAttachment, + PendingImage, + PendingBashMessage, + SuggestedFollowup, + SuggestedFollowupsState, + ClickedFollowupsMap, } -export type SuggestedFollowupsState = { - /** The tool call ID that created these followups */ - toolCallId: string - /** The list of followup suggestions */ - followups: SuggestedFollowup[] - /** Set of indices that have been clicked */ - clickedIndices: Set -} - -/** Map of toolCallId -> Set of clicked indices (persists across followup sets) */ -export type ClickedFollowupsMap = Map> - export type ChatStoreState = { /** Unique ID for this chat session, regenerated on /new */ chatSessionId: string @@ -216,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, @@ -246,7 +185,7 @@ const initialState: ChatStoreState = { isChainInProgress: false, slashSelectedIndex: 0, agentSelectedIndex: 0, - agentMode: loadModePreference(), + agentMode: IS_FREEBUFF ? ('LITE' as const) : loadModePreference(), hasReceivedPlanResponse: false, lastMessageMode: null, sessionCreditsUsed: 0, @@ -333,12 +272,14 @@ export const useChatStore = create()( setAgentMode: (mode) => set((state) => { + if (IS_FREEBUFF) return state.agentMode = mode saveModePreference(mode) }), toggleAgentMode: () => set((state) => { + if (IS_FREEBUFF) return const currentIndex = AGENT_MODES.indexOf(state.agentMode) const nextIndex = (currentIndex + 1) % AGENT_MODES.length state.agentMode = AGENT_MODES[nextIndex] @@ -392,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) @@ -405,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, ) }), @@ -420,6 +361,15 @@ export const useChatStore = create()( }, removePendingImage: (path) => { + // Clear any auto-remove timer to prevent memory leaks + // Import dynamically to avoid circular dependency + import('../utils/pending-attachments') + .then(({ clearErrorImageTimer }) => { + clearErrorImageTimer(path) + }) + .catch(() => { + // Silently ignore import errors - timer cleanup is best-effort + }) useChatStore.getState().removePendingAttachment(path) }, @@ -445,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/state/feedback-store.ts b/cli/src/state/feedback-store.ts index 1226df844f..54d26f9ea2 100644 --- a/cli/src/state/feedback-store.ts +++ b/cli/src/state/feedback-store.ts @@ -1,16 +1,20 @@ import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' +import type { FeedbackCategory } from '@codebuff/common/constants/feedback' + interface FeedbackState { feedbackMessageId: string | null feedbackMode: boolean feedbackText: string feedbackCursor: number - feedbackCategory: string + feedbackCategory: FeedbackCategory + isSubmitting: boolean + clientFeedbackId: string | null savedInputValue: string savedCursorPosition: number messagesWithFeedback: Set - messageFeedbackCategories: Map + messageFeedbackCategories: Map feedbackFooterMessage: string | null errors: Array<{ id: string; message: string }> | null } @@ -19,7 +23,7 @@ interface FeedbackActions { openFeedbackForMessage: ( messageId: string | null, options?: { - category?: string + category?: FeedbackCategory footerMessage?: string errors?: Array<{ id: string; message: string }> }, @@ -27,10 +31,11 @@ interface FeedbackActions { closeFeedback: () => void setFeedbackText: (text: string) => void setFeedbackCursor: (cursor: number) => void - setFeedbackCategory: (category: string) => void + setFeedbackCategory: (category: FeedbackCategory) => void + setIsSubmitting: (isSubmitting: boolean) => void saveCurrentInput: (value: string, cursor: number) => void restoreSavedInput: () => { value: string; cursor: number } - markMessageFeedbackSubmitted: (messageId: string, category: string) => void + markMessageFeedbackSubmitted: (messageId: string, category: FeedbackCategory) => void resetFeedbackForm: () => void reset: () => void } @@ -43,6 +48,8 @@ const initialState: FeedbackState = { feedbackText: '', feedbackCursor: 0, feedbackCategory: 'other', + isSubmitting: false, + clientFeedbackId: null, savedInputValue: '', savedCursorPosition: 0, messagesWithFeedback: new Set(), @@ -62,6 +69,8 @@ export const useFeedbackStore = create()( state.feedbackText = '' state.feedbackCursor = 0 state.feedbackCategory = options?.category || 'other' + state.isSubmitting = false + state.clientFeedbackId = crypto.randomUUID() state.feedbackFooterMessage = options?.footerMessage || null state.errors = options?.errors || null }), @@ -70,6 +79,10 @@ export const useFeedbackStore = create()( set((state) => { state.feedbackMode = false state.feedbackMessageId = null + state.clientFeedbackId = null + state.feedbackText = '' + state.feedbackCursor = 0 + state.feedbackCategory = 'other' }), setFeedbackText: (text) => @@ -87,6 +100,11 @@ export const useFeedbackStore = create()( state.feedbackCategory = category }), + setIsSubmitting: (isSubmitting) => + set((state) => { + state.isSubmitting = isSubmitting + }), + saveCurrentInput: (value, cursor) => set((state) => { state.savedInputValue = value @@ -113,6 +131,7 @@ export const useFeedbackStore = create()( state.feedbackCursor = 0 state.feedbackCategory = 'other' state.feedbackMessageId = null + state.clientFeedbackId = null state.feedbackFooterMessage = null state.errors = null }), diff --git a/cli/src/state/freebuff-model-store.ts b/cli/src/state/freebuff-model-store.ts new file mode 100644 index 0000000000..c602d8464e --- /dev/null +++ b/cli/src/state/freebuff-model-store.ts @@ -0,0 +1,42 @@ +import { + DEFAULT_FREEBUFF_MODEL_ID, + resolveAvailableFreebuffModel, + resolveFreebuffModel, +} from '@codebuff/common/constants/freebuff-models' +import { create } from 'zustand' + +import { loadFreebuffModelPreference } from '../utils/settings' + +/** + * Holds the user's currently-selected freebuff model. Initialized from the + * persisted settings file so freebuff defaults to whatever model the user + * last picked. + * + * `setSelectedModel` is in-memory only — it does NOT persist. Persistence + * happens exclusively in `joinFreebuffQueue` (the explicit-pick path), so + * server-driven auto-flips (`model_locked`, `model_unavailable`, takeover) + * can update the in-memory selection without overwriting the user's saved + * preference. The latter previously caused users to get permanently flipped + * to the fallback model after a single auto-fallback. + * + * Components in the waiting room read this to highlight the current row in + * the model picker; the session hook reads it to decide which queue to join. + */ +interface FreebuffModelStore { + selectedModel: string + setSelectedModel: (model: string) => void +} + +export const useFreebuffModelStore = create((set) => ({ + selectedModel: resolveAvailableFreebuffModel( + loadFreebuffModelPreference() ?? DEFAULT_FREEBUFF_MODEL_ID, + ), + setSelectedModel: (model) => + set({ selectedModel: resolveFreebuffModel(model) }), +})) + +/** Imperative read for non-React callers (the session hook's tick loop and + * the chat-completions metadata builder). */ +export function getSelectedFreebuffModel(): string { + return useFreebuffModelStore.getState().selectedModel +} diff --git a/cli/src/state/freebuff-session-store.ts b/cli/src/state/freebuff-session-store.ts new file mode 100644 index 0000000000..ccac166cb4 --- /dev/null +++ b/cli/src/state/freebuff-session-store.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand' + +import type { FreebuffSessionResponse } from '../types/freebuff-session' + +/** + * Shared state for the freebuff waiting-room session. + * + * The hook in `use-freebuff-session.ts` owns the poll loop and writes into + * this store; React components subscribe via selectors, and non-React code + * reads via `useFreebuffSessionStore.getState()`. + * + * Imperative session controls (force re-POST, mark superseded/ended) live on + * the module exports of `use-freebuff-session.ts` rather than on this store — + * that way callers don't need to null-check a "driver" slot whose lifetime + * is tied to the React tree. + */ +interface FreebuffSessionStore { + session: FreebuffSessionResponse | null + error: string | null + + setSession: (session: FreebuffSessionResponse | null) => void + setError: (error: string | null) => void +} + +export const useFreebuffSessionStore = create((set) => ({ + session: null, + error: null, + setSession: (session) => set({ session }), + setError: (error) => set({ error }), +})) diff --git a/cli/src/state/login-store.ts b/cli/src/state/login-store.ts index 64ce7dba45..915dde05c3 100644 --- a/cli/src/state/login-store.ts +++ b/cli/src/state/login-store.ts @@ -5,6 +5,7 @@ export type LoginStoreState = { loginUrl: string | null loading: boolean error: string | null + fingerprintId: string | null fingerprintHash: string | null expiresAt: string | null isWaitingForEnter: boolean @@ -23,6 +24,9 @@ type LoginStoreActions = { setError: ( value: string | null | ((prev: string | null) => string | null), ) => void + setFingerprintId: ( + value: string | null | ((prev: string | null) => string | null), + ) => void setFingerprintHash: ( value: string | null | ((prev: string | null) => string | null), ) => void @@ -46,6 +50,7 @@ const initialState: LoginStoreState = { loginUrl: null, loading: false, error: null, + fingerprintId: null, fingerprintHash: null, expiresAt: null, isWaitingForEnter: false, @@ -76,6 +81,12 @@ export const useLoginStore = create()( state.error = typeof value === 'function' ? value(state.error) : value }), + setFingerprintId: (value) => + set((state) => { + state.fingerprintId = + typeof value === 'function' ? value(state.fingerprintId) : value + }), + setFingerprintHash: (value) => set((state) => { state.fingerprintHash = @@ -125,6 +136,7 @@ export const useLoginStore = create()( state.loginUrl = initialState.loginUrl state.loading = initialState.loading state.error = initialState.error + state.fingerprintId = initialState.fingerprintId state.fingerprintHash = initialState.fingerprintHash state.expiresAt = initialState.expiresAt state.isWaitingForEnter = initialState.isWaitingForEnter diff --git a/cli/src/state/message-block-store.ts b/cli/src/state/message-block-store.ts new file mode 100644 index 0000000000..e27e71d65d --- /dev/null +++ b/cli/src/state/message-block-store.ts @@ -0,0 +1,121 @@ +import { create } from 'zustand' +import { immer } from 'zustand/middleware/immer' + +import type { FeedbackCategory } from '@codebuff/common/constants/feedback' + +import type { ChatMessage } from '../types/chat' +import type { ChatTheme } from '../types/theme-system' +import type { MarkdownPalette } from '../utils/markdown-renderer' + +/** + * Context values that are updated by the Chat component and consumed by + * message rendering components (MessageWithAgents, AgentMessage, etc). + */ +export interface MessageBlockContext { + /** Active chat theme (colors, etc). */ + theme: ChatTheme | null + /** Palette for markdown rendering. Null until Chat component initializes it. */ + markdownPalette: MarkdownPalette | null + /** Message tree mapping parent message ID -> child agent messages. */ + messageTree: Map | null + /** Whether the main agent is currently waiting for a response. */ + isWaitingForResponse: boolean + /** Timer start time for the main agent stream, used for UI timers. */ + timerStartTime: number | null + /** Available width for rendering message content. */ + availableWidth: number +} + +/** + * Stable callback functions for message block interactions. + * These are set by the Chat component and consumed by message blocks. + */ +export interface MessageBlockCallbacks { + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + onBuildLite: () => void + onFeedback: ( + messageId: string, + options?: { + category?: FeedbackCategory + footerMessage?: string + errors?: Array<{ id: string; message: string }> + }, + ) => void + onCloseFeedback: () => void +} + +interface MessageBlockStoreState { + context: MessageBlockContext + callbacks: MessageBlockCallbacks +} + +interface MessageBlockStoreActions { + /** + * Batch update context values. Pass only the values you want to update. + * + * This is called from the Chat component whenever any of the dependent + * values (theme, markdownPalette, messageTree, etc) change. + */ + setContext: (context: Partial) => void + /** + * Replace all callbacks at once. These are typically stable functions set + * up once when the Chat component mounts. + */ + setCallbacks: (callbacks: MessageBlockCallbacks) => void + /** + * Reset the store to its initial state. Primarily used by tests. + */ + reset: () => void +} + +type MessageBlockStore = MessageBlockStoreState & MessageBlockStoreActions + +const noop = () => {} +const noopFeedback: MessageBlockCallbacks['onFeedback'] = () => {} + +const initialContext: MessageBlockContext = { + theme: null, + markdownPalette: null, + messageTree: null, + isWaitingForResponse: false, + timerStartTime: null, + availableWidth: 80, +} + +const initialCallbacks: MessageBlockCallbacks = { + onToggleCollapsed: noop, + onBuildFast: noop, + onBuildMax: noop, + onBuildLite: noop, + onFeedback: noopFeedback, + onCloseFeedback: noop, +} + +const initialState: MessageBlockStoreState = { + context: initialContext, + callbacks: initialCallbacks, +} + +export const useMessageBlockStore = create()( + immer((set) => ({ + ...initialState, + + setContext: (updates) => + set((state) => { + state.context = { ...state.context, ...updates } + }), + + setCallbacks: (callbacks) => + set((state) => { + state.callbacks = callbacks + }), + + reset: () => + set((state) => { + state.context = { ...initialContext } + state.callbacks = { ...initialCallbacks } + }), + })), +) diff --git a/cli/src/state/review-store.ts b/cli/src/state/review-store.ts new file mode 100644 index 0000000000..5d5fa74619 --- /dev/null +++ b/cli/src/state/review-store.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand' +import { immer } from 'zustand/middleware/immer' + +interface ReviewState { + reviewMode: boolean + openReviewScreen: () => void + closeReviewScreen: () => void +} + +export const useReviewStore = create()( + immer((set) => ({ + reviewMode: false, + openReviewScreen: () => { + set((state) => { + state.reviewMode = true + }) + }, + closeReviewScreen: () => { + set((state) => { + state.reviewMode = false + }) + }, + })), +) diff --git a/cli/src/testing/env.ts b/cli/src/testing/env.ts index bfbfe29681..8aae6ad566 100644 --- a/cli/src/testing/env.ts +++ b/cli/src/testing/env.ts @@ -9,6 +9,9 @@ export const createTestCliEnv = (overrides: Partial = {}): CliEnv => ({ ...createTestBaseEnv(), // CLI-specific defaults + SSH_CLIENT: undefined, + SSH_TTY: undefined, + SSH_CONNECTION: undefined, KITTY_WINDOW_ID: undefined, SIXEL_SUPPORT: undefined, ZED_NODE_ENV: undefined, @@ -38,5 +41,6 @@ export const createTestCliEnv = (overrides: Partial = {}): CliEnv => ({ CODEBUFF_CLI_TARGET: undefined, CODEBUFF_RG_PATH: undefined, CODEBUFF_SCROLL_MULTIPLIER: undefined, + FREEBUFF_MODE: undefined, ...overrides, }) diff --git a/cli/src/types/chat-state.ts b/cli/src/types/chat-state.ts new file mode 100644 index 0000000000..dbc3034457 --- /dev/null +++ b/cli/src/types/chat-state.ts @@ -0,0 +1,18 @@ +/** + * Type definitions for chat state management. + * Re-exports types from the extracted hooks for convenience. + */ + +// Re-export types from the extracted hooks +export type { + ChatStateRefs, + UseChatStateReturn, +} from '../hooks/use-chat-state' + +export type { + UseChatMessagesOptions, + UseChatMessagesReturn, +} from '../hooks/use-chat-messages' + +// Re-export StreamStatus from use-message-queue for convenience +export type { StreamStatus } from '../hooks/use-message-queue' diff --git a/cli/src/types/chat.ts b/cli/src/types/chat.ts index ab5c52d651..248b606550 100644 --- a/cli/src/types/chat.ts +++ b/cli/src/types/chat.ts @@ -2,8 +2,16 @@ import type { ChatTheme } from './theme-system' import type { ToolName } from '@codebuff/sdk' import type { ReactNode } from 'react' +/** + * isCollapsed/userOpened are duplicated across block types intentionally - each UI + * element tracks collapse state independently for different defaults and to persist + * user intent vs programmatic state. + */ + export type ChatVariant = 'ai' | 'user' | 'agent' | 'error' +export type ThinkingCollapseState = 'expanded' | 'preview' | 'hidden' + export type TextContentBlock = { type: 'text' content: string @@ -17,7 +25,9 @@ export type TextContentBlock = { userOpened?: boolean /** True if this is a reasoning block from a tag that hasn't been closed yet */ thinkingOpen?: boolean + thinkingCollapseState?: ThinkingCollapseState } +/** Renders dynamic React content. NOT serializable - don't use for persistent data. */ export type HtmlContentBlock = { type: 'html' marginTop?: number @@ -42,7 +52,7 @@ export type AgentContentBlock = { agentName: string agentType: string content: string - status: 'running' | 'complete' | 'failed' + status: 'running' | 'complete' | 'failed' | 'cancelled' blocks?: ContentBlock[] initialPrompt?: string params?: Record @@ -123,6 +133,13 @@ export type TextAttachment = { charCount: number } +export type FileAttachment = { + path: string + filename: string + isDirectory: boolean + note?: string +} + export type ContentBlock = | AgentContentBlock | AgentListContentBlock @@ -166,8 +183,15 @@ export type ChatMessage = { isComplete?: boolean metadata?: ChatMessageMetadata validationErrors?: Array<{ id: string; message: string }> + /** + * UI-only runtime error displayed in UserErrorBanner (not sent to LLM). + * Set by setError() when an error occurs during message streaming. + * Can be cleared by clearUserError() when starting a new successful interaction. + */ + userError?: string attachments?: ImageAttachment[] textAttachments?: TextAttachment[] + fileAttachments?: FileAttachment[] } // Type guard functions for safe type narrowing diff --git a/cli/src/types/contracts/send-message.ts b/cli/src/types/contracts/send-message.ts index b185314d34..64baf3913e 100644 --- a/cli/src/types/contracts/send-message.ts +++ b/cli/src/types/contracts/send-message.ts @@ -1,4 +1,4 @@ -import type { PendingAttachment } from '../../state/chat-store' +import type { PendingAttachment } from '../store' import type { AgentMode } from '../../utils/constants' import type { ChatMessage } from '../chat' diff --git a/cli/src/types/env.ts b/cli/src/types/env.ts index 94403c4060..948de24c7b 100644 --- a/cli/src/types/env.ts +++ b/cli/src/types/env.ts @@ -16,6 +16,20 @@ import type { * CLI-specific env vars for terminal/IDE detection and editor preferences. */ export type CliEnv = BaseEnv & { + // Terminal detection (for tmux/screen passthrough) + TERM?: string + TMUX?: string + STY?: string + + // SSH/remote session detection + SSH_CLIENT?: string + SSH_TTY?: string + SSH_CONNECTION?: string + + // Display server detection (Linux headless check) + DISPLAY?: string + WAYLAND_DISPLAY?: string + // Terminal-specific KITTY_WINDOW_ID?: string SIXEL_SUPPORT?: string @@ -59,6 +73,7 @@ export type CliEnv = BaseEnv & { CODEBUFF_RG_PATH?: string CODEBUFF_SCROLL_MULTIPLIER?: string CODEBUFF_PERF_TEST?: string + FREEBUFF_MODE?: string } /** diff --git a/cli/src/types/freebuff-session.ts b/cli/src/types/freebuff-session.ts new file mode 100644 index 0000000000..ef6ee83afb --- /dev/null +++ b/cli/src/types/freebuff-session.ts @@ -0,0 +1,17 @@ +export type { FreebuffSessionServerResponse } from '@codebuff/common/types/freebuff-session' + +import type { FreebuffSessionServerResponse } from '@codebuff/common/types/freebuff-session' + +/** + * CLI session shape. Most states are wire-level `/api/v1/freebuff/session` + * responses; `takeover_prompt` is local-only so startup can ask before POSTing + * and rotating another running CLI's instance id. + */ +export type FreebuffSessionResponse = + | FreebuffSessionServerResponse + | { + status: 'takeover_prompt' + model: string + } + +export type FreebuffSessionStatus = FreebuffSessionResponse['status'] diff --git a/cli/src/types/function-params.ts b/cli/src/types/function-params.ts index dc5ed696ab..5b66266a30 100644 --- a/cli/src/types/function-params.ts +++ b/cli/src/types/function-params.ts @@ -1,5 +1,5 @@ -import type { UnionToIntersection } from 'bun-types/vendor/expect-type' import type { Prettify } from './utils' +import type { UnionToIntersection } from 'bun-types/vendor/expect-type' type StripExact = T extends infer U & { [x: string]: never } ? U : T diff --git a/cli/src/types/react19-compat.d.ts b/cli/src/types/react19-compat.d.ts new file mode 100644 index 0000000000..11ca1af2a0 --- /dev/null +++ b/cli/src/types/react19-compat.d.ts @@ -0,0 +1,19 @@ +/** + * React 19 compatibility shim for OpenTUI JSX types. + * + * OpenTUI's JSX namespace defines `type Element = React.ReactNode`. + * In React 19, `FunctionComponent` returns `ReactNode | Promise`, + * but `Promise` is not assignable to `ReactNode`. + * + * This augmentation adds a narrower call signature to `FunctionComponent` + * that returns just `ReactNode`. Due to TypeScript's interface merging rules, + * the later declaration's overloads have higher precedence, so the narrower + * signature is resolved first — fixing all `React.FC` JSX compatibility errors. + */ +import 'react' + +declare module 'react' { + interface FunctionComponent

{ + (props: P): ReactNode + } +} diff --git a/cli/src/types/store.ts b/cli/src/types/store.ts new file mode 100644 index 0000000000..516b903ce1 --- /dev/null +++ b/cli/src/types/store.ts @@ -0,0 +1,111 @@ +/** Types of banners that can appear at the top of the chat */ +export type TopBannerType = 'homeDir' | 'gitRoot' | null + +export type InputValue = { + text: string + cursorPosition: number + lastEditDueToNav: boolean +} + +export type AskUserQuestion = { + question: string + header?: string + options: + | string[] + | Array<{ + label: string + description?: string + }> + multiSelect?: boolean + validation?: { + maxLength?: number + minLength?: number + pattern?: string + patternError?: string + } +} + +export type AnswerState = number | number[] + +export type AskUserState = { + toolCallId: string + questions: AskUserQuestion[] + selectedAnswers: AnswerState[] // Single-select: number (-1 = not answered), Multi-select: number[] + otherTexts: string[] // Custom text input for each question (empty string if not used) +} | null + +export type PendingImageStatus = 'processing' | 'ready' | 'error' + +/** Image attachment with processed data */ +export type PendingImageAttachment = { + kind: 'image' + path: string + filename: string + status: PendingImageStatus + size?: number + width?: number + height?: number + note?: string // Display note: "compressed" | error message + processedImage?: { + base64: string + mediaType: string + } +} + +/** Text attachment (large pasted text) */ +export type PendingTextAttachment = { + kind: 'text' + id: string + content: string + preview: string // First ~100 chars for display + 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 | PendingFileAttachment + +/** @deprecated Use PendingImageAttachment instead */ +export type PendingImage = PendingImageAttachment + +export type PendingBashMessage = { + id: string + command: string + stdout: string + stderr: string + exitCode: number + /** Whether the command is still running */ + isRunning: boolean + startTime?: number + cwd?: string + /** Whether the message was already added to UI chat history (non-ghost mode) */ + addedToHistory?: boolean +} + +export type SuggestedFollowup = { + prompt: string + label?: string +} + +export type SuggestedFollowupsState = { + /** The tool call ID that created these followups */ + toolCallId: string + /** The list of followup suggestions */ + followups: SuggestedFollowup[] + /** Set of indices that have been clicked */ + clickedIndices: Set +} + +/** Map of toolCallId -> Set of clicked indices (persists across followup sets) */ +export type ClickedFollowupsMap = Map> diff --git a/cli/src/utils/__tests__/agent-display.test.ts b/cli/src/utils/__tests__/agent-display.test.ts new file mode 100644 index 0000000000..248a7a074a --- /dev/null +++ b/cli/src/utils/__tests__/agent-display.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'bun:test' + +import { + getAgentDisplayPrompt, + getBasherFinishedOutputPreview, + truncateToSingleLinePreview, +} from '../agent-display' + +import type { AgentContentBlock } from '../../types/chat' + +const createAgentBlock = ( + overrides: Partial, +): AgentContentBlock => ({ + type: 'agent', + agentId: 'agent-1', + agentName: 'Basher', + agentType: 'basher', + content: '', + status: 'running', + blocks: [], + initialPrompt: '', + ...overrides, +}) + +describe('getAgentDisplayPrompt', () => { + test('uses initial prompt when present', () => { + const block = createAgentBlock({ + initialPrompt: 'Run tests', + params: { + what_to_summarize: 'Summarize failures', + }, + }) + + expect(getAgentDisplayPrompt(block)).toBe('Run tests') + }) + + test('uses basher what_to_summarize when prompt is omitted', () => { + const block = createAgentBlock({ + params: { + command: 'bun test', + what_to_summarize: 'Summarize failing tests only', + }, + }) + + expect(getAgentDisplayPrompt(block)).toBe('Summarize failing tests only') + }) + + test('normalizes scoped and versioned basher agent ids', () => { + const block = createAgentBlock({ + agentType: 'codebuff/basher@1.0.0', + params: { + what_to_summarize: 'Summarize command output', + }, + }) + + expect(getAgentDisplayPrompt(block)).toBe('Summarize command output') + }) + + test('ignores non-basher what_to_summarize params', () => { + const block = createAgentBlock({ + agentName: 'code-searcher', + agentType: 'code-searcher', + params: { + what_to_summarize: 'This is not a basher prompt', + }, + }) + + expect(getAgentDisplayPrompt(block)).toBeUndefined() + }) +}) + +describe('getBasherFinishedOutputPreview', () => { + test('returns undefined while basher is still running', () => { + const block = createAgentBlock({ + status: 'running', + params: { + what_to_summarize: 'Report the test result', + }, + blocks: [{ type: 'text', content: 'Tests passed' }], + }) + + expect(getBasherFinishedOutputPreview(block)).toBeUndefined() + }) + + test('uses finished basher text output before what_to_summarize', () => { + const block = createAgentBlock({ + status: 'complete', + params: { + what_to_summarize: 'Report the test result', + }, + blocks: [ + { + type: 'text', + content: 'Tests passed\n42 assertions completed', + textType: 'text', + }, + ], + }) + + expect(getBasherFinishedOutputPreview(block)).toBe( + 'Tests passed 42 assertions completed', + ) + }) + + test('falls back to command output when no text block exists', () => { + const block = createAgentBlock({ + status: 'complete', + blocks: [ + { + type: 'tool', + toolCallId: 'tool-1', + toolName: 'run_terminal_command', + input: { command: 'git status --short' }, + output: ' M cli/src/app.tsx\n', + }, + ], + }) + + expect(getBasherFinishedOutputPreview(block)).toBe('M cli/src/app.tsx') + }) + + test('ignores non-basher output', () => { + const block = createAgentBlock({ + agentType: 'code-searcher', + status: 'complete', + blocks: [{ type: 'text', content: 'Search results' }], + }) + + expect(getBasherFinishedOutputPreview(block)).toBeUndefined() + }) +}) + +describe('truncateToSingleLinePreview', () => { + test('collapses whitespace and truncates to the requested length', () => { + expect(truncateToSingleLinePreview('one\ntwo three four', 13)).toBe( + 'one two th...', + ) + }) +}) diff --git a/cli/src/utils/__tests__/analytics-client.test.ts b/cli/src/utils/__tests__/analytics-client.test.ts index d59a3686bc..28aec870ad 100644 --- a/cli/src/utils/__tests__/analytics-client.test.ts +++ b/cli/src/utils/__tests__/analytics-client.test.ts @@ -1,8 +1,6 @@ -import { describe, test, expect, beforeEach, mock } from 'bun:test' - import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { describe, test, expect, beforeEach, mock } from 'bun:test' -import type { AnalyticsClientWithIdentify } from '@codebuff/common/analytics-core' import { initAnalytics, @@ -12,6 +10,9 @@ import { type AnalyticsDeps, } from '../analytics' +import type { AnalyticsClientWithIdentify } from '@codebuff/common/analytics-core' + + describe('analytics with PostHog alias', () => { // Store references to track calls let captureMock: ReturnType diff --git a/cli/src/utils/__tests__/bash-context-processor.test.ts b/cli/src/utils/__tests__/bash-context-processor.test.ts index 76c7589cbe..619595d585 100644 --- a/cli/src/utils/__tests__/bash-context-processor.test.ts +++ b/cli/src/utils/__tests__/bash-context-processor.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import { processBashContext } from '../bash-context-processor' -import type { PendingBashMessage } from '../../state/chat-store' +import type { PendingBashMessage } from '../../types/store' const createPendingBash = ( overrides: Partial = {}, diff --git a/cli/src/utils/__tests__/block-processor.test.ts b/cli/src/utils/__tests__/block-processor.test.ts new file mode 100644 index 0000000000..7413c53e3e --- /dev/null +++ b/cli/src/utils/__tests__/block-processor.test.ts @@ -0,0 +1,810 @@ +import { describe, expect, test } from 'bun:test' + +import { + processBlocks, + splitAgentsBySize, + isReasoningTextBlock, + type BlockProcessorHandlers, +} from '../block-processor' + +import type { + ContentBlock, + TextContentBlock, + ToolContentBlock, + AgentContentBlock, + ImageContentBlock, +} from '../../types/chat' + +// ============================================================================ +// Test Helpers - Block Factories +// ============================================================================ + +function createTextBlock( + content: string, + textType?: 'reasoning' | 'text', +): TextContentBlock { + return { + type: 'text', + content, + textType, + } as TextContentBlock +} + +function createReasoningBlock(content: string): TextContentBlock { + return createTextBlock(content, 'reasoning') +} + +function createToolBlock( + toolName: string, + toolCallId: string = `tool-${toolName}`, +): ToolContentBlock { + return { + type: 'tool', + toolCallId, + toolName: toolName as ToolContentBlock['toolName'], + input: {}, + } +} + +function createImageBlock( + mediaType: string = 'image/png', + image: string = 'base64data', +): ImageContentBlock { + return { + type: 'image', + mediaType, + image, + } as ImageContentBlock +} + +function createImplementorAgent( + agentId: string, + agentType: string = 'editor-implementor', +): AgentContentBlock { + return { + type: 'agent', + agentId, + agentName: `Implementor ${agentId}`, + agentType, + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock +} + +function createNonImplementorAgent( + agentId: string, + agentType: string = 'file-picker', +): AgentContentBlock { + return { + type: 'agent', + agentId, + agentName: agentType, + agentType, + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock +} + +// ============================================================================ +// Test Helpers - Mock Handlers +// ============================================================================ + +interface MockCallRecord { + handler: string + args: unknown[] +} + +function createMockHandlers(): { + handlers: BlockProcessorHandlers + calls: MockCallRecord[] +} { + const calls: MockCallRecord[] = [] + + const handlers: BlockProcessorHandlers = { + onReasoningGroup: (blocks, startIndex) => { + calls.push({ handler: 'onReasoningGroup', args: [blocks, startIndex] }) + return `reasoning-${startIndex}` + }, + onImageBlock: (block, index) => { + calls.push({ handler: 'onImageBlock', args: [block, index] }) + return `image-${index}` + }, + onToolGroup: (blocks, startIndex, nextIndex) => { + calls.push({ + handler: 'onToolGroup', + args: [blocks, startIndex, nextIndex], + }) + return `tools-${startIndex}-${nextIndex}` + }, + onImplementorGroup: (blocks, startIndex, nextIndex) => { + calls.push({ + handler: 'onImplementorGroup', + args: [blocks, startIndex, nextIndex], + }) + return `implementors-${startIndex}-${nextIndex}` + }, + onAgentGroup: (blocks, startIndex, nextIndex) => { + calls.push({ + handler: 'onAgentGroup', + args: [blocks, startIndex, nextIndex], + }) + return `agents-${startIndex}-${nextIndex}` + }, + onSingleBlock: (block, index) => { + calls.push({ handler: 'onSingleBlock', args: [block, index] }) + return `single-${index}` + }, + } + + return { handlers, calls } +} + +// ============================================================================ +// Tests: isReasoningTextBlock +// ============================================================================ + +describe('isReasoningTextBlock', () => { + test('returns true for text block with textType "reasoning"', () => { + const block = createReasoningBlock('thinking...') + expect(isReasoningTextBlock(block)).toBe(true) + }) + + test('returns false for text block without textType', () => { + const block = createTextBlock('normal text') + expect(isReasoningTextBlock(block)).toBe(false) + }) + + test('returns false for text block with textType "text"', () => { + const block = createTextBlock('normal text', 'text') + expect(isReasoningTextBlock(block)).toBe(false) + }) + + test('returns false for non-text blocks', () => { + expect(isReasoningTextBlock(createToolBlock('str_replace'))).toBe(false) + expect(isReasoningTextBlock(createImageBlock())).toBe(false) + expect(isReasoningTextBlock(createNonImplementorAgent('a1'))).toBe(false) + }) +}) + +// ============================================================================ +// Tests: processBlocks - Basic Cases +// ============================================================================ + +describe('processBlocks', () => { + describe('basic cases', () => { + test('returns empty array for empty blocks', () => { + const { handlers, calls } = createMockHandlers() + const result = processBlocks([], handlers) + + expect(result).toEqual([]) + expect(calls).toHaveLength(0) + }) + + test('processes single text block with onSingleBlock', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [createTextBlock('hello')] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single-0']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onSingleBlock') + expect((calls[0].args[0] as TextContentBlock).content).toBe('hello') + expect(calls[0].args[1]).toBe(0) + }) + }) + + // ========================================================================== + // Tests: Reasoning Block Grouping + // ========================================================================== + + describe('reasoning block grouping', () => { + test('groups single reasoning block', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [createReasoningBlock('thinking')] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['reasoning-0']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onReasoningGroup') + expect((calls[0].args[0] as TextContentBlock[]).length).toBe(1) + expect(calls[0].args[1]).toBe(0) + }) + + test('groups consecutive reasoning blocks together', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createReasoningBlock('thought 1'), + createReasoningBlock('thought 2'), + createReasoningBlock('thought 3'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['reasoning-0']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onReasoningGroup') + const reasoningBlocks = calls[0].args[0] as TextContentBlock[] + expect(reasoningBlocks).toHaveLength(3) + expect(reasoningBlocks[0].content).toBe('thought 1') + expect(reasoningBlocks[1].content).toBe('thought 2') + expect(reasoningBlocks[2].content).toBe('thought 3') + }) + + test('separates reasoning groups interrupted by other blocks', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createReasoningBlock('thought 1'), + createTextBlock('response'), + createReasoningBlock('thought 2'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['reasoning-0', 'single-1', 'reasoning-2']) + expect(calls).toHaveLength(3) + expect(calls[0].handler).toBe('onReasoningGroup') + expect(calls[1].handler).toBe('onSingleBlock') + expect(calls[2].handler).toBe('onReasoningGroup') + }) + }) + + // ========================================================================== + // Tests: Image Block Handling + // ========================================================================== + + describe('image block handling', () => { + test('handles image block with onImageBlock handler', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [createImageBlock('image/png', 'data123')] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['image-0']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onImageBlock') + expect((calls[0].args[0] as ImageContentBlock).image).toBe('data123') + expect(calls[0].args[1]).toBe(0) + }) + + test('skips image blocks when onImageBlock is not provided', () => { + const calls: MockCallRecord[] = [] + const handlers: BlockProcessorHandlers = { + onReasoningGroup: () => null, + // onImageBlock intentionally omitted + onToolGroup: () => null, + onImplementorGroup: () => null, + onAgentGroup: () => null, + onSingleBlock: (block, index) => { + calls.push({ handler: 'onSingleBlock', args: [block, index] }) + return `single-${index}` + }, + } + + const blocks: ContentBlock[] = [ + createTextBlock('before'), + createImageBlock(), + createTextBlock('after'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single-0', 'single-2']) + expect(calls).toHaveLength(2) + // Image at index 1 was skipped, not passed to onSingleBlock + }) + + test('handles multiple consecutive images', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createImageBlock('image/png', 'img1'), + createImageBlock('image/jpeg', 'img2'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['image-0', 'image-1']) + expect(calls).toHaveLength(2) + expect(calls[0].handler).toBe('onImageBlock') + expect(calls[1].handler).toBe('onImageBlock') + }) + }) + + // ========================================================================== + // Tests: Tool Block Grouping + // ========================================================================== + + describe('tool block grouping', () => { + test('groups single tool block', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [createToolBlock('str_replace', 'tool-1')] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['tools-0-1']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onToolGroup') + expect((calls[0].args[0] as ToolContentBlock[]).length).toBe(1) + expect(calls[0].args[1]).toBe(0) // startIndex + expect(calls[0].args[2]).toBe(1) // nextIndex + }) + + test('groups consecutive tool blocks with correct indices', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createToolBlock('write_file', 'tool-2'), + createToolBlock('read_files', 'tool-3'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['tools-0-3']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onToolGroup') + const toolBlocks = calls[0].args[0] as ToolContentBlock[] + expect(toolBlocks).toHaveLength(3) + expect(toolBlocks[0].toolCallId).toBe('tool-1') + expect(toolBlocks[1].toolCallId).toBe('tool-2') + expect(toolBlocks[2].toolCallId).toBe('tool-3') + expect(calls[0].args[1]).toBe(0) // startIndex + expect(calls[0].args[2]).toBe(3) // nextIndex + }) + + test('separates tool groups interrupted by text', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createTextBlock('middle'), + createToolBlock('write_file', 'tool-2'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['tools-0-1', 'single-1', 'tools-2-3']) + expect(calls).toHaveLength(3) + expect(calls[0].handler).toBe('onToolGroup') + expect(calls[0].args[1]).toBe(0) + expect(calls[0].args[2]).toBe(1) + expect(calls[1].handler).toBe('onSingleBlock') + expect(calls[2].handler).toBe('onToolGroup') + expect(calls[2].args[1]).toBe(2) + expect(calls[2].args[2]).toBe(3) + }) + }) + + // ========================================================================== + // Tests: Implementor Agent Grouping + // ========================================================================== + + describe('implementor agent grouping', () => { + test('groups single implementor agent', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'editor-implementor'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['implementors-0-1']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onImplementorGroup') + }) + + test('groups consecutive implementor agents of different types', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'editor-implementor'), + createImplementorAgent('impl-2', 'editor-implementor-opus'), + createImplementorAgent('impl-3', 'editor-implementor-gpt-5'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['implementors-0-3']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onImplementorGroup') + const implBlocks = calls[0].args[0] as AgentContentBlock[] + expect(implBlocks).toHaveLength(3) + }) + + test('separates implementor groups from non-implementor agents', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createNonImplementorAgent('fp-1', 'file-picker'), + createImplementorAgent('impl-2'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual([ + 'implementors-0-1', + 'agents-1-2', + 'implementors-2-3', + ]) + expect(calls).toHaveLength(3) + }) + }) + + // ========================================================================== + // Tests: Non-Implementor Agent Grouping + // ========================================================================== + + describe('non-implementor agent grouping', () => { + test('groups single non-implementor agent', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['agents-0-1']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onAgentGroup') + }) + + test('groups consecutive small (collapsed-by-default) agents together', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('b-1', 'basher'), + createNonImplementorAgent('cs-1', 'code-searcher'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['agents-0-3']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onAgentGroup') + const agentBlocks = calls[0].args[0] as AgentContentBlock[] + expect(agentBlocks).toHaveLength(3) + expect(agentBlocks[0].agentType).toBe('file-picker') + expect(agentBlocks[1].agentType).toBe('basher') + expect(agentBlocks[2].agentType).toBe('code-searcher') + }) + + test('groups consecutive non-implementor agents including mixed sizes', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('cr-1', 'code-reviewer'), + createNonImplementorAgent('cs-1', 'code-searcher'), + ] + + const result = processBlocks(blocks, handlers) + + // All consecutive non-implementor agents go into a single onAgentGroup call + expect(result).toEqual(['agents-0-3']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onAgentGroup') + const agentBlocks = calls[0].args[0] as AgentContentBlock[] + expect(agentBlocks).toHaveLength(3) + expect(agentBlocks[0].agentType).toBe('file-picker') + expect(agentBlocks[1].agentType).toBe('code-reviewer') + expect(agentBlocks[2].agentType).toBe('code-searcher') + }) + + test('separates non-implementor groups from other block types', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createTextBlock('commentary'), + createNonImplementorAgent('cmd-1', 'commander'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['agents-0-1', 'single-1', 'agents-2-3']) + expect(calls).toHaveLength(3) + }) + }) + + // ========================================================================== + // Tests: Single Block Fallback + // ========================================================================== + + describe('single block fallback', () => { + test('handles regular text blocks with onSingleBlock', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createTextBlock('hello'), + createTextBlock('world'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single-0', 'single-1']) + expect(calls).toHaveLength(2) + expect(calls[0].handler).toBe('onSingleBlock') + expect(calls[1].handler).toBe('onSingleBlock') + }) + + test('handles html blocks with onSingleBlock', () => { + const { handlers, calls } = createMockHandlers() + const htmlBlock: ContentBlock = { + type: 'html', + render: () => null, + } as ContentBlock + + const blocks: ContentBlock[] = [htmlBlock] + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single-0']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onSingleBlock') + }) + }) + + // ========================================================================== + // Tests: Null Filtering + // ========================================================================== + + describe('null filtering', () => { + test('filters out null returns from handlers', () => { + const handlers: BlockProcessorHandlers = { + onReasoningGroup: () => null, + onImageBlock: () => null, + onToolGroup: () => null, + onImplementorGroup: () => null, + onAgentGroup: () => null, + onSingleBlock: (block, index) => + index % 2 === 0 ? `single-${index}` : null, + } + + const blocks: ContentBlock[] = [ + createTextBlock('keep'), // index 0, should be kept + createTextBlock('skip'), // index 1, should be filtered + createTextBlock('keep'), // index 2, should be kept + createTextBlock('skip'), // index 3, should be filtered + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single-0', 'single-2']) + }) + + test('filters null from reasoning groups', () => { + const handlers: BlockProcessorHandlers = { + onReasoningGroup: () => null, + onToolGroup: () => 'tool', + onImplementorGroup: () => 'impl', + onAgentGroup: () => 'agent', + onSingleBlock: () => 'single', + } + + const blocks: ContentBlock[] = [ + createReasoningBlock('thought'), + createTextBlock('visible'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single']) + }) + + test('filters null from all handler types', () => { + const handlers: BlockProcessorHandlers = { + onReasoningGroup: () => null, + onImageBlock: () => null, + onToolGroup: () => null, + onImplementorGroup: () => null, + onAgentGroup: () => null, + onSingleBlock: () => null, + } + + const blocks: ContentBlock[] = [ + createReasoningBlock('thought'), + createImageBlock(), + createToolBlock('str_replace'), + createImplementorAgent('impl-1'), + createNonImplementorAgent('fp-1'), + createTextBlock('text'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual([]) + }) + }) + + // ========================================================================== + // Tests: Mixed Block Combinations + // ========================================================================== + + describe('mixed block combinations', () => { + test('processes typical message flow', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createReasoningBlock('thinking about the problem'), + createReasoningBlock('considering options'), + createTextBlock('I will search for files first'), + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('cs-1', 'code-searcher'), + createTextBlock('Now I will make changes'), + createImplementorAgent('impl-1', 'editor-implementor'), + createImplementorAgent('impl-2', 'editor-implementor-opus'), + createTextBlock('Changes complete'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual([ + 'reasoning-0', + 'single-2', + 'agents-3-5', + 'single-5', + 'implementors-6-8', + 'single-8', + ]) + expect(calls).toHaveLength(6) + }) + + test('handles interleaved tools and agents', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createToolBlock('read_files', 'tool-1'), + createToolBlock('code_search', 'tool-2'), + createNonImplementorAgent('fp-1', 'file-picker'), + createToolBlock('str_replace', 'tool-3'), + createImplementorAgent('impl-1'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual([ + 'tools-0-2', + 'agents-2-3', + 'tools-3-4', + 'implementors-4-5', + ]) + }) + + test('processes complex real-world scenario', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + // Assistant thinking + createReasoningBlock('Let me analyze this...'), + createReasoningBlock('I see the issue'), + // Assistant response with tool usage + createTextBlock('I found the issue. Let me fix it.'), + createToolBlock('str_replace', 'fix-1'), + createToolBlock('str_replace', 'fix-2'), + // More thinking + createReasoningBlock('Checking if more changes needed'), + // Final response + createTextBlock('Done! The bug is fixed.'), + // Image attachment + createImageBlock('image/png', 'screenshot'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual([ + 'reasoning-0', + 'single-2', + 'tools-3-5', + 'reasoning-5', + 'single-6', + 'image-7', + ]) + expect(calls).toHaveLength(6) + }) + }) + + // ========================================================================== + // Tests: Index Correctness + // ========================================================================== + + describe('index correctness', () => { + test('maintains correct indices after grouping', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createTextBlock('text at 0'), + createToolBlock('tool-1', 't1'), // group starts at 1 + createToolBlock('tool-2', 't2'), + createToolBlock('tool-3', 't3'), // group ends, nextIndex = 4 + createTextBlock('text at 4'), + createNonImplementorAgent('a1'), // group starts at 5 (file-picker = small) + createNonImplementorAgent('a2'), // group ends, nextIndex = 7 (file-picker = small) + createTextBlock('text at 7'), + ] + + processBlocks(blocks, handlers) + + // Verify startIndex and nextIndex for each group + expect(calls[0].args[1]).toBe(0) // single text at 0 + expect(calls[1].args[1]).toBe(1) // tools start at 1 + expect(calls[1].args[2]).toBe(4) // tools next at 4 + expect(calls[2].args[1]).toBe(4) // single text at 4 + expect(calls[3].args[1]).toBe(5) // agents start at 5 + expect(calls[3].args[2]).toBe(7) // agents next at 7 + expect(calls[4].args[1]).toBe(7) // single text at 7 + }) + + test('maintains correct indices for mixed-size agent groups', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createTextBlock('text at 0'), + createNonImplementorAgent('fp-1', 'file-picker'), // index 1 + createNonImplementorAgent('b-1', 'basher'), // index 2 + createNonImplementorAgent('cr-1', 'code-reviewer'), // index 3 + createNonImplementorAgent('cs-1', 'code-searcher'), // index 4 + createTextBlock('text at 5'), + ] + + processBlocks(blocks, handlers) + + // text at 0 + expect(calls[0].handler).toBe('onSingleBlock') + expect(calls[0].args[1]).toBe(0) + // All non-implementor agents grouped together + expect(calls[1].handler).toBe('onAgentGroup') + expect(calls[1].args[1]).toBe(1) + expect(calls[1].args[2]).toBe(5) + expect((calls[1].args[0] as AgentContentBlock[]).length).toBe(4) + // text at 5 + expect(calls[2].handler).toBe('onSingleBlock') + expect(calls[2].args[1]).toBe(5) + }) + }) +}) + +// ============================================================================ +// Tests: splitAgentsBySize +// ============================================================================ + +describe('splitAgentsBySize', () => { + test('returns single group for empty array', () => { + const result = splitAgentsBySize([]) + expect(result).toEqual([[]]) + }) + + test('returns single group for one agent', () => { + const agent = createNonImplementorAgent('cr-1', 'code-reviewer') + const result = splitAgentsBySize([agent]) + expect(result).toEqual([[agent]]) + }) + + test('groups all small agents together', () => { + const agents = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('b-1', 'basher'), + createNonImplementorAgent('cs-1', 'code-searcher'), + ] + const result = splitAgentsBySize(agents) + expect(result).toEqual([agents]) + }) + + test('gives each large agent its own group', () => { + const agents = [ + createNonImplementorAgent('cr-1', 'code-reviewer'), + createNonImplementorAgent('ed-1', 'editor'), + ] + const result = splitAgentsBySize(agents) + expect(result).toEqual([[agents[0]], [agents[1]]]) + }) + + test('splits small and large agents correctly', () => { + const agents = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('cr-1', 'code-reviewer'), + createNonImplementorAgent('b-1', 'basher'), + createNonImplementorAgent('b-2', 'basher'), + createNonImplementorAgent('ed-1', 'editor'), + createNonImplementorAgent('rw-1', 'researcher-web'), + ] + const result = splitAgentsBySize(agents) + expect(result).toEqual([ + [agents[0]], // file-picker (small) + [agents[1]], // code-reviewer (large) + [agents[2], agents[3]], // basher + basher (small) + [agents[4]], // editor (large) + [agents[5]], // researcher-web (small) + ]) + }) +}) diff --git a/cli/src/utils/__tests__/chat-history.test.ts b/cli/src/utils/__tests__/chat-history.test.ts new file mode 100644 index 0000000000..31acf47f34 --- /dev/null +++ b/cli/src/utils/__tests__/chat-history.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +let tempDataDir = '' + +mock.module('../../project-files', () => ({ + getProjectDataDir: () => tempDataDir, +})) + +mock.module('../logger', () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, + }, +})) + +import { deleteChatSession, getAllChats } from '../chat-history' + +function writeChat(chatId: string, prompt: string) { + const chatDir = path.join(tempDataDir, 'chats', chatId) + fs.mkdirSync(chatDir, { recursive: true }) + fs.writeFileSync( + path.join(chatDir, 'chat-messages.json'), + JSON.stringify([ + { + id: `${chatId}-message`, + variant: 'user', + content: prompt, + timestamp: new Date().toISOString(), + blocks: [], + }, + ]), + ) +} + +describe('chat-history', () => { + beforeEach(() => { + tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codebuff-history-')) + }) + + afterEach(() => { + fs.rmSync(tempDataDir, { recursive: true, force: true }) + }) + + test('deleteChatSession removes a saved chat directory', () => { + writeChat('chat-a', 'hello from chat a') + writeChat('chat-b', 'hello from chat b') + + expect(deleteChatSession('chat-a')).toBe(true) + + expect(fs.existsSync(path.join(tempDataDir, 'chats', 'chat-a'))).toBe(false) + expect(fs.existsSync(path.join(tempDataDir, 'chats', 'chat-b'))).toBe(true) + expect(getAllChats().map((chat) => chat.chatId)).toEqual(['chat-b']) + }) + + test('deleteChatSession rejects invalid chat ids', () => { + const outsideDir = path.join(tempDataDir, 'outside') + fs.mkdirSync(outsideDir, { recursive: true }) + + expect(deleteChatSession('../outside')).toBe(false) + expect(deleteChatSession('..')).toBe(false) + + expect(fs.existsSync(outsideDir)).toBe(true) + }) + + test('deleteChatSession returns false when the chat does not exist', () => { + expect(deleteChatSession('missing-chat')).toBe(false) + }) +}) diff --git a/cli/src/utils/__tests__/chatgpt-oauth.test.ts b/cli/src/utils/__tests__/chatgpt-oauth.test.ts new file mode 100644 index 0000000000..6c2c04c49d --- /dev/null +++ b/cli/src/utils/__tests__/chatgpt-oauth.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' + +import { + exchangeChatGptCodeForTokens, + startChatGptOAuthFlow, +} from '../chatgpt-oauth' + +describe('chatgpt-oauth utility', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('token exchange error is sanitized and does not include response body', async () => { + startChatGptOAuthFlow() + + globalThis.fetch = mock(async () => { + return { + ok: false, + status: 401, + text: async () => + 'invalid_grant access_token=secret-token refresh_token=secret-refresh', + } as unknown as Response + }) as unknown as typeof fetch + + const error = await exchangeChatGptCodeForTokens('auth-code').catch((e) => e) + + expect(error).toBeInstanceOf(Error) + expect(error.message).toContain('status 401') + expect(error.message).not.toContain('secret-token') + expect(error.message).not.toContain('secret-refresh') + expect(error.message).not.toContain('invalid_grant') + }) +}) diff --git a/cli/src/utils/__tests__/clipboard.test.ts b/cli/src/utils/__tests__/clipboard.test.ts new file mode 100644 index 0000000000..e977f3f9f4 --- /dev/null +++ b/cli/src/utils/__tests__/clipboard.test.ts @@ -0,0 +1,775 @@ +import { execSync } from 'child_process' + +import { createMockTimers } from '@codebuff/common/testing/mocks/timers' +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test' + +import { + copyTextToClipboard, + showClipboardMessage, + subscribeClipboardMessages, + clearClipboardMessage, + registerClipboardRenderer, + unregisterClipboardRenderer, +} from '../clipboard' +import { logger } from '../logger' + +import type { MockTimers } from '@codebuff/common/testing/mocks/timers' + +/** + * Tests for clipboard.ts functionality. + * + * What IS tested: + * - Message subscription system (show, clear, timer cancellation, multiple subscribers) + * - Empty/whitespace text handling (early return) + * - Success message formatting (truncation, whitespace collapse, custom messages) + * - Error handling when both copy methods fail + * - macOS integration test (actual pbcopy when available) + * + * What is NOT fully tested (internal functions are not exported): + * - SSH session detection logic (isRemoteSession) + * - OSC52 sequence generation (buildOsc52Sequence) with tmux/screen wrapping + * - Platform tool selection (tryCopyViaPlatformTool) for Linux/Windows + * - OSC52 32KB payload size limit + * + * The copy priority behavior (local: platform tools first, remote: OSC52 first) + * is tested indirectly through the error handling tests. + */ + +describe('clipboard', () => { + describe('showClipboardMessage and subscriptions', () => { + let mockTimers: MockTimers + let receivedMessages: (string | null)[] + + beforeEach(() => { + mockTimers = createMockTimers() + mockTimers.install() + receivedMessages = [] + clearClipboardMessage() + }) + + afterEach(() => { + mockTimers.restore() + clearClipboardMessage() + }) + + test('notifies subscribers when message is shown', () => { + const unsubscribe = subscribeClipboardMessages((msg) => { + receivedMessages.push(msg) + }) + + showClipboardMessage('Test message') + + expect(receivedMessages).toContain('Test message') + + unsubscribe() + }) + + test('clears message after default duration (3000ms)', () => { + const unsubscribe = subscribeClipboardMessages((msg) => { + receivedMessages.push(msg) + }) + + showClipboardMessage('Test message') + expect(receivedMessages).toContain('Test message') + + mockTimers.advanceBy(3001) + + expect(receivedMessages[receivedMessages.length - 1]).toBeNull() + + unsubscribe() + }) + + test('clears message after custom duration', () => { + const unsubscribe = subscribeClipboardMessages((msg) => { + receivedMessages.push(msg) + }) + + showClipboardMessage('Test message', { durationMs: 1000 }) + + mockTimers.advanceBy(1001) + + expect(receivedMessages[receivedMessages.length - 1]).toBeNull() + + unsubscribe() + }) + + test('cancels previous timer when new message is shown', () => { + // Subscribe first, then show messages + const unsubscribe = subscribeClipboardMessages((msg) => { + receivedMessages.push(msg) + }) + + // Clear initial null from subscription + receivedMessages = [] + + showClipboardMessage('First message', { durationMs: 5000 }) + mockTimers.advanceBy(2000) + showClipboardMessage('Second message', { durationMs: 5000 }) + mockTimers.advanceBy(3000) + + // First message's timer should have been cancelled, so no null yet + expect(receivedMessages).toEqual(['First message', 'Second message']) + + unsubscribe() + }) + + test('unsubscribe stops receiving messages', () => { + const unsubscribe = subscribeClipboardMessages((msg) => { + receivedMessages.push(msg) + }) + + // Clear initial null + receivedMessages = [] + + showClipboardMessage('Before unsubscribe') + unsubscribe() + showClipboardMessage('After unsubscribe') + + expect(receivedMessages).toContain('Before unsubscribe') + expect(receivedMessages).not.toContain('After unsubscribe') + }) + + test('multiple subscribers all receive messages', () => { + const messages1: (string | null)[] = [] + const messages2: (string | null)[] = [] + + const unsub1 = subscribeClipboardMessages((msg) => messages1.push(msg)) + const unsub2 = subscribeClipboardMessages((msg) => messages2.push(msg)) + + showClipboardMessage('Broadcast message') + + expect(messages1).toContain('Broadcast message') + expect(messages2).toContain('Broadcast message') + + unsub1() + unsub2() + }) + + test('clearClipboardMessage immediately clears the message', () => { + const unsubscribe = subscribeClipboardMessages((msg) => { + receivedMessages.push(msg) + }) + + showClipboardMessage('Test message', { durationMs: 10000 }) + clearClipboardMessage() + + expect(receivedMessages[receivedMessages.length - 1]).toBeNull() + + unsubscribe() + }) + }) + + describe('copyTextToClipboard - empty/whitespace handling', () => { + beforeEach(() => { + clearClipboardMessage() + }) + + afterEach(() => { + clearClipboardMessage() + }) + + test('returns early for empty string', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + messages.length = 0 // Clear initial null + + await copyTextToClipboard('') + + // Should not show any success or error message + expect(messages.filter((m) => m !== null)).toHaveLength(0) + + unsubscribe() + }) + + test('returns early for whitespace-only string', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + messages.length = 0 // Clear initial null + + await copyTextToClipboard(' \n\t ') + + // Should not show any success or error message + expect(messages.filter((m) => m !== null)).toHaveLength(0) + + unsubscribe() + }) + }) + + describe('copyTextToClipboard - success message formatting', () => { + // These tests run on macOS with actual pbcopy - skip on other platforms/CI + const shouldRun = process.platform === 'darwin' && !process.env.CI + + beforeEach(() => { + clearClipboardMessage() + }) + + afterEach(() => { + clearClipboardMessage() + }) + + test.skipIf(!shouldRun)('formats short text with quotes', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + + await copyTextToClipboard('Hello') + + expect(messages).toContain('Copied: "Hello"') + + unsubscribe() + }) + + test.skipIf(!shouldRun)('truncates long text with ellipsis', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + + const longText = 'This is a very long piece of text that should be truncated because it exceeds the maximum display length' + await copyTextToClipboard(longText) + + const lastMessage = messages.find((m) => m?.startsWith('Copied:')) + expect(lastMessage).toBeDefined() + expect(lastMessage!.length).toBeLessThan(55) // "Copied: " + 40 chars max + quotes + expect(lastMessage).toContain('…') + + unsubscribe() + }) + + test.skipIf(!shouldRun)('collapses whitespace in preview', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + + await copyTextToClipboard('Hello\n\n\nWorld\t\tTest') + + expect(messages).toContain('Copied: "Hello World Test"') + + unsubscribe() + }) + + test.skipIf(!shouldRun)('uses custom success message when provided', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + + await copyTextToClipboard('test', { successMessage: 'Custom success!' }) + + expect(messages).toContain('Custom success!') + + unsubscribe() + }) + + test.skipIf(!shouldRun)('shows no message when successMessage is null', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + messages.length = 0 // Clear initial null + + await copyTextToClipboard('test', { successMessage: null }) + + expect(messages.filter((m) => m?.startsWith('Copied'))).toHaveLength(0) + + unsubscribe() + }) + + test.skipIf(!shouldRun)('suppresses message when suppressGlobalMessage is true', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + messages.length = 0 // Clear initial null + + await copyTextToClipboard('test', { suppressGlobalMessage: true }) + + expect(messages.filter((m) => m !== null)).toHaveLength(0) + + unsubscribe() + }) + }) + + describe('copyTextToClipboard - error handling when both methods fail', () => { + let mockTimers: MockTimers + let loggerErrorSpy: ReturnType + let originalPlatform: PropertyDescriptor | undefined + let originalEnv: { SSH_CLIENT?: string; SSH_TTY?: string; SSH_CONNECTION?: string; TERM?: string } + + beforeEach(() => { + mockTimers = createMockTimers() + mockTimers.install() + + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + // Use a platform that has no clipboard tool (freebsd) + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + + // Save env vars + originalEnv = { + SSH_CLIENT: process.env.SSH_CLIENT, + SSH_TTY: process.env.SSH_TTY, + SSH_CONNECTION: process.env.SSH_CONNECTION, + TERM: process.env.TERM, + } + // Clear SSH env vars to ensure local session detection + delete process.env.SSH_CLIENT + delete process.env.SSH_TTY + delete process.env.SSH_CONNECTION + // Set TERM=dumb to disable OSC52 (it returns early for dumb terminals) + process.env.TERM = 'dumb' + + loggerErrorSpy = spyOn(logger, 'error').mockImplementation(() => {}) + + clearClipboardMessage() + }) + + afterEach(() => { + mockTimers.restore() + loggerErrorSpy.mockRestore() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + // Restore env vars + if (originalEnv.SSH_CLIENT !== undefined) process.env.SSH_CLIENT = originalEnv.SSH_CLIENT + else delete process.env.SSH_CLIENT + if (originalEnv.SSH_TTY !== undefined) process.env.SSH_TTY = originalEnv.SSH_TTY + else delete process.env.SSH_TTY + if (originalEnv.SSH_CONNECTION !== undefined) process.env.SSH_CONNECTION = originalEnv.SSH_CONNECTION + else delete process.env.SSH_CONNECTION + if (originalEnv.TERM !== undefined) process.env.TERM = originalEnv.TERM + else delete process.env.TERM + clearClipboardMessage() + }) + + test('shows default error message when both methods fail', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + + await expect(copyTextToClipboard('test text')).rejects.toThrow() + + expect(messages).toContain('Failed to copy to clipboard') + + unsubscribe() + }) + + test('shows custom error message when provided', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + + await expect( + copyTextToClipboard('test text', { errorMessage: 'Custom error!' }) + ).rejects.toThrow() + + expect(messages).toContain('Custom error!') + + unsubscribe() + }) + + test('suppresses error message when suppressGlobalMessage is true', async () => { + const messages: (string | null)[] = [] + const unsubscribe = subscribeClipboardMessages((msg) => messages.push(msg)) + messages.length = 0 // Clear initial + + await expect( + copyTextToClipboard('test text', { suppressGlobalMessage: true }) + ).rejects.toThrow() + + expect(messages.filter((m) => m !== null)).toHaveLength(0) + + unsubscribe() + }) + + test('logs error when both methods fail', async () => { + await expect( + copyTextToClipboard('test text', { suppressGlobalMessage: true }) + ).rejects.toThrow() + + expect(loggerErrorSpy).toHaveBeenCalled() + }) + + test('throws error when both methods fail', async () => { + await expect( + copyTextToClipboard('test text', { suppressGlobalMessage: true }) + ).rejects.toThrow('No clipboard method available') + }) + }) + + describe('copyTextToClipboard - integration test', () => { + // This test actually calls the real clipboard on macOS + // Skip on CI or non-macOS systems + const shouldRun = process.platform === 'darwin' && !process.env.CI + + test.skipIf(!shouldRun)('actually copies text to system clipboard on macOS', async () => { + const testText = `clipboard-test-${Date.now()}` + + await copyTextToClipboard(testText, { suppressGlobalMessage: true }) + + // Verify with pbpaste + const clipboardContent = execSync('pbpaste', { encoding: 'utf8' }) + + expect(clipboardContent).toBe(testText) + }) + }) + + 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. + // We can't directly test isRemoteSession() since it's not exported, but we can verify + // the behavior by observing what happens when platform tools are unavailable. + + let originalEnv: Record + let originalPlatform: PropertyDescriptor | undefined + let loggerErrorSpy: ReturnType + + beforeEach(() => { + 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, + } + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + loggerErrorSpy = spyOn(logger, 'error').mockImplementation(() => {}) + clearClipboardMessage() + }) + + afterEach(() => { + // Restore all env vars + for (const [key, value] of Object.entries(originalEnv)) { + if (value !== undefined) process.env[key] = value + else delete process.env[key] + } + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + loggerErrorSpy.mockRestore() + clearClipboardMessage() + }) + + test('SSH_CLIENT env var triggers remote session behavior', async () => { + // Set up as remote session with SSH_CLIENT + process.env.SSH_CLIENT = '192.168.1.100 54321 22' + delete process.env.SSH_TTY + delete process.env.SSH_CONNECTION + process.env.TERM = 'xterm-256color' + delete process.env.TMUX + delete process.env.STY + + // Use freebsd platform so platform tools fail, forcing OSC52 path + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + + // In remote session with working /dev/tty, OSC52 should succeed + // This test verifies that having SSH_CLIENT set changes the behavior + // (the copy may succeed or fail depending on /dev/tty availability) + try { + await copyTextToClipboard('test', { suppressGlobalMessage: true }) + // If it succeeded, OSC52 worked in remote mode + } catch { + // If it failed, that's expected when /dev/tty isn't available + // The important thing is that the code path was triggered + } + + // Test passed - code executed the SSH detection path + expect(true).toBe(true) + }) + + test('SSH_TTY env var triggers remote session behavior', async () => { + delete process.env.SSH_CLIENT + process.env.SSH_TTY = '/dev/pts/0' + delete process.env.SSH_CONNECTION + process.env.TERM = 'xterm-256color' + + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + + try { + await copyTextToClipboard('test', { suppressGlobalMessage: true }) + } catch { + // Expected when /dev/tty isn't available + } + + expect(true).toBe(true) + }) + + test('SSH_CONNECTION env var triggers remote session behavior', async () => { + delete process.env.SSH_CLIENT + delete process.env.SSH_TTY + process.env.SSH_CONNECTION = '192.168.1.100 54321 10.0.0.1 22' + process.env.TERM = 'xterm-256color' + + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + + try { + await copyTextToClipboard('test', { suppressGlobalMessage: true }) + } catch { + // Expected when /dev/tty isn't available + } + + expect(true).toBe(true) + }) + + test('no SSH env vars triggers local session behavior (platform tools first)', async () => { + // Clear all SSH env vars + delete process.env.SSH_CLIENT + delete process.env.SSH_TTY + delete process.env.SSH_CONNECTION + process.env.TERM = 'xterm-256color' + + // Restore the original platform for this test since we need real platform tools + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + + // On macOS with no SSH vars, should try pbcopy first (local session) + if (process.platform === 'darwin' && !process.env.CI) { + const testText = `local-session-test-${Date.now()}` + await copyTextToClipboard(testText, { suppressGlobalMessage: true }) + + // Verify pbcopy was used (local path) + const clipboardContent = execSync('pbpaste', { encoding: 'utf8' }) + expect(clipboardContent).toBe(testText) + } else { + // On non-macOS or CI, just verify no errors when detecting local session + expect(true).toBe(true) + } + }) + }) + + describe('copyTextToClipboard - OSC52 behavior', () => { + // Tests for OSC52 escape sequence behavior. + // OSC52 is used for clipboard access over SSH and in terminal multiplexers. + + let originalEnv: Record + let originalPlatform: PropertyDescriptor | undefined + let loggerErrorSpy: ReturnType + + beforeEach(() => { + 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, + } + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + loggerErrorSpy = spyOn(logger, 'error').mockImplementation(() => {}) + clearClipboardMessage() + }) + + afterEach(() => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value !== undefined) process.env[key] = value + else delete process.env[key] + } + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + loggerErrorSpy.mockRestore() + clearClipboardMessage() + }) + + test('TERM=dumb disables OSC52 (returns null sequence)', async () => { + // TERM=dumb should cause OSC52 to be skipped entirely + 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 + + // Use freebsd so platform tools also fail + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + + // Should fail because both methods are disabled + await expect( + copyTextToClipboard('test', { suppressGlobalMessage: true }) + ).rejects.toThrow('No clipboard method available') + }) + + test('very large text (>32KB) causes OSC52 to be skipped due to size limit', async () => { + // OSC52 has a 32KB limit for the base64-encoded payload + // Text that encodes to >32KB should cause OSC52 to return null + delete process.env.SSH_CLIENT + delete process.env.SSH_TTY + delete process.env.SSH_CONNECTION + process.env.TERM = 'xterm-256color' + delete process.env.TMUX + delete process.env.STY + + // Use freebsd so platform tools fail, only OSC52 available + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + + // Create text that will exceed 32KB when base64 encoded + // Base64 expands by ~4/3, so 25KB of text should exceed 32KB encoded + const largeText = 'x'.repeat(25_000) + + // Should fail because OSC52 rejects oversized payload and platform tools unavailable + await expect( + copyTextToClipboard(largeText, { suppressGlobalMessage: true }) + ).rejects.toThrow('No clipboard method available') + }) + + test('TMUX env var should use tmux passthrough wrapping for OSC52', async () => { + // When TMUX is set, OSC52 should wrap in DCS passthrough + // We can't directly verify the sequence, but we can verify the path is taken + process.env.SSH_CLIENT = '192.168.1.100 54321 22' // Force remote session + process.env.TERM = 'xterm-256color' + process.env.TMUX = '/tmp/tmux-1000/default,12345,0' + delete process.env.STY + + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + + try { + await copyTextToClipboard('test', { suppressGlobalMessage: true }) + // Success means tmux passthrough worked + } catch { + // Failure expected if /dev/tty not available, but path was exercised + } + + expect(true).toBe(true) + }) + + test('STY env var (GNU screen) should use screen passthrough wrapping for OSC52', async () => { + // When STY is set (GNU screen), OSC52 should use screen-style passthrough + process.env.SSH_CLIENT = '192.168.1.100 54321 22' + process.env.TERM = 'screen-256color' + delete process.env.TMUX + process.env.STY = '12345.pts-0.hostname' + + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + + try { + await copyTextToClipboard('test', { suppressGlobalMessage: true }) + } catch { + // Expected if /dev/tty not available + } + + expect(true).toBe(true) + }) + }) +}) diff --git a/cli/src/utils/__tests__/code-search-summary.test.ts b/cli/src/utils/__tests__/code-search-summary.test.ts new file mode 100644 index 0000000000..6634496130 --- /dev/null +++ b/cli/src/utils/__tests__/code-search-summary.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test' + +import { + countCodeSearchResults, + getCodeSearcherCollapsedPreview, +} from '../code-search-summary' + +import type { AgentContentBlock, ToolContentBlock } from '../../types/chat' + +const createCodeSearchToolBlock = ( + output: string, + id = 'tool-1', +): ToolContentBlock => ({ + type: 'tool', + toolCallId: id, + toolName: 'code_search', + input: { pattern: 'MODEL_ID' }, + output, +}) + +const createCodeSearcherBlock = ( + options: Partial = {}, +): AgentContentBlock => ({ + type: 'agent', + agentId: 'agent-1', + agentName: 'code-searcher', + agentType: 'code-searcher', + content: '', + status: 'complete', + params: { + searchQueries: [ + { pattern: 'FREEBUFF_MODEL_SELECTOR_MODELS' }, + { pattern: 'FREEBUFF_MODEL_SELECTOR_MODEL_IDS' }, + { pattern: 'DEFAULT_FREEBUFF_MODEL_ID' }, + ], + }, + blocks: [], + ...options, +}) + +describe('code search summary helpers', () => { + test('counts formatted code search matches from stdout', () => { + expect( + countCodeSearchResults(`stdout: |- + Found 2 matches + ./message-block-helpers.ts: + Line 13: export const getAgentBaseName = (type: string): string => { + Line 196: getAgentBaseName(options.agentType ?? '') === 'code-searcher'`), + ).toBe(2) + }) + + test('summarizes collapsed code-searcher searches and results', () => { + const agentBlock = createCodeSearcherBlock({ + blocks: [ + createCodeSearchToolBlock('Found 7 matches', 'tool-1'), + createCodeSearchToolBlock('Found 2 matches', 'tool-2'), + createCodeSearchToolBlock('Found 7 matches', 'tool-3'), + ], + }) + + expect(getCodeSearcherCollapsedPreview(agentBlock)).toBe( + '3 searches · 16 results', + ) + }) + + test('shows search count before tool outputs arrive', () => { + expect(getCodeSearcherCollapsedPreview(createCodeSearcherBlock())).toBe( + '3 searches', + ) + }) + + test('handles singular labels', () => { + const agentBlock = createCodeSearcherBlock({ + params: { + searchQueries: [{ pattern: 'DEFAULT_FREEBUFF_MODEL_ID' }], + }, + blocks: [createCodeSearchToolBlock('Found 1 match')], + }) + + expect(getCodeSearcherCollapsedPreview(agentBlock)).toBe( + '1 search · 1 result', + ) + }) +}) diff --git a/cli/src/utils/__tests__/codebuff-api.test.ts b/cli/src/utils/__tests__/codebuff-api.test.ts index 31be2844d3..92dea6d39b 100644 --- a/cli/src/utils/__tests__/codebuff-api.test.ts +++ b/cli/src/utils/__tests__/codebuff-api.test.ts @@ -1,5 +1,7 @@ import { describe, test, expect, mock, beforeEach } from 'bun:test' +import type { FeedbackRequest } from '@codebuff/common/schemas/feedback' + import { createCodebuffApiClient } from '../codebuff-api' // Type for mocked fetch function @@ -471,4 +473,62 @@ describe('createCodebuffApiClient', () => { }) }) }) + + describe('feedback method', () => { + const minimalFeedbackPayload: FeedbackRequest = { + category: 'other', + type: 'general', + text: 'test feedback', + } + + test('should not retry on 429 (rate limit) responses', async () => { + const mockRateLimitFetch = mock(() => + Promise.resolve({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + json: () => Promise.resolve({ error: 'Rate limited' }), + } as Response), + ) + + const client = createCodebuffApiClient({ + baseUrl: 'https://test.api', + fetch: mockRateLimitFetch as unknown as typeof fetch, + retry: { maxRetries: 3, initialDelayMs: 10 }, + }) + + const result = await client.feedback(minimalFeedbackPayload) + + expect(result.ok).toBe(false) + expect(result.status).toBe(429) + expect(mockRateLimitFetch).toHaveBeenCalledTimes(1) + }) + + test('should not retry on 500 responses (non-idempotent endpoint)', async () => { + const mockServerErrorFetch = mock(() => + Promise.resolve({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: () => Promise.resolve({ error: 'Server error' }), + } as Response), + ) + + const client = createCodebuffApiClient({ + baseUrl: 'https://test.api', + fetch: mockServerErrorFetch as unknown as typeof fetch, + retry: { + maxRetries: 3, + initialDelayMs: 10, + maxDelayMs: 50, + }, + }) + + const result = await client.feedback(minimalFeedbackPayload) + + expect(result.ok).toBe(false) + expect(result.status).toBe(500) + expect(mockServerErrorFetch).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/cli/src/utils/__tests__/collapse-helpers.test.ts b/cli/src/utils/__tests__/collapse-helpers.test.ts new file mode 100644 index 0000000000..dcd4ef83bd --- /dev/null +++ b/cli/src/utils/__tests__/collapse-helpers.test.ts @@ -0,0 +1,1109 @@ +import { describe, test, expect } from 'bun:test' + +import { setAllBlocksCollapsedState, hasAnyExpandedBlocks } from '../collapse-helpers' + +import type { + ChatMessage, + ContentBlock, + ToolContentBlock, + AgentContentBlock, + TextContentBlock, + AgentListContentBlock, + ThinkingCollapseState, +} from '../../types/chat' + +// Type helper for accessing isCollapsed/userOpened on any block type +type CollapsibleBlock = ToolContentBlock | AgentContentBlock | TextContentBlock | AgentListContentBlock + +// Helper to create minimal test messages +const createMessage = ( + id: string, + variant: 'ai' | 'user' | 'agent' | 'error' = 'ai', + blocks?: ContentBlock[], + metadata?: { isCollapsed?: boolean; userOpened?: boolean }, +): ChatMessage => ({ + id, + variant, + content: '', + timestamp: new Date().toISOString(), + blocks, + metadata, +}) + +// Helper to create tool blocks +const createToolBlock = ( + toolCallId: string, + isCollapsed?: boolean, + userOpened?: boolean, +): ContentBlock => ({ + type: 'tool', + toolCallId, + toolName: 'read_files', + input: {}, + isCollapsed, + userOpened, +}) + +// Helper to create agent blocks +const createAgentBlock = ( + agentId: string, + isCollapsed?: boolean, + userOpened?: boolean, + nestedBlocks?: ContentBlock[], +): ContentBlock => ({ + type: 'agent', + agentId, + agentName: 'Test Agent', + agentType: 'test-agent', + content: '', + status: 'complete', + isCollapsed, + userOpened, + blocks: nestedBlocks, +}) + +// Helper to create thinking/text blocks with thinkingId +const createThinkingBlock = ( + thinkingId: string, + thinkingCollapseState?: ThinkingCollapseState, + userOpened?: boolean, +): ContentBlock => ({ + type: 'text', + content: 'thinking content', + thinkingId, + ...(thinkingCollapseState !== undefined && { thinkingCollapseState }), + userOpened, +}) + +// Helper to create agent-list blocks +const createAgentListBlock = ( + id: string, + isCollapsed?: boolean, + userOpened?: boolean, +): ContentBlock => ({ + type: 'agent-list', + id, + agents: [], + agentsDir: '/test', + isCollapsed, + userOpened, +}) + +// Helper to create plain text blocks (not collapsible) +const createTextBlock = (content: string): ContentBlock => ({ + type: 'text', + content, +}) + +describe('hasAnyExpandedBlocks', () => { + describe('empty and basic cases', () => { + test('returns false for empty messages', () => { + expect(hasAnyExpandedBlocks([])).toBe(false) + }) + + test('returns false for messages with no collapsible content', () => { + const messages = [ + createMessage('1', 'user'), + createMessage('2', 'ai', [createTextBlock('hello')]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + + test('returns false for messages with no blocks', () => { + const messages = [createMessage('1', 'ai')] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + }) + + describe('agent variant messages', () => { + test('returns true for expanded agent variant message', () => { + const messages = [createMessage('1', 'agent', undefined, { isCollapsed: false })] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('returns false for collapsed agent variant message', () => { + const messages = [createMessage('1', 'agent', undefined, { isCollapsed: true })] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + + test('returns false for agent variant message with undefined isCollapsed (treated as collapsed)', () => { + const messages = [createMessage('1', 'agent')] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + }) + + describe('tool blocks', () => { + test('returns true when any tool block is expanded', () => { + const messages = [ + createMessage('1', 'ai', [ + createToolBlock('tool-1', true), + createToolBlock('tool-2', false), + ]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('returns false when all tool blocks are collapsed', () => { + const messages = [ + createMessage('1', 'ai', [ + createToolBlock('tool-1', true), + createToolBlock('tool-2', true), + ]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + + test('returns false when tool block has undefined isCollapsed (treated as collapsed)', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1')]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + }) + + describe('agent blocks', () => { + test('returns true when agent block is expanded', () => { + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', false)]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('returns false when agent block is collapsed', () => { + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true)]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + + test('returns true when nested block within collapsed agent is expanded', () => { + const nestedBlocks = [createToolBlock('nested-tool', false)] // expanded + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedBlocks)]), // collapsed parent + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('returns false when agent and all nested blocks are collapsed', () => { + const nestedBlocks = [createToolBlock('nested-tool', true)] // collapsed + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedBlocks)]), // collapsed parent + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + }) + + describe('thinking blocks', () => { + test('returns true when thinking block is expanded', () => { + const messages = [ + createMessage('1', 'ai', [createThinkingBlock('think-1', 'expanded')]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('returns false when thinking block is collapsed', () => { + const messages = [ + createMessage('1', 'ai', [createThinkingBlock('think-1', 'hidden')]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + }) + + describe('agent-list blocks', () => { + test('returns true when agent-list block is expanded', () => { + const messages = [ + createMessage('1', 'ai', [createAgentListBlock('list-1', false)]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('returns false when agent-list block is collapsed', () => { + const messages = [ + createMessage('1', 'ai', [createAgentListBlock('list-1', true)]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + }) + + describe('multiple messages', () => { + test('returns true when any message has expanded content', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1', true)]), + createMessage('2', 'ai', [createAgentBlock('agent-1', false)]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('returns false when all messages have collapsed content', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1', true)]), + createMessage('2', 'ai', [createAgentBlock('agent-1', true)]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + }) + + describe('deeply nested blocks', () => { + test('returns true when deeply nested block is expanded', () => { + const deepNestedBlocks = [createToolBlock('deep-tool', false)] // expanded + const nestedAgentBlocks = [createAgentBlock('nested-agent', true, false, deepNestedBlocks)] // collapsed + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedAgentBlocks)]), // collapsed + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('returns false when all deeply nested blocks are collapsed', () => { + const deepNestedBlocks = [createToolBlock('deep-tool', true)] // collapsed + const nestedAgentBlocks = [createAgentBlock('nested-agent', true, false, deepNestedBlocks)] // collapsed + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedAgentBlocks)]), // collapsed + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + }) +}) + +describe('setAllBlocksCollapsedState', () => { + describe('empty and basic cases', () => { + test('returns empty array for empty messages', () => { + const result = setAllBlocksCollapsedState([], true) + expect(result).toEqual([]) + }) + + test('returns messages unchanged when no collapsible content', () => { + const messages = [ + createMessage('1', 'user'), + createMessage('2', 'ai', [createTextBlock('hello')]), + ] + const result = setAllBlocksCollapsedState(messages, true) + expect(result).toEqual(messages) + }) + + test('returns messages unchanged when no blocks', () => { + const messages = [createMessage('1', 'ai')] + const result = setAllBlocksCollapsedState(messages, true) + expect(result).toEqual(messages) + }) + }) + + describe('agent variant messages', () => { + test('collapses agent variant message', () => { + const messages = [createMessage('1', 'agent', undefined, { isCollapsed: false })] + const result = setAllBlocksCollapsedState(messages, true) + + expect(result[0]?.metadata?.isCollapsed).toBe(true) + }) + + test('expands agent variant message', () => { + const messages = [createMessage('1', 'agent', undefined, { isCollapsed: true })] + const result = setAllBlocksCollapsedState(messages, false) + + expect(result[0]?.metadata?.isCollapsed).toBe(false) + expect(result[0]?.metadata?.userOpened).toBe(true) + }) + + test('does not modify already collapsed agent variant message', () => { + const messages = [createMessage('1', 'agent', undefined, { isCollapsed: true })] + const result = setAllBlocksCollapsedState(messages, true) + + // Should return same reference when no change needed + expect(result[0]).toBe(messages[0]) + }) + + test('does not modify already expanded agent variant message', () => { + const messages = [createMessage('1', 'agent', undefined, { isCollapsed: false })] + const result = setAllBlocksCollapsedState(messages, false) + + expect(result[0]).toBe(messages[0]) + }) + + test('handles agent variant message with undefined isCollapsed when collapsing', () => { + const messages = [createMessage('1', 'agent')] + const result = setAllBlocksCollapsedState(messages, true) + + // undefined is treated as collapsed, so no change should be made + expect(result[0]).toBe(messages[0]) + }) + + test('expands agent variant message with undefined isCollapsed', () => { + const messages = [createMessage('1', 'agent')] + const result = setAllBlocksCollapsedState(messages, false) + + // undefined is treated as collapsed, so expand should work + expect(result[0]?.metadata?.isCollapsed).toBe(false) + expect(result[0]?.metadata?.userOpened).toBe(true) + }) + }) + + describe('tool blocks', () => { + test('collapses all tool blocks', () => { + const messages = [ + createMessage('1', 'ai', [ + createToolBlock('tool-1', false), + createToolBlock('tool-2', false), + ]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const blocks = result[0]?.blocks as CollapsibleBlock[] + expect(blocks[0]?.isCollapsed).toBe(true) + expect(blocks[1]?.isCollapsed).toBe(true) + }) + + test('expands all tool blocks', () => { + const messages = [ + createMessage('1', 'ai', [ + createToolBlock('tool-1', true), + createToolBlock('tool-2', true), + ]), + ] + const result = setAllBlocksCollapsedState(messages, false) + + const blocks = result[0]?.blocks as CollapsibleBlock[] + expect(blocks[0]?.isCollapsed).toBe(false) + expect(blocks[0]?.userOpened).toBe(true) + expect(blocks[1]?.isCollapsed).toBe(false) + expect(blocks[1]?.userOpened).toBe(true) + }) + + test('handles mixed collapsed states', () => { + const messages = [ + createMessage('1', 'ai', [ + createToolBlock('tool-1', true), + createToolBlock('tool-2', false), + ]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const blocks = result[0]?.blocks as CollapsibleBlock[] + expect(blocks[0]?.isCollapsed).toBe(true) + expect(blocks[1]?.isCollapsed).toBe(true) + }) + + test('expands tool blocks with undefined isCollapsed', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1')]), + ] + const result = setAllBlocksCollapsedState(messages, false) + + // undefined is treated as collapsed, so expand should work + const block = result[0]?.blocks?.[0] as CollapsibleBlock + expect(block?.isCollapsed).toBe(false) + expect(block?.userOpened).toBe(true) + }) + + test('does not modify tool block with undefined isCollapsed when collapsing', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1')]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + // undefined is treated as collapsed, so no change should be made + expect(result[0]).toBe(messages[0]) + }) + }) + + describe('agent blocks', () => { + test('collapses agent blocks', () => { + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', false)]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const block = result[0]?.blocks?.[0] as CollapsibleBlock + expect(block?.isCollapsed).toBe(true) + }) + + test('expands agent blocks and sets userOpened', () => { + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true)]), + ] + const result = setAllBlocksCollapsedState(messages, false) + + const block = result[0]?.blocks?.[0] as CollapsibleBlock + expect(block?.isCollapsed).toBe(false) + expect(block?.userOpened).toBe(true) + }) + + test('handles nested blocks within agent blocks', () => { + const nestedBlocks = [ + createToolBlock('nested-tool-1', false), + createToolBlock('nested-tool-2', false), + ] + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', false, false, nestedBlocks)]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const agentBlock = result[0]?.blocks?.[0] as AgentContentBlock + const nestedBlocksResult = agentBlock?.blocks as CollapsibleBlock[] + expect(nestedBlocksResult?.[0]?.isCollapsed).toBe(true) + expect(nestedBlocksResult?.[1]?.isCollapsed).toBe(true) + }) + + test('handles deeply nested agent blocks', () => { + const deepNestedBlocks = [createToolBlock('deep-tool', false)] + const nestedAgentBlocks = [createAgentBlock('nested-agent', false, false, deepNestedBlocks)] + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', false, false, nestedAgentBlocks)]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const outerAgent = result[0]?.blocks?.[0] as AgentContentBlock + expect(outerAgent?.isCollapsed).toBe(true) + + const innerAgent = outerAgent?.blocks?.[0] as AgentContentBlock + expect(innerAgent?.isCollapsed).toBe(true) + + const deepBlock = innerAgent?.blocks?.[0] as CollapsibleBlock + expect(deepBlock?.isCollapsed).toBe(true) + }) + }) + + describe('thinking blocks', () => { + test('collapses thinking blocks', () => { + const messages = [ + createMessage('1', 'ai', [createThinkingBlock('think-1', 'expanded')]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const block = result[0]?.blocks?.[0] as TextContentBlock + expect(block?.thinkingCollapseState).toBe('hidden') + }) + + test('expands thinking blocks and sets userOpened', () => { + const messages = [ + createMessage('1', 'ai', [createThinkingBlock('think-1', 'hidden')]), + ] + const result = setAllBlocksCollapsedState(messages, false) + + const block = result[0]?.blocks?.[0] as TextContentBlock + expect(block?.thinkingCollapseState).toBe('expanded') + expect(block?.userOpened).toBe(true) + }) + + test('does not collapse text blocks without thinkingId', () => { + const messages = [ + createMessage('1', 'ai', [createTextBlock('regular text')]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + // Should return same reference since no change + expect(result[0]).toBe(messages[0]) + }) + }) + + describe('agent-list blocks', () => { + test('collapses agent-list blocks', () => { + const messages = [ + createMessage('1', 'ai', [createAgentListBlock('list-1', false)]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const block = result[0]?.blocks?.[0] as CollapsibleBlock + expect(block?.isCollapsed).toBe(true) + }) + + test('expands agent-list blocks and sets userOpened', () => { + const messages = [ + createMessage('1', 'ai', [createAgentListBlock('list-1', true)]), + ] + const result = setAllBlocksCollapsedState(messages, false) + + const block = result[0]?.blocks?.[0] as CollapsibleBlock + expect(block?.isCollapsed).toBe(false) + expect(block?.userOpened).toBe(true) + }) + }) + + describe('mixed block types', () => { + test('collapses all block types together', () => { + const messages = [ + createMessage('1', 'ai', [ + createToolBlock('tool-1', false), + createAgentBlock('agent-1', false), + createThinkingBlock('think-1', 'expanded'), + createAgentListBlock('list-1', false), + createTextBlock('regular text'), + ]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const blocks = result[0]?.blocks as CollapsibleBlock[] + expect(blocks[0]?.isCollapsed).toBe(true) // tool + expect(blocks[1]?.isCollapsed).toBe(true) // agent + expect((blocks[2] as TextContentBlock)?.thinkingCollapseState).toBe('hidden') // thinking + expect(blocks[3]?.isCollapsed).toBe(true) // agent-list + expect((blocks[4] as TextContentBlock)?.isCollapsed).toBeUndefined() // text (not collapsible) + }) + + test('expands all block types together', () => { + const messages = [ + createMessage('1', 'ai', [ + createToolBlock('tool-1', true), + createAgentBlock('agent-1', true), + createThinkingBlock('think-1', 'hidden'), + createAgentListBlock('list-1', true), + ]), + ] + const result = setAllBlocksCollapsedState(messages, false) + + const blocks = result[0]?.blocks as CollapsibleBlock[] + expect(blocks[0]?.isCollapsed).toBe(false) + expect(blocks[0]?.userOpened).toBe(true) + expect(blocks[1]?.isCollapsed).toBe(false) + expect(blocks[1]?.userOpened).toBe(true) + expect((blocks[2] as TextContentBlock)?.thinkingCollapseState).toBe('expanded') + expect((blocks[2] as TextContentBlock)?.userOpened).toBe(true) + expect(blocks[3]?.isCollapsed).toBe(false) + expect(blocks[3]?.userOpened).toBe(true) + }) + }) + + describe('multiple messages', () => { + test('collapses blocks across multiple messages', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1', false)]), + createMessage('2', 'ai', [createAgentBlock('agent-1', false)]), + createMessage('3', 'agent', undefined, { isCollapsed: false }), + ] + const result = setAllBlocksCollapsedState(messages, true) + + expect((result[0]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + expect((result[1]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + expect(result[2]?.metadata?.isCollapsed).toBe(true) + }) + + test('expands blocks across multiple messages', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1', true)]), + createMessage('2', 'ai', [createAgentBlock('agent-1', true)]), + createMessage('3', 'agent', undefined, { isCollapsed: true }), + ] + const result = setAllBlocksCollapsedState(messages, false) + + expect((result[0]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(false) + expect((result[1]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(false) + expect(result[2]?.metadata?.isCollapsed).toBe(false) + }) + + test('only modifies messages with collapsible content', () => { + const messages = [ + createMessage('1', 'user'), + createMessage('2', 'ai', [createToolBlock('tool-1', false)]), + createMessage('3', 'ai', [createTextBlock('regular text')]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + // User message unchanged + expect(result[0]).toBe(messages[0]) + // Tool block message changed + expect(result[1]).not.toBe(messages[1]) + expect((result[1]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + // Text-only message unchanged + expect(result[2]).toBe(messages[2]) + }) + }) + + describe('userOpened behavior', () => { + test('sets userOpened to true when expanding', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1', true, false)]), + ] + const result = setAllBlocksCollapsedState(messages, false) + + const block = result[0]?.blocks?.[0] as CollapsibleBlock + expect(block?.userOpened).toBe(true) + }) + + test('preserves existing userOpened when collapsing', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1', false, true)]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const block = result[0]?.blocks?.[0] as CollapsibleBlock + expect(block?.userOpened).toBe(true) + }) + + test('handles undefined userOpened when collapsing', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1', false)]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const block = result[0]?.blocks?.[0] as CollapsibleBlock + expect(block?.userOpened).toBeUndefined() + }) + }) + + describe('reference preservation (optimization)', () => { + test('preserves message reference when no changes needed', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1', true)]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + expect(result[0]).toBe(messages[0]) + }) + + test('preserves blocks array reference when no nested changes', () => { + const messages = [ + createMessage('1', 'ai', [createTextBlock('no change needed')]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + expect(result[0]?.blocks).toBe(messages[0]?.blocks) + }) + }) + + describe('edge cases', () => { + test('handles undefined blocks in agent block', () => { + const agentBlock = createAgentBlock('agent-1', false) + delete (agentBlock as { blocks?: ContentBlock[] }).blocks + + const messages = [createMessage('1', 'ai', [agentBlock])] + const result = setAllBlocksCollapsedState(messages, true) + + expect((result[0]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + }) + + test('handles empty blocks array', () => { + const messages = [createMessage('1', 'ai', [])] + const result = setAllBlocksCollapsedState(messages, true) + + expect(result[0]).toBe(messages[0]) + }) + + test('handles message with undefined metadata for agent variant when collapsing', () => { + const message = createMessage('1', 'agent') + delete message.metadata + + const result = setAllBlocksCollapsedState([message], true) + + // undefined metadata is treated as collapsed, so no change should be made + expect(result[0]).toBe(message) + }) + + test('handles message with undefined metadata for agent variant when expanding', () => { + const message = createMessage('1', 'agent') + delete message.metadata + + const result = setAllBlocksCollapsedState([message], false) + + // undefined metadata is treated as collapsed, so expand should work + expect(result[0]?.metadata?.isCollapsed).toBe(false) + expect(result[0]?.metadata?.userOpened).toBe(true) + }) + }) +}) + +describe('toggle-all edge cases', () => { + describe('nested agent blocks with mixed collapsed states', () => { + test('hasAnyExpandedBlocks: collapsed parent with expanded child returns true', () => { + const nestedBlocks = [createToolBlock('tool-1', false)] // expanded + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedBlocks)]), // collapsed parent + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('hasAnyExpandedBlocks: expanded parent with collapsed child returns true', () => { + const nestedBlocks = [createToolBlock('tool-1', true)] // collapsed + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', false, false, nestedBlocks)]), // expanded parent + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('hasAnyExpandedBlocks: expanded parent with expanded child returns true', () => { + const nestedBlocks = [createToolBlock('tool-1', false)] // expanded + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', false, false, nestedBlocks)]), // expanded parent + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('hasAnyExpandedBlocks: collapsed parent with collapsed child returns false', () => { + const nestedBlocks = [createToolBlock('tool-1', true)] // collapsed + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedBlocks)]), // collapsed parent + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + + test('hasAnyExpandedBlocks: collapsed parent with mixed nested states returns true', () => { + const nestedBlocks = [ + createToolBlock('tool-1', true), // collapsed + createToolBlock('tool-2', false), // expanded + createToolBlock('tool-3', true), // collapsed + ] + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedBlocks)]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('setAllBlocksCollapsedState: collapses both parent and nested blocks', () => { + const nestedBlocks = [ + createToolBlock('tool-1', false), + createThinkingBlock('think-1', 'expanded'), + ] + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', false, false, nestedBlocks)]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const agentBlock = result[0]?.blocks?.[0] as AgentContentBlock + expect(agentBlock?.isCollapsed).toBe(true) + expect((agentBlock?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + expect((agentBlock?.blocks?.[1] as TextContentBlock)?.thinkingCollapseState).toBe('hidden') + }) + + test('setAllBlocksCollapsedState: expands both parent and nested blocks', () => { + const nestedBlocks = [ + createToolBlock('tool-1', true), + createThinkingBlock('think-1', 'hidden'), + ] + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedBlocks)]), + ] + const result = setAllBlocksCollapsedState(messages, false) + + const agentBlock = result[0]?.blocks?.[0] as AgentContentBlock + expect(agentBlock?.isCollapsed).toBe(false) + expect(agentBlock?.userOpened).toBe(true) + expect((agentBlock?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(false) + expect((agentBlock?.blocks?.[0] as CollapsibleBlock)?.userOpened).toBe(true) + expect((agentBlock?.blocks?.[1] as TextContentBlock)?.thinkingCollapseState).toBe('expanded') + expect((agentBlock?.blocks?.[1] as TextContentBlock)?.userOpened).toBe(true) + }) + }) + + describe('deeply nested structures (3+ levels)', () => { + test('hasAnyExpandedBlocks: finds expanded block at level 3', () => { + const level3Blocks = [createToolBlock('deep-tool', false)] // expanded at level 3 + const level2Blocks = [createAgentBlock('level2-agent', true, false, level3Blocks)] // collapsed at level 2 + const level1Blocks = [createAgentBlock('level1-agent', true, false, level2Blocks)] // collapsed at level 1 + const messages = [createMessage('1', 'ai', level1Blocks)] + + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('hasAnyExpandedBlocks: all collapsed at 3 levels returns false', () => { + const level3Blocks = [createToolBlock('deep-tool', true)] // collapsed at level 3 + const level2Blocks = [createAgentBlock('level2-agent', true, false, level3Blocks)] // collapsed at level 2 + const level1Blocks = [createAgentBlock('level1-agent', true, false, level2Blocks)] // collapsed at level 1 + const messages = [createMessage('1', 'ai', level1Blocks)] + + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + + test('setAllBlocksCollapsedState: collapses all 3 levels', () => { + const level3Blocks = [createToolBlock('deep-tool', false)] // expanded + const level2Blocks = [createAgentBlock('level2-agent', false, false, level3Blocks)] // expanded + const level1Blocks = [createAgentBlock('level1-agent', false, false, level2Blocks)] // expanded + const messages = [createMessage('1', 'ai', level1Blocks)] + + const result = setAllBlocksCollapsedState(messages, true) + + const level1 = result[0]?.blocks?.[0] as AgentContentBlock + expect(level1?.isCollapsed).toBe(true) + + const level2 = level1?.blocks?.[0] as AgentContentBlock + expect(level2?.isCollapsed).toBe(true) + + const level3 = level2?.blocks?.[0] as CollapsibleBlock + expect(level3?.isCollapsed).toBe(true) + }) + + test('setAllBlocksCollapsedState: expands all 3 levels with undefined states', () => { + // All undefined (treated as collapsed) + const level3Blocks = [createToolBlock('deep-tool')] + const level2Blocks = [createAgentBlock('level2-agent', undefined, undefined, level3Blocks)] + const level1Blocks = [createAgentBlock('level1-agent', undefined, undefined, level2Blocks)] + const messages = [createMessage('1', 'ai', level1Blocks)] + + const result = setAllBlocksCollapsedState(messages, false) + + const level1 = result[0]?.blocks?.[0] as AgentContentBlock + expect(level1?.isCollapsed).toBe(false) + expect(level1?.userOpened).toBe(true) + + const level2 = level1?.blocks?.[0] as AgentContentBlock + expect(level2?.isCollapsed).toBe(false) + expect(level2?.userOpened).toBe(true) + + const level3 = level2?.blocks?.[0] as CollapsibleBlock + expect(level3?.isCollapsed).toBe(false) + expect(level3?.userOpened).toBe(true) + }) + }) + + describe('mixed collapsible and non-collapsible blocks', () => { + test('hasAnyExpandedBlocks: ignores non-collapsible text blocks when checking', () => { + const nestedBlocks = [ + createTextBlock('regular text'), // not collapsible + createToolBlock('tool-1', true), // collapsed + ] + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedBlocks)]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + + test('hasAnyExpandedBlocks: finds expanded block among non-collapsible blocks', () => { + const nestedBlocks = [ + createTextBlock('regular text 1'), // not collapsible + createToolBlock('tool-1', false), // expanded + createTextBlock('regular text 2'), // not collapsible + ] + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', true, false, nestedBlocks)]), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('setAllBlocksCollapsedState: preserves non-collapsible blocks in nested structure', () => { + const nestedBlocks = [ + createTextBlock('regular text'), + createToolBlock('tool-1', false), + ] + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', false, false, nestedBlocks)]), + ] + const result = setAllBlocksCollapsedState(messages, true) + + const agentBlock = result[0]?.blocks?.[0] as AgentContentBlock + expect(agentBlock?.blocks?.[0]?.type).toBe('text') + expect((agentBlock?.blocks?.[0] as TextContentBlock)?.content).toBe('regular text') + expect((agentBlock?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBeUndefined() + expect((agentBlock?.blocks?.[1] as CollapsibleBlock)?.isCollapsed).toBe(true) + }) + }) + + describe('agent variant messages with blocks', () => { + test('hasAnyExpandedBlocks: checks both message-level and block-level collapsed state', () => { + const messages = [ + createMessage('1', 'agent', [createToolBlock('tool-1', false)], { isCollapsed: true }), + ] + // Even though message-level is collapsed, block-level is expanded + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('hasAnyExpandedBlocks: message-level expanded is detected', () => { + const messages = [ + createMessage('1', 'agent', [createToolBlock('tool-1', true)], { isCollapsed: false }), + ] + // Message-level is expanded even though block-level is collapsed + expect(hasAnyExpandedBlocks(messages)).toBe(true) + }) + + test('hasAnyExpandedBlocks: both collapsed returns false', () => { + const messages = [ + createMessage('1', 'agent', [createToolBlock('tool-1', true)], { isCollapsed: true }), + ] + expect(hasAnyExpandedBlocks(messages)).toBe(false) + }) + + test('setAllBlocksCollapsedState: collapses both message-level and block-level', () => { + const messages = [ + createMessage('1', 'agent', [createToolBlock('tool-1', false)], { isCollapsed: false }), + ] + const result = setAllBlocksCollapsedState(messages, true) + + expect(result[0]?.metadata?.isCollapsed).toBe(true) + expect((result[0]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + }) + + test('setAllBlocksCollapsedState: expands both message-level and block-level', () => { + const messages = [ + createMessage('1', 'agent', [createToolBlock('tool-1', true)], { isCollapsed: true }), + ] + const result = setAllBlocksCollapsedState(messages, false) + + expect(result[0]?.metadata?.isCollapsed).toBe(false) + expect(result[0]?.metadata?.userOpened).toBe(true) + expect((result[0]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(false) + expect((result[0]?.blocks?.[0] as CollapsibleBlock)?.userOpened).toBe(true) + }) + }) + + describe('toggle-all workflow (hasAnyExpandedBlocks + setAllBlocksCollapsedState)', () => { + test('toggle: when any expanded, collapse all', () => { + const messages = [ + createMessage('1', 'ai', [ + createToolBlock('tool-1', true), // collapsed + createToolBlock('tool-2', false), // expanded + ]), + ] + + // First: check if any are expanded + const hasExpanded = hasAnyExpandedBlocks(messages) + expect(hasExpanded).toBe(true) + + // Then: collapse all (since some are expanded) + const result = setAllBlocksCollapsedState(messages, true) + + // Verify all are now collapsed + expect(hasAnyExpandedBlocks(result)).toBe(false) + expect((result[0]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + expect((result[0]?.blocks?.[1] as CollapsibleBlock)?.isCollapsed).toBe(true) + }) + + test('toggle: when all collapsed, expand all', () => { + const messages = [ + createMessage('1', 'ai', [ + createToolBlock('tool-1', true), // collapsed + createToolBlock('tool-2', true), // collapsed + ]), + ] + + // First: check if any are expanded + const hasExpanded = hasAnyExpandedBlocks(messages) + expect(hasExpanded).toBe(false) + + // Then: expand all (since none are expanded) + const result = setAllBlocksCollapsedState(messages, false) + + // Verify all are now expanded + expect(hasAnyExpandedBlocks(result)).toBe(true) + expect((result[0]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(false) + expect((result[0]?.blocks?.[1] as CollapsibleBlock)?.isCollapsed).toBe(false) + }) + + test('toggle: fresh session with undefined states expands all', () => { + // Simulates first Ctrl+T on fresh session + const messages = [ + createMessage('1', 'ai', [ + createToolBlock('tool-1'), // undefined = collapsed + createAgentBlock('agent-1'), // undefined = collapsed + ]), + ] + + // Check if any expanded (should be false since undefined = collapsed) + const hasExpanded = hasAnyExpandedBlocks(messages) + expect(hasExpanded).toBe(false) + + // Expand all since none are expanded + const result = setAllBlocksCollapsedState(messages, false) + + // Verify all are now expanded + expect(hasAnyExpandedBlocks(result)).toBe(true) + }) + + test('toggle: double-toggle returns to expanded state', () => { + const messages = [ + createMessage('1', 'ai', [createToolBlock('tool-1', false)]), // expanded + ] + + // First toggle: collapse (since one is expanded) + const afterFirstToggle = setAllBlocksCollapsedState(messages, true) + expect(hasAnyExpandedBlocks(afterFirstToggle)).toBe(false) + + // Second toggle: expand (since all are collapsed) + const afterSecondToggle = setAllBlocksCollapsedState(afterFirstToggle, false) + expect(hasAnyExpandedBlocks(afterSecondToggle)).toBe(true) + }) + + test('toggle: complex nested structure toggle workflow', () => { + const level2Blocks = [ + createToolBlock('nested-tool-1', false), // expanded + createToolBlock('nested-tool-2', true), // collapsed + ] + const messages = [ + createMessage('1', 'ai', [ + createAgentBlock('agent-1', true, false, level2Blocks), // collapsed parent, mixed children + createToolBlock('tool-1', true), // collapsed + ]), + createMessage('2', 'agent', undefined, { isCollapsed: true }), // collapsed agent variant + ] + + // Any expanded? Yes (nested-tool-1 is expanded) + expect(hasAnyExpandedBlocks(messages)).toBe(true) + + // First toggle: collapse all + const afterCollapse = setAllBlocksCollapsedState(messages, true) + expect(hasAnyExpandedBlocks(afterCollapse)).toBe(false) + + // Verify all are collapsed including nested + const agentBlock = afterCollapse[0]?.blocks?.[0] as AgentContentBlock + expect((agentBlock?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + expect((agentBlock?.blocks?.[1] as CollapsibleBlock)?.isCollapsed).toBe(true) + + // Second toggle: expand all + const afterExpand = setAllBlocksCollapsedState(afterCollapse, false) + expect(hasAnyExpandedBlocks(afterExpand)).toBe(true) + + // Verify all are expanded including nested + const expandedAgentBlock = afterExpand[0]?.blocks?.[0] as AgentContentBlock + expect(expandedAgentBlock?.isCollapsed).toBe(false) + expect((expandedAgentBlock?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(false) + expect((expandedAgentBlock?.blocks?.[1] as CollapsibleBlock)?.isCollapsed).toBe(false) + expect((afterExpand[0]?.blocks?.[1] as CollapsibleBlock)?.isCollapsed).toBe(false) + expect(afterExpand[1]?.metadata?.isCollapsed).toBe(false) + }) + }) + + describe('empty and edge case nested structures', () => { + test('agent block with empty nested blocks array', () => { + const messages = [ + createMessage('1', 'ai', [createAgentBlock('agent-1', false, false, [])]), + ] + + expect(hasAnyExpandedBlocks(messages)).toBe(true) // parent is expanded + + const result = setAllBlocksCollapsedState(messages, true) + expect((result[0]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + }) + + test('multiple agent blocks at same level with mixed states', () => { + const messages = [ + createMessage('1', 'ai', [ + createAgentBlock('agent-1', true, false, [createToolBlock('tool-1', true)]), + createAgentBlock('agent-2', false, false, [createToolBlock('tool-2', true)]), + createAgentBlock('agent-3', true, false, [createToolBlock('tool-3', false)]), + ]), + ] + + // agent-2 is expanded, tool-3 is expanded + expect(hasAnyExpandedBlocks(messages)).toBe(true) + + const result = setAllBlocksCollapsedState(messages, true) + + // All should be collapsed now + expect((result[0]?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + expect((result[0]?.blocks?.[1] as CollapsibleBlock)?.isCollapsed).toBe(true) + expect((result[0]?.blocks?.[2] as CollapsibleBlock)?.isCollapsed).toBe(true) + + const agent1 = result[0]?.blocks?.[0] as AgentContentBlock + const agent2 = result[0]?.blocks?.[1] as AgentContentBlock + const agent3 = result[0]?.blocks?.[2] as AgentContentBlock + expect((agent1?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + expect((agent2?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + expect((agent3?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + }) + + test('nested agent blocks with all types of collapsible blocks', () => { + const deepBlocks = [ + createToolBlock('deep-tool', false), + createThinkingBlock('deep-think', 'expanded'), + createAgentListBlock('deep-list', false), + ] + const messages = [ + createMessage('1', 'ai', [createAgentBlock('outer-agent', false, false, deepBlocks)]), + ] + + expect(hasAnyExpandedBlocks(messages)).toBe(true) + + const result = setAllBlocksCollapsedState(messages, true) + + const outerAgent = result[0]?.blocks?.[0] as AgentContentBlock + expect(outerAgent?.isCollapsed).toBe(true) + expect((outerAgent?.blocks?.[0] as CollapsibleBlock)?.isCollapsed).toBe(true) + expect((outerAgent?.blocks?.[1] as TextContentBlock)?.thinkingCollapseState).toBe('hidden') + expect((outerAgent?.blocks?.[2] as CollapsibleBlock)?.isCollapsed).toBe(true) + }) + }) +}) diff --git a/cli/src/utils/__tests__/error-handling.test.ts b/cli/src/utils/__tests__/error-handling.test.ts new file mode 100644 index 0000000000..73517de083 --- /dev/null +++ b/cli/src/utils/__tests__/error-handling.test.ts @@ -0,0 +1,571 @@ +import { describe, test, expect } from 'bun:test' + +import { + getFreebuffRateLimitErrorMessage, + getFreeModeUnavailableErrorMessage, + isOutOfCreditsError, + isFreeModeUnavailableError, + getCountryBlockFromFreeModeError, + OUT_OF_CREDITS_MESSAGE, + FREE_MODE_UNAVAILABLE_MESSAGE, + FREEBUFF_RATE_LIMIT_MESSAGE, + createErrorMessage, +} from '../error-handling' + +describe('error-handling', () => { + describe('isOutOfCreditsError', () => { + test('returns true for error with statusCode 402', () => { + const error = { statusCode: 402, message: 'Payment required' } + expect(isOutOfCreditsError(error)).toBe(true) + }) + + test('returns false for error with statusCode 401', () => { + const error = { statusCode: 401, message: 'Unauthorized' } + expect(isOutOfCreditsError(error)).toBe(false) + }) + + test('returns false for error with statusCode 403', () => { + const error = { statusCode: 403, message: 'Forbidden' } + expect(isOutOfCreditsError(error)).toBe(false) + }) + + test('returns false for error with statusCode 500', () => { + const error = { statusCode: 500, message: 'Server error' } + expect(isOutOfCreditsError(error)).toBe(false) + }) + + test('returns false for null error', () => { + expect(isOutOfCreditsError(null)).toBe(false) + }) + + test('returns false for undefined error', () => { + expect(isOutOfCreditsError(undefined)).toBe(false) + }) + + test('returns false for string error', () => { + expect(isOutOfCreditsError('error string')).toBe(false) + }) + + test('returns false for Error object without statusCode', () => { + const error = new Error('Plain error') + expect(isOutOfCreditsError(error)).toBe(false) + }) + + test('returns false for error with non-402 numeric statusCode', () => { + const error = { statusCode: 400, message: 'Bad request' } + expect(isOutOfCreditsError(error)).toBe(false) + }) + + test('returns false for error with string statusCode', () => { + const error = { statusCode: '402', message: 'Payment required' } + expect(isOutOfCreditsError(error)).toBe(false) + }) + + test('returns true for 402 errors with additional properties', () => { + const error = { + statusCode: 402, + message: 'Payment required', + details: { credits: 0 }, + timestamp: new Date().toISOString(), + } + expect(isOutOfCreditsError(error)).toBe(true) + }) + }) + + describe('isFreeModeUnavailableError', () => { + test('returns true for error with statusCode 403 and error free_mode_unavailable', () => { + const error = { + statusCode: 403, + error: 'free_mode_unavailable', + message: 'Free mode is not available in your country.', + } + expect(isFreeModeUnavailableError(error)).toBe(true) + }) + + test('returns true for responseBody free_mode_unavailable errors', () => { + expect( + isFreeModeUnavailableError({ + statusCode: 403, + responseBody: JSON.stringify({ + error: 'free_mode_unavailable', + message: 'Freebuff cannot be used from VPN traffic.', + }), + }), + ).toBe(true) + }) + + test('returns false for 403 without error field', () => { + const error = { statusCode: 403, message: 'Forbidden' } + expect(isFreeModeUnavailableError(error)).toBe(false) + }) + + test('returns false for 403 with different error code', () => { + const error = { + statusCode: 403, + error: 'account_suspended', + message: 'Suspended', + } + expect(isFreeModeUnavailableError(error)).toBe(false) + }) + + test('returns false for non-403 status with free_mode_unavailable error', () => { + const error = { + statusCode: 400, + error: 'free_mode_unavailable', + message: 'Bad request', + } + expect(isFreeModeUnavailableError(error)).toBe(false) + }) + + test('returns false for null', () => { + expect(isFreeModeUnavailableError(null)).toBe(false) + }) + + test('returns false for undefined', () => { + expect(isFreeModeUnavailableError(undefined)).toBe(false) + }) + + test('returns false for plain Error object', () => { + expect(isFreeModeUnavailableError(new Error('Forbidden'))).toBe(false) + }) + }) + + describe('getFreebuffRateLimitErrorMessage', () => { + test('returns the generic message for untyped 429 errors', () => { + expect( + getFreebuffRateLimitErrorMessage({ + statusCode: 429, + message: 'Too Many Requests', + }), + ).toBe(FREEBUFF_RATE_LIMIT_MESSAGE) + }) + + test('returns the generic message for thrown API errors with status 429', () => { + expect( + getFreebuffRateLimitErrorMessage({ + status: 429, + message: 'Too Many Requests', + }), + ).toBe(FREEBUFF_RATE_LIMIT_MESSAGE) + }) + + test('returns the generic message for retry-wrapped untyped 429 errors', () => { + expect( + getFreebuffRateLimitErrorMessage({ + message: 'Failed after 4 attempts. Last error: Too Many Requests', + lastError: { + statusCode: 429, + message: 'Too Many Requests', + }, + }), + ).toBe(FREEBUFF_RATE_LIMIT_MESSAGE) + }) + + test('returns null for non-429 status codes', () => { + expect(getFreebuffRateLimitErrorMessage({ statusCode: 402 })).toBe(null) + expect(getFreebuffRateLimitErrorMessage({ statusCode: 500 })).toBe(null) + }) + + test('returns null for string statusCode', () => { + expect(getFreebuffRateLimitErrorMessage({ statusCode: '429' })).toBe( + null, + ) + }) + + test('preserves normalized free mode quota messages', () => { + const message = + 'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.' + + expect( + getFreebuffRateLimitErrorMessage({ + statusCode: 429, + error: 'free_mode_rate_limited', + message, + }), + ).toBe(message) + }) + + test('preserves responseBody free mode quota messages', () => { + const message = + 'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.' + + expect( + getFreebuffRateLimitErrorMessage({ + statusCode: 429, + message: 'Too Many Requests', + responseBody: JSON.stringify({ + error: 'free_mode_rate_limited', + message, + }), + }), + ).toBe(message) + }) + + test('preserves retry-wrapped free mode quota messages', () => { + const message = + 'Free mode rate limit exceeded (1 minute limit). Try again in 30 seconds.' + + expect( + getFreebuffRateLimitErrorMessage({ + message: 'Failed after 4 attempts. Last error: Too Many Requests', + lastError: { + statusCode: 429, + message: 'Too Many Requests', + responseBody: JSON.stringify({ + error: 'free_mode_rate_limited', + message, + }), + }, + }), + ).toBe(message) + }) + + test('falls back to the generic message when typed quota errors have no message', () => { + expect( + getFreebuffRateLimitErrorMessage({ + statusCode: 429, + error: 'free_mode_rate_limited', + }), + ).toBe(FREEBUFF_RATE_LIMIT_MESSAGE) + }) + }) + + describe('getCountryBlockFromFreeModeError', () => { + test('extracts country block details from free-mode unavailable errors', () => { + const error = { + statusCode: 403, + error: 'free_mode_unavailable', + countryCode: 'US', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['vpn', 'hosting', 123], + } + + expect(getCountryBlockFromFreeModeError(error)).toEqual({ + countryCode: 'US', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['vpn', 'hosting'], + }) + }) + + test('extracts country block details from responseBody errors', () => { + const error = { + statusCode: 403, + responseBody: JSON.stringify({ + error: 'free_mode_unavailable', + countryCode: 'US', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['proxy', 'hosting', 123], + }), + } + + expect(getCountryBlockFromFreeModeError(error)).toEqual({ + countryCode: 'US', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['proxy', 'hosting'], + }) + }) + + test('defaults missing country code to UNKNOWN', () => { + const error = { + statusCode: 403, + error: 'free_mode_unavailable', + } + + expect(getCountryBlockFromFreeModeError(error)).toEqual({ + countryCode: 'UNKNOWN', + countryBlockReason: undefined, + ipPrivacySignals: undefined, + }) + }) + + test('returns null for non-free-mode errors', () => { + expect( + getCountryBlockFromFreeModeError({ + statusCode: 403, + error: 'account_suspended', + }), + ).toBe(null) + }) + }) + + describe('FREE_MODE_UNAVAILABLE_MESSAGE', () => { + test('mentions unavailability in country', () => { + expect(FREE_MODE_UNAVAILABLE_MESSAGE.toLowerCase()).toContain( + 'not available in your country', + ) + }) + }) + + describe('getFreeModeUnavailableErrorMessage', () => { + test('uses a VPN/proxy-specific message for anonymous-network blocks', () => { + expect( + getFreeModeUnavailableErrorMessage({ + statusCode: 403, + error: 'free_mode_unavailable', + message: 'Forbidden', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['vpn', 'hosting'], + }), + ).toContain('VPN') + }) + + test('uses a VPN/proxy-specific message from responseBody details', () => { + expect( + getFreeModeUnavailableErrorMessage({ + statusCode: 403, + message: 'Forbidden', + responseBody: JSON.stringify({ + error: 'free_mode_unavailable', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['tor'], + }), + }), + ).toContain('Tor') + }) + + test('preserves server message for non-privacy free mode blocks', () => { + expect( + getFreeModeUnavailableErrorMessage({ + statusCode: 403, + error: 'free_mode_unavailable', + message: 'Free mode is not available in your country.', + }), + ).toBe('Free mode is not available in your country.') + }) + }) + + describe('OUT_OF_CREDITS_MESSAGE', () => { + test('contains usage URL', () => { + expect(OUT_OF_CREDITS_MESSAGE).toContain('/usage') + }) + + test('contains out of credits message', () => { + expect(OUT_OF_CREDITS_MESSAGE.toLowerCase()).toContain('out of credits') + }) + + test('contains add credits instruction', () => { + expect(OUT_OF_CREDITS_MESSAGE.toLowerCase()).toContain('add credits') + }) + }) + + describe('FREEBUFF_RATE_LIMIT_MESSAGE', () => { + test('encourages retry without mentioning credits or payment', () => { + const message = FREEBUFF_RATE_LIMIT_MESSAGE.toLowerCase() + expect(message).toContain('try again') + expect(message).not.toContain('credit') + expect(message).not.toContain('pay') + }) + }) + + describe('createErrorMessage', () => { + test('creates message from Error object', () => { + const error = new Error('Something went wrong') + const result = createErrorMessage(error, 'msg-123') + + expect(result.id).toBe('msg-123') + expect(result.content).toContain('Something went wrong') + expect(result.content).toContain('**Error:**') + expect(result.isComplete).toBe(true) + expect(result.blocks).toBeUndefined() + }) + + test('creates message from string error', () => { + const result = createErrorMessage('String error', 'msg-456') + + expect(result.id).toBe('msg-456') + expect(result.content).toContain('String error') + }) + + test('creates message from object with message property', () => { + const error = { message: 'Object error message', code: 'ERR_001' } + const result = createErrorMessage(error, 'msg-789') + + expect(result.content).toContain('Object error message') + }) + + test('uses fallback for unknown error types', () => { + const result = createErrorMessage(null, 'msg-null') + + expect(result.content).toContain('Unknown error occurred') + }) + + test('includes stack trace when available', () => { + const error = new Error('Error with stack') + const result = createErrorMessage(error, 'msg-stack') + + expect(result.content).toContain('Error with stack') + // Stack trace should be included + expect(result.content).toContain('at') + }) + + test('handles error without message property', () => { + const error = { code: 'ERR_UNKNOWN' } + const result = createErrorMessage(error, 'msg-no-msg') + + expect(result.content).toContain('Unknown error occurred') + }) + + test('handles error with empty message', () => { + const error = { message: '' } + const result = createErrorMessage(error, 'msg-empty') + + expect(result.content).toContain('Unknown error occurred') + }) + + test('handles error with numeric message', () => { + const error = { message: 123 } + const result = createErrorMessage(error, 'msg-num') + + expect(result.content).toContain('Unknown error occurred') + }) + + test('handles out of credits error', () => { + const error = { statusCode: 402, message: 'Payment required' } + const result = createErrorMessage(error, 'msg-402') + + expect(result.content).toContain('Payment required') + }) + + test('preserves message ID', () => { + const error = new Error('Test') + const result = createErrorMessage(error, 'unique-id-123') + + expect(result.id).toBe('unique-id-123') + }) + + test('marks message as complete', () => { + const error = new Error('Test') + const result = createErrorMessage(error, 'msg-complete') + + expect(result.isComplete).toBe(true) + }) + + test('clears blocks from error message', () => { + const error = new Error('Test') + const result = createErrorMessage(error, 'msg-blocks') + + expect(result.blocks).toBeUndefined() + }) + + test('handles deeply nested error objects', () => { + const error = { + message: 'Outer error', + cause: { + message: 'Inner error', + cause: { + message: 'Root cause', + }, + }, + } + const result = createErrorMessage(error, 'msg-nested') + + // Should only extract the top-level message + expect(result.content).toContain('Outer error') + }) + + test('handles API error responses', () => { + const apiError = { + message: 'API request failed', + statusCode: 500, + response: { error: 'Internal server error' }, + } + const result = createErrorMessage(apiError, 'msg-api') + + expect(result.content).toContain('API request failed') + }) + + test('handles network timeout errors', () => { + const timeoutError = new Error('Request timeout') + ;(timeoutError as any).code = 'ETIMEDOUT' + const result = createErrorMessage(timeoutError, 'msg-timeout') + + expect(result.content).toContain('Request timeout') + }) + + test('handles auth errors', () => { + const authError = { + statusCode: 401, + message: 'Invalid authentication token', + } + const result = createErrorMessage(authError, 'msg-auth') + + expect(result.content).toContain('Invalid authentication token') + }) + }) + + describe('error scenarios', () => { + test('handles rate limit error (429)', () => { + const rateLimitError = { + statusCode: 429, + message: 'Too many requests', + retryAfter: 60, + } + + expect(isOutOfCreditsError(rateLimitError)).toBe(false) + + const result = createErrorMessage(rateLimitError, 'msg-rate') + expect(result.content).toContain('Too many requests') + }) + + test('handles server error (500)', () => { + const serverError = { + statusCode: 500, + message: 'Internal server error', + } + + expect(isOutOfCreditsError(serverError)).toBe(false) + + const result = createErrorMessage(serverError, 'msg-500') + expect(result.content).toContain('Internal server error') + }) + + test('handles validation error (400)', () => { + const validationError = { + statusCode: 400, + message: 'Invalid request parameters', + errors: [{ field: 'prompt', message: 'Required' }], + } + + expect(isOutOfCreditsError(validationError)).toBe(false) + + const result = createErrorMessage(validationError, 'msg-400') + expect(result.content).toContain('Invalid request parameters') + }) + + test('handles forbidden error (403)', () => { + const forbiddenError = { + statusCode: 403, + message: 'Access denied', + } + + expect(isOutOfCreditsError(forbiddenError)).toBe(false) + + const result = createErrorMessage(forbiddenError, 'msg-403') + expect(result.content).toContain('Access denied') + }) + + test('handles not found error (404)', () => { + const notFoundError = { + statusCode: 404, + message: 'Resource not found', + } + + expect(isOutOfCreditsError(notFoundError)).toBe(false) + + const result = createErrorMessage(notFoundError, 'msg-404') + expect(result.content).toContain('Resource not found') + }) + + test('handles conflict error (409)', () => { + const conflictError = { + statusCode: 409, + message: 'Conflict detected', + } + + expect(isOutOfCreditsError(conflictError)).toBe(false) + + const result = createErrorMessage(conflictError, 'msg-409') + expect(result.content).toContain('Conflict detected') + }) + }) +}) diff --git a/cli/src/utils/__tests__/feedback-helpers.test.ts b/cli/src/utils/__tests__/feedback-helpers.test.ts new file mode 100644 index 0000000000..55baed122d --- /dev/null +++ b/cli/src/utils/__tests__/feedback-helpers.test.ts @@ -0,0 +1,444 @@ +import { describe, expect, test } from 'bun:test' + +import { feedbackRequestSchema } from '@codebuff/common/schemas/feedback' + +import { buildFeedbackPayload, buildMessageContext, type RecentMessageSummary } from '../feedback-helpers' + +import type { ChatMessage } from '../../types/chat' + +const createMessage = ( + overrides: Partial & { id: string }, +): ChatMessage => ({ + variant: 'ai', + content: 'test content', + timestamp: new Date().toISOString(), + ...overrides, +}) + +describe('buildMessageContext', () => { + test('returns target and recent messages for a valid target', () => { + const messages = [ + createMessage({ id: 'msg-1', variant: 'user' }), + createMessage({ id: 'msg-2', variant: 'ai' }), + createMessage({ id: 'msg-3', variant: 'user' }), + ] + + const result = buildMessageContext(messages, 'msg-2') + + expect(result.target).toBe(messages[1]) + expect(result.recentMessages).toHaveLength(2) + expect(result.recentMessages[0]).toEqual({ type: 'user', id: 'msg-1' }) + expect(result.recentMessages[1]).toEqual({ type: 'ai', id: 'msg-2' }) + }) + + test('returns null target and all messages when targetMessageId is null', () => { + const messages = [ + createMessage({ id: 'msg-1' }), + createMessage({ id: 'msg-2' }), + ] + + const result = buildMessageContext(messages, null) + + expect(result.target).toBeNull() + expect(result.recentMessages).toHaveLength(2) + }) + + test('returns null target and empty recentMessages when message ID is not found', () => { + const messages = [ + createMessage({ id: 'msg-1' }), + createMessage({ id: 'msg-2' }), + ] + + const result = buildMessageContext(messages, 'nonexistent') + + expect(result.target).toBeNull() + expect(result.recentMessages).toHaveLength(0) + }) + + test('limits to last 10 messages when targetMessageId is null', () => { + const messages = Array.from({ length: 15 }, (_, i) => + createMessage({ id: `msg-${i}` }), + ) + + const result = buildMessageContext(messages, null) + + expect(result.recentMessages).toHaveLength(10) + expect(result.recentMessages[0]).toMatchObject({ id: 'msg-5' }) + expect(result.recentMessages[9]).toMatchObject({ id: 'msg-14' }) + }) + + test('includes credits: 0 in recent messages (not dropped)', () => { + const messages = [ + createMessage({ id: 'msg-1', credits: 0 }), + createMessage({ id: 'msg-2', credits: 5.5 }), + createMessage({ id: 'msg-3' }), + ] + + const result = buildMessageContext(messages, null) + + expect(result.recentMessages[0]).toEqual({ + type: 'ai', + id: 'msg-1', + credits: 0, + }) + expect(result.recentMessages[1]).toEqual({ + type: 'ai', + id: 'msg-2', + credits: 5.5, + }) + expect(result.recentMessages[2]).toEqual({ type: 'ai', id: 'msg-3' }) + }) + + test('omits credits when undefined', () => { + const messages = [createMessage({ id: 'msg-1' })] + + const result = buildMessageContext(messages, null) + + expect(result.recentMessages[0]).toEqual({ type: 'ai', id: 'msg-1' }) + expect('credits' in result.recentMessages[0]).toBe(false) + }) + + test('includes completionTime when present', () => { + const messages = [ + createMessage({ id: 'msg-1', completionTime: '3.2s' }), + ] + + const result = buildMessageContext(messages, null) + + expect(result.recentMessages[0]).toEqual({ + type: 'ai', + id: 'msg-1', + completionTime: '3.2s', + }) + }) + + test('includes empty string completionTime (not dropped by != null)', () => { + const messages = [ + createMessage({ id: 'msg-1', completionTime: '' }), + ] + + const result = buildMessageContext(messages, null) + + expect(result.recentMessages[0]).toEqual({ + type: 'ai', + id: 'msg-1', + completionTime: '', + }) + }) + + test('limits to last 10 messages up to target', () => { + const messages = Array.from({ length: 15 }, (_, i) => + createMessage({ id: `msg-${i}` }), + ) + + const result = buildMessageContext(messages, 'msg-14') + + expect(result.recentMessages).toHaveLength(10) + expect(result.recentMessages[0]).toMatchObject({ id: 'msg-5' }) + expect(result.recentMessages[9]).toMatchObject({ id: 'msg-14' }) + }) + + test('returns all messages when fewer than 10 exist', () => { + const messages = [ + createMessage({ id: 'msg-1' }), + createMessage({ id: 'msg-2' }), + createMessage({ id: 'msg-3' }), + ] + + const result = buildMessageContext(messages, 'msg-3') + + expect(result.recentMessages).toHaveLength(3) + }) + + test('returns only target message when target is at index 0', () => { + const messages = [ + createMessage({ id: 'msg-0' }), + createMessage({ id: 'msg-1' }), + createMessage({ id: 'msg-2' }), + ] + + const result = buildMessageContext(messages, 'msg-0') + + expect(result.target).toBe(messages[0]) + expect(result.recentMessages).toHaveLength(1) + expect(result.recentMessages[0]).toMatchObject({ id: 'msg-0' }) + }) + + test('handles empty messages array', () => { + const result = buildMessageContext([], null) + + expect(result.target).toBeNull() + expect(result.recentMessages).toHaveLength(0) + }) +}) + +describe('buildFeedbackPayload', () => { + const baseParams = { + text: 'Great feature!', + feedbackCategory: 'good_result' as const, + feedbackMessageId: null as string | null, + target: null as ReturnType | null, + recentMessages: [] as RecentMessageSummary[], + agentMode: null as string | null, + sessionCreditsUsed: null as number | null, + errors: null as Array<{ id: string; message: string }> | null, + clientFeedbackId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + } + + test('builds minimal general feedback payload', () => { + const payload = buildFeedbackPayload(baseParams) + + expect(payload).toEqual({ + text: 'Great feature!', + category: 'good_result', + type: 'general', + clientFeedbackId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + source: 'cli', + }) + }) + + test('always includes source: cli', () => { + const payload = buildFeedbackPayload(baseParams) + expect(payload.source).toBe('cli') + }) + + test('passes through the provided clientFeedbackId', () => { + const payload = buildFeedbackPayload(baseParams) + expect(payload.clientFeedbackId).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890') + }) + + test('uses the exact clientFeedbackId provided', () => { + const specificId = 'b2c3d4e5-f6a7-8901-bcde-f12345678901' + const payload = buildFeedbackPayload({ + ...baseParams, + clientFeedbackId: specificId, + }) + expect(payload.clientFeedbackId).toBe(specificId) + }) + + test('sets type to message when feedbackMessageId is present', () => { + const payload = buildFeedbackPayload({ + ...baseParams, + feedbackMessageId: 'msg-123', + }) + + expect(payload.type).toBe('message') + expect(payload.messageId).toBe('msg-123') + }) + + test('sends messageId even when target message is not found', () => { + const payload = buildFeedbackPayload({ + ...baseParams, + feedbackMessageId: 'msg-deleted', + target: null, + }) + + expect(payload.type).toBe('message') + expect(payload.messageId).toBe('msg-deleted') + expect(payload.messageVariant).toBeUndefined() + expect(payload.credits).toBeUndefined() + expect(payload.completionTime).toBeUndefined() + }) + + test('includes target message details when target is found', () => { + const target = createMessage({ + id: 'msg-1', + variant: 'ai', + completionTime: '2.5s', + credits: 1.2, + }) + + const payload = buildFeedbackPayload({ + ...baseParams, + feedbackMessageId: 'msg-1', + target, + }) + + expect(payload.messageId).toBe('msg-1') + expect(payload.messageVariant).toBe('ai') + expect(payload.completionTime).toBe('2.5s') + expect(payload.credits).toBe(1.2) + }) + + test('includes target credits: 0 (not dropped)', () => { + const target = createMessage({ + id: 'msg-1', + credits: 0, + }) + + const payload = buildFeedbackPayload({ + ...baseParams, + feedbackMessageId: 'msg-1', + target, + }) + + expect(payload.credits).toBe(0) + }) + + test('includes optional fields when present', () => { + const recentMessages: RecentMessageSummary[] = [{ type: 'user', id: 'msg-1' }] + const errors = [{ id: 'err-1', message: 'Something went wrong' }] + + const payload = buildFeedbackPayload({ + ...baseParams, + agentMode: 'MAX', + sessionCreditsUsed: 3.5, + recentMessages, + errors, + }) + + expect(payload.agentMode).toBe('MAX') + expect(payload.sessionCreditsUsed).toBe(3.5) + expect(payload.recentMessages).toEqual(recentMessages) + expect(payload.errors).toEqual(errors) + }) + + test('includes sessionCreditsUsed: 0 (not dropped)', () => { + const payload = buildFeedbackPayload({ + ...baseParams, + sessionCreditsUsed: 0, + }) + + expect(payload.sessionCreditsUsed).toBe(0) + }) + + test('omits empty recentMessages', () => { + const payload = buildFeedbackPayload({ + ...baseParams, + recentMessages: [], + }) + + expect(payload.recentMessages).toBeUndefined() + }) + + test('omits null errors', () => { + const payload = buildFeedbackPayload({ + ...baseParams, + errors: null, + }) + + expect(payload.errors).toBeUndefined() + }) + + test('omits empty string agentMode', () => { + const payload = buildFeedbackPayload({ + ...baseParams, + agentMode: '', + }) + + expect(payload.agentMode).toBeUndefined() + }) + + test('omits empty string completionTime from target', () => { + const target = createMessage({ + id: 'msg-1', + completionTime: '', + }) + + const payload = buildFeedbackPayload({ + ...baseParams, + feedbackMessageId: 'msg-1', + target, + }) + + expect(payload.completionTime).toBeUndefined() + }) + + test('truncates errors to schema limits', () => { + const largeErrors = Array.from({ length: 60 }, (_, i) => ({ + id: 'e'.repeat(300), + message: 'a'.repeat(3000), + })) + + const payload = buildFeedbackPayload({ + ...baseParams, + errors: largeErrors, + }) + + expect(payload.errors).toHaveLength(50) + expect(payload.errors![0].message).toHaveLength(2000) + expect(payload.errors![0].id).toHaveLength(200) + }) + + test('treats empty feedbackMessageId as general type', () => { + const payload = buildFeedbackPayload({ + ...baseParams, + feedbackMessageId: '', + }) + + expect(payload.type).toBe('general') + expect(payload.messageId).toBeUndefined() + }) +}) + +describe('Cross-layer validation', () => { + test('buildFeedbackPayload output satisfies server-side zod schema', () => { + const messages = [ + createMessage({ id: 'msg-1', variant: 'user' }), + createMessage({ id: 'msg-2', variant: 'ai', completionTime: '2.5s', credits: 1.2 }), + ] + + const { target, recentMessages } = buildMessageContext(messages, 'msg-2') + const payload = buildFeedbackPayload({ + text: 'Great feature!', + feedbackCategory: 'good_result', + feedbackMessageId: 'msg-2', + target, + recentMessages, + agentMode: 'MAX', + sessionCreditsUsed: 3.5, + errors: [{ id: 'err-1', message: 'Something went wrong' }], + clientFeedbackId: 'c3d4e5f6-a7b8-4012-8def-123456789012', + }) + + const result = feedbackRequestSchema.safeParse(payload) + expect(result.success).toBe(true) + }) + + test('minimal buildFeedbackPayload output satisfies server-side zod schema', () => { + const payload = buildFeedbackPayload({ + text: 'Bug report', + feedbackCategory: 'app_bug', + feedbackMessageId: null, + target: null, + recentMessages: [], + agentMode: null, + sessionCreditsUsed: null, + errors: null, + clientFeedbackId: 'd4e5f6a7-b8c9-4123-9efa-234567890123', + }) + + const result = feedbackRequestSchema.safeParse(payload) + expect(result.success).toBe(true) + }) + + test('payload always includes source field', () => { + const payload = buildFeedbackPayload({ + text: 'test', + feedbackCategory: 'other', + feedbackMessageId: null, + target: null, + recentMessages: [], + agentMode: null, + sessionCreditsUsed: null, + errors: null, + clientFeedbackId: 'e5f6a7b8-c9d0-4234-afab-345678901234', + }) + + expect(payload.source).toBe('cli') + const result = feedbackRequestSchema.safeParse(payload) + expect(result.success).toBe(true) + }) + + test('schema rejects type=message without messageId', () => { + const payload = { + text: 'test', + category: 'other', + type: 'message', + source: 'cli', + } + + const result = feedbackRequestSchema.safeParse(payload) + expect(result.success).toBe(false) + }) +}) diff --git a/cli/src/utils/__tests__/feedback-submission.test.ts b/cli/src/utils/__tests__/feedback-submission.test.ts new file mode 100644 index 0000000000..50afeb9e2f --- /dev/null +++ b/cli/src/utils/__tests__/feedback-submission.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' + +import { resolveFeedbackSubmission } from '../feedback-submission' + +describe('resolveFeedbackSubmission', () => { + test('settles and marks as current when ids match', () => { + expect(resolveFeedbackSubmission('id-1', 'id-1')).toEqual({ + isCurrentSubmission: true, + shouldSettleSubmission: true, + }) + }) + + test('settles non-current submission when feedback was closed mid-request', () => { + expect(resolveFeedbackSubmission(null, 'id-1')).toEqual({ + isCurrentSubmission: false, + shouldSettleSubmission: true, + }) + }) + + test('ignores stale submission when a newer feedback session is active', () => { + expect(resolveFeedbackSubmission('new-id', 'old-id')).toEqual({ + isCurrentSubmission: false, + shouldSettleSubmission: false, + }) + }) +}) diff --git a/cli/src/utils/__tests__/fetch-usage.test.ts b/cli/src/utils/__tests__/fetch-usage.test.ts index c2d5e6fcf1..1b2e68f6e6 100644 --- a/cli/src/utils/__tests__/fetch-usage.test.ts +++ b/cli/src/utils/__tests__/fetch-usage.test.ts @@ -44,15 +44,15 @@ describe('fetchAndUpdateUsage (deprecated)', () => { loginStatus: mock(() => Promise.resolve({ ok: true, status: 200, data: {} }), ) as CodebuffApiClient['loginStatus'], - referral: mock(() => - Promise.resolve({ ok: true, status: 200, data: {} }), - ) as CodebuffApiClient['referral'], publish: mock(() => Promise.resolve({ ok: true, status: 200, data: {} }), ) as CodebuffApiClient['publish'], logout: mock(() => Promise.resolve({ ok: true, status: 200, data: {} }), ) as CodebuffApiClient['logout'], + feedback: mock(() => + Promise.resolve({ ok: true, status: 200, data: {} }), + ) as CodebuffApiClient['feedback'], baseUrl: 'https://test.codebuff.com', authToken: 'test-auth-token', }) diff --git a/cli/src/utils/__tests__/fingerprint.test.ts b/cli/src/utils/__tests__/fingerprint.test.ts new file mode 100644 index 0000000000..12d71ddfda --- /dev/null +++ b/cli/src/utils/__tests__/fingerprint.test.ts @@ -0,0 +1,144 @@ +import { describe, test, expect } from 'bun:test' + +import { getFingerprintType, generateFingerprintIdSync } from '../fingerprint' + +describe('fingerprint utilities', () => { + describe('getFingerprintType', () => { + describe('enhanced fingerprints', () => { + test('should detect enhanced- prefix as enhanced_cli', () => { + expect(getFingerprintType('enhanced-abc123')).toBe('enhanced_cli') + }) + + test('should detect enhanced fingerprint with full hash', () => { + const fullHash = 'enhanced-Ks7mN2pQxR3vW5yZ8aB4cD6eF9gH1iJ2kL4mN5oP7qR8sT0uV1wX3yZ' + expect(getFingerprintType(fullHash)).toBe('enhanced_cli') + }) + + test('should detect enhanced- prefix with empty suffix', () => { + expect(getFingerprintType('enhanced-')).toBe('enhanced_cli') + }) + }) + + describe('legacy fingerprints', () => { + test('should detect codebuff-cli- prefix as legacy', () => { + expect(getFingerprintType('codebuff-cli-abc12345')).toBe('legacy') + }) + + test('should detect legacy- prefix as legacy', () => { + expect(getFingerprintType('legacy-abc123-xyz789')).toBe('legacy') + }) + + test('should detect codebuff-cli- prefix with any suffix', () => { + expect(getFingerprintType('codebuff-cli-')).toBe('legacy') + expect(getFingerprintType('codebuff-cli-randomsuffix')).toBe('legacy') + expect(getFingerprintType('codebuff-cli-12345678')).toBe('legacy') + }) + + test('should detect legacy- prefix with any suffix', () => { + expect(getFingerprintType('legacy-')).toBe('legacy') + expect(getFingerprintType('legacy-hash-suffix')).toBe('legacy') + }) + }) + + describe('unknown fingerprints', () => { + test('should return unknown for empty string', () => { + expect(getFingerprintType('')).toBe('unknown') + }) + + test('should return unknown for unrecognized prefix', () => { + expect(getFingerprintType('unknown-prefix-123')).toBe('unknown') + }) + + test('should return unknown for partial matches', () => { + // Should not match if prefix is incomplete + expect(getFingerprintType('enhance-abc123')).toBe('unknown') + expect(getFingerprintType('codebuff-abc123')).toBe('unknown') + expect(getFingerprintType('lega-abc123')).toBe('unknown') + }) + + test('should return unknown for SDK fingerprints', () => { + expect(getFingerprintType('codebuff-sdk-abc123')).toBe('unknown') + }) + + test('should return unknown for random strings', () => { + expect(getFingerprintType('random-string')).toBe('unknown') + expect(getFingerprintType('abc123')).toBe('unknown') + expect(getFingerprintType('fingerprint')).toBe('unknown') + }) + + test('should be case-sensitive', () => { + expect(getFingerprintType('Enhanced-abc123')).toBe('unknown') + expect(getFingerprintType('ENHANCED-abc123')).toBe('unknown') + expect(getFingerprintType('Codebuff-cli-abc123')).toBe('unknown') + expect(getFingerprintType('LEGACY-abc123')).toBe('unknown') + }) + }) + }) + + describe('generateFingerprintIdSync', () => { + describe('format validation', () => { + test('should return string starting with codebuff-cli-', () => { + const fingerprint = generateFingerprintIdSync() + expect(fingerprint.startsWith('codebuff-cli-')).toBe(true) + }) + + test('should return fingerprint of expected length', () => { + const fingerprint = generateFingerprintIdSync() + // Format: codebuff-cli- (13 chars) + 8 random chars = 21 chars + expect(fingerprint.length).toBe(21) + }) + + test('should contain only valid base64url characters in suffix', () => { + const fingerprint = generateFingerprintIdSync() + const suffix = fingerprint.replace('codebuff-cli-', '') + // base64url alphabet: A-Z, a-z, 0-9, -, _ + const base64urlPattern = /^[A-Za-z0-9_-]+$/ + expect(base64urlPattern.test(suffix)).toBe(true) + }) + + test('should have exactly 8 characters in the random suffix', () => { + const fingerprint = generateFingerprintIdSync() + const suffix = fingerprint.replace('codebuff-cli-', '') + expect(suffix.length).toBe(8) + }) + }) + + describe('uniqueness', () => { + test('should generate unique fingerprints across multiple calls', () => { + const fingerprints = new Set() + const iterations = 100 + + for (let i = 0; i < iterations; i++) { + fingerprints.add(generateFingerprintIdSync()) + } + + // All fingerprints should be unique + expect(fingerprints.size).toBe(iterations) + }) + + test('should generate different fingerprints on consecutive calls', () => { + const first = generateFingerprintIdSync() + const second = generateFingerprintIdSync() + const third = generateFingerprintIdSync() + + expect(first).not.toBe(second) + expect(second).not.toBe(third) + expect(first).not.toBe(third) + }) + }) + + describe('type detection integration', () => { + test('should be detected as legacy by getFingerprintType', () => { + const fingerprint = generateFingerprintIdSync() + expect(getFingerprintType(fingerprint)).toBe('legacy') + }) + + test('multiple generated fingerprints should all be detected as legacy', () => { + for (let i = 0; i < 10; i++) { + const fingerprint = generateFingerprintIdSync() + expect(getFingerprintType(fingerprint)).toBe('legacy') + } + }) + }) + }) +}) diff --git a/cli/src/utils/__tests__/format-timeout.test.ts b/cli/src/utils/__tests__/format-timeout.test.ts new file mode 100644 index 0000000000..78127e03fd --- /dev/null +++ b/cli/src/utils/__tests__/format-timeout.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test' + +import { formatTimeout } from '../format-timeout' + +describe('formatTimeout', () => { + describe('normal values', () => { + test('returns seconds for values less than 60', () => { + expect(formatTimeout(10)).toBe('10s timeout') + expect(formatTimeout(30)).toBe('30s timeout') + expect(formatTimeout(45)).toBe('45s timeout') + }) + + test('returns minutes for values evenly divisible by 60', () => { + expect(formatTimeout(60)).toBe('1m timeout') + expect(formatTimeout(120)).toBe('2m timeout') + expect(formatTimeout(300)).toBe('5m timeout') + }) + + test('returns hours for values evenly divisible by 3600', () => { + expect(formatTimeout(3600)).toBe('1h timeout') + expect(formatTimeout(7200)).toBe('2h timeout') + expect(formatTimeout(10800)).toBe('3h timeout') + }) + + test('returns minutes for large values divisible by 60 but not 3600', () => { + expect(formatTimeout(5400)).toBe('90m timeout') + }) + + test('returns seconds for large values not evenly divisible by 60', () => { + expect(formatTimeout(3700)).toBe('3700s timeout') + }) + + test('returns seconds for values >= 60 not evenly divisible by 60', () => { + expect(formatTimeout(90)).toBe('90s timeout') + expect(formatTimeout(150)).toBe('150s timeout') + }) + + test('returns "0s timeout" for 0', () => { + expect(formatTimeout(0)).toBe('0s timeout') + }) + }) + + describe('negative values', () => { + test('returns "no timeout" for -1', () => { + expect(formatTimeout(-1)).toBe('no timeout') + }) + + test('returns "no timeout" for other negative values', () => { + expect(formatTimeout(-5)).toBe('no timeout') + expect(formatTimeout(-100)).toBe('no timeout') + expect(formatTimeout(-0.5)).toBe('no timeout') + }) + }) + + describe('non-finite values', () => { + test('returns "no timeout" for NaN', () => { + expect(formatTimeout(NaN)).toBe('no timeout') + }) + + test('returns "no timeout" for Infinity', () => { + expect(formatTimeout(Infinity)).toBe('no timeout') + }) + + test('returns "no timeout" for -Infinity', () => { + expect(formatTimeout(-Infinity)).toBe('no timeout') + }) + }) + + describe('floating point values', () => { + test('rounds floating point values to nearest integer', () => { + expect(formatTimeout(30.4)).toBe('30s timeout') + expect(formatTimeout(30.5)).toBe('31s timeout') + expect(formatTimeout(30.9)).toBe('31s timeout') + }) + + test('rounds floating point values for minute display', () => { + expect(formatTimeout(59.5)).toBe('1m timeout') + expect(formatTimeout(60.4)).toBe('1m timeout') + expect(formatTimeout(119.6)).toBe('2m timeout') + }) + + test('handles floating point values that round to non-minute values', () => { + expect(formatTimeout(60.6)).toBe('61s timeout') + expect(formatTimeout(89.5)).toBe('90s timeout') + }) + }) +}) diff --git a/cli/src/utils/__tests__/freebuff-instance-owner.test.ts b/cli/src/utils/__tests__/freebuff-instance-owner.test.ts new file mode 100644 index 0000000000..d8aacaf41f --- /dev/null +++ b/cli/src/utils/__tests__/freebuff-instance-owner.test.ts @@ -0,0 +1,69 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { ensureCliTestEnv } from '../../__tests__/test-utils' + +const OWNER_FILE = 'freebuff-instance-owner.json' + +ensureCliTestEnv() + +const { getConfigDir } = await import('../auth') +const { + isFreebuffInstanceOwnedByDeadLocalProcess, + recordFreebuffInstanceOwner, +} = await import('../freebuff-instance-owner') + +describe('freebuff instance owner', () => { + let originalHome: string | undefined + let tempHome: string + + const ownerPath = () => path.join(getConfigDir(), OWNER_FILE) + + beforeEach(() => { + originalHome = process.env.HOME + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'freebuff-owner-')) + process.env.HOME = tempHome + }) + + afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + fs.rmSync(tempHome, { recursive: true, force: true }) + }) + + test('does not classify the current process as dead', () => { + recordFreebuffInstanceOwner('inst-current') + + expect( + isFreebuffInstanceOwnedByDeadLocalProcess('inst-current'), + ).toBe(false) + }) + + test('classifies a matching owner with a dead pid as dead', () => { + fs.mkdirSync(getConfigDir(), { recursive: true }) + fs.writeFileSync( + ownerPath(), + JSON.stringify({ instanceId: 'inst-dead', pid: 2_147_483_647 }), + ) + + expect(isFreebuffInstanceOwnedByDeadLocalProcess('inst-dead')).toBe(true) + }) + + test('ignores a dead pid for a different instance id', () => { + fs.mkdirSync(getConfigDir(), { recursive: true }) + fs.writeFileSync( + ownerPath(), + JSON.stringify({ instanceId: 'inst-other', pid: 2_147_483_647 }), + ) + + expect( + isFreebuffInstanceOwnedByDeadLocalProcess('inst-current'), + ).toBe(false) + }) +}) diff --git a/cli/src/utils/__tests__/freebuff-model-navigation.test.ts b/cli/src/utils/__tests__/freebuff-model-navigation.test.ts new file mode 100644 index 0000000000..68157d71ae --- /dev/null +++ b/cli/src/utils/__tests__/freebuff-model-navigation.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'bun:test' + +import { + freebuffModelNavigationDirectionForKey, + nextFreebuffModelId, +} from '../freebuff-model-navigation' + +describe('nextFreebuffModelId', () => { + test('moves to the next model when moving forward', () => { + const modelIds = ['glm', 'minimax'] + + expect( + nextFreebuffModelId({ + modelIds, + focusedId: 'minimax', + direction: 'forward', + }), + ).toBe('glm') + }) + + test('moves to the previous model when moving backward', () => { + const modelIds = ['glm', 'minimax'] + + expect( + nextFreebuffModelId({ + modelIds, + focusedId: 'minimax', + direction: 'backward', + }), + ).toBe('glm') + }) + + test('wraps through every model regardless of selectability', () => { + const modelIds = ['glm', 'minimax', 'other'] + + expect( + nextFreebuffModelId({ + modelIds, + focusedId: 'minimax', + direction: 'forward', + }), + ).toBe('other') + }) + + test('returns null when no model exists', () => { + expect( + nextFreebuffModelId({ + modelIds: [], + focusedId: 'glm', + direction: 'forward', + }), + ).toBeNull() + }) +}) + +describe('freebuffModelNavigationDirectionForKey', () => { + test('maps arrow keys to model navigation directions', () => { + expect(freebuffModelNavigationDirectionForKey({ name: 'down' })).toBe( + 'forward', + ) + expect(freebuffModelNavigationDirectionForKey({ name: 'right' })).toBe( + 'forward', + ) + expect(freebuffModelNavigationDirectionForKey({ name: 'up' })).toBe( + 'backward', + ) + expect(freebuffModelNavigationDirectionForKey({ name: 'left' })).toBe( + 'backward', + ) + }) + + test('maps tab and shift-tab to model navigation directions', () => { + expect(freebuffModelNavigationDirectionForKey({ name: 'tab' })).toBe( + 'forward', + ) + expect( + freebuffModelNavigationDirectionForKey({ name: 'tab', shift: true }), + ).toBe('backward') + }) + + test('maps terminal tab sequences to model navigation directions', () => { + expect(freebuffModelNavigationDirectionForKey({ sequence: '\t' })).toBe( + 'forward', + ) + expect( + freebuffModelNavigationDirectionForKey({ sequence: '\x1b[9u' }), + ).toBe('forward') + expect( + freebuffModelNavigationDirectionForKey({ sequence: '\x1b[Z' }), + ).toBe('backward') + expect( + freebuffModelNavigationDirectionForKey({ sequence: '\x1b[9;2u' }), + ).toBe('backward') + expect( + freebuffModelNavigationDirectionForKey({ sequence: '\x1b[27;2;9~' }), + ).toBe('backward') + }) + + test('ignores non-navigation keys', () => { + expect(freebuffModelNavigationDirectionForKey({ name: 'enter' })).toBeNull() + }) +}) diff --git a/cli/src/utils/__tests__/freebuff-premium-reset.test.ts b/cli/src/utils/__tests__/freebuff-premium-reset.test.ts new file mode 100644 index 0000000000..d69021bfc0 --- /dev/null +++ b/cli/src/utils/__tests__/freebuff-premium-reset.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' + +import { + formatFreebuffPremiumResetCountdown, + getFreebuffPremiumResetAt, +} from '../freebuff-premium-reset' + +describe('freebuff premium reset helpers', () => { + test('uses server resetAt when it is in the future', () => { + const nowMs = Date.parse('2026-05-11T20:00:00.000Z') + const resetAt = getFreebuffPremiumResetAt({ + nowMs, + rateLimitsByModel: { + 'test/model': { + model: 'test/model', + limit: 5, + period: 'pacific_day', + resetTimeZone: 'America/Los_Angeles', + resetAt: '2026-05-12T07:00:00.000Z', + windowHours: 24, + recentCount: 2, + }, + }, + }) + + expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z') + }) + + test('falls back to next midnight Pacific when resetAt is absent', () => { + const resetAt = getFreebuffPremiumResetAt({ + nowMs: Date.parse('2026-05-11T20:00:00.000Z'), + }) + + expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z') + }) + + test('keeps expired server resetAt instead of rolling stale quota forward', () => { + const nowMs = Date.parse('2026-05-12T07:05:00.000Z') + const resetAt = getFreebuffPremiumResetAt({ + nowMs, + rateLimitsByModel: { + 'test/model': { + model: 'test/model', + limit: 5, + period: 'pacific_day', + resetTimeZone: 'America/Los_Angeles', + resetAt: '2026-05-12T07:00:00.000Z', + windowHours: 24, + recentCount: 5, + }, + }, + }) + + expect(resetAt.toISOString()).toBe('2026-05-12T07:00:00.000Z') + expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('now') + }) + + test('handles Pacific daylight saving time boundaries', () => { + const resetAt = getFreebuffPremiumResetAt({ + nowMs: Date.parse('2026-01-15T20:00:00.000Z'), + }) + + expect(resetAt.toISOString()).toBe('2026-01-16T08:00:00.000Z') + }) + + test('formats hours and minutes left', () => { + const nowMs = Date.parse('2026-05-11T20:00:00.000Z') + const resetAt = new Date('2026-05-12T07:30:00.000Z') + + expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('11h 30m') + }) + + test('formats sub-hour reset countdowns', () => { + const nowMs = Date.parse('2026-05-12T06:30:00.000Z') + const resetAt = new Date('2026-05-12T07:00:00.000Z') + + expect(formatFreebuffPremiumResetCountdown(resetAt, nowMs)).toBe('30m') + }) +}) diff --git a/cli/src/utils/__tests__/image-dimensions.test.ts b/cli/src/utils/__tests__/image-dimensions.test.ts index c8381aa0d6..6fa4982ae4 100644 --- a/cli/src/utils/__tests__/image-dimensions.test.ts +++ b/cli/src/utils/__tests__/image-dimensions.test.ts @@ -1,13 +1,24 @@ import { mkdirSync, rmSync } from 'fs' import path from 'path' -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' import { Jimp } from 'jimp' import { setProjectRoot } from '../../project-files' import { calculateDisplaySize } from '../image-display' import { processImageFile } from '../image-handler' +// Mock the logger to prevent analytics initialization errors in tests +mock.module('../logger', () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, + }, +})) + const TEST_DIR = path.join(__dirname, 'temp-test-images') beforeEach(async () => { diff --git a/cli/src/utils/__tests__/image-processor.test.ts b/cli/src/utils/__tests__/image-processor.test.ts index 3de1ec46b2..14f9b9ce48 100644 --- a/cli/src/utils/__tests__/image-processor.test.ts +++ b/cli/src/utils/__tests__/image-processor.test.ts @@ -2,23 +2,42 @@ import { describe, expect, test, mock } from 'bun:test' import { processImagesForMessage } from '../image-processor' -import type { PendingImageAttachment } from '../../state/chat-store' +import type { PendingImageAttachment } from '../../types/store' -const createPendingImage = (path: string): PendingImageAttachment => ({ +// Type for the processor function used in tests +type ProcessorResult = + | { success: true; imagePart: { type: 'image'; image: string; mediaType: string } } + | { success: false; error: string } +type MockProcessor = (path: string, projectRoot: string) => Promise + +// Minimal logger type for tests - only need warn for these tests +interface TestLogger { + warn: (...args: unknown[]) => void + error: (...args: unknown[]) => void + debug: (...args: unknown[]) => void + info: (...args: unknown[]) => void + fatal: (...args: unknown[]) => void +} + +const createPendingImage = (path: string, processedImage?: { base64: string; mediaType: string }): PendingImageAttachment => ({ kind: 'image', path, filename: path.split('/').pop() ?? 'image.png', status: 'ready', + ...(processedImage && { processedImage }), }) describe('processImagesForMessage', () => { - test('deduplicates image paths and returns message content', async () => { - const pendingImages = [createPendingImage('/tmp/pic.png')] + test('uses pre-processed image data from pendingImages without re-reading from disk', async () => { + const pendingImages = [createPendingImage('/tmp/pic.png', { + base64: 'pre-processed-base64-data', + mediaType: 'image/png', + })] const processor = mock(async () => ({ success: true, imagePart: { type: 'image' as const, - image: 'base64-data', + image: 'disk-base64-data', mediaType: 'image/png', }, })) @@ -27,34 +46,164 @@ describe('processImagesForMessage', () => { content: 'Here is an image @/tmp/pic.png', pendingImages, projectRoot: '/repo', - processor: processor as any, + processor: processor as MockProcessor, }) - expect(processor).toHaveBeenCalledTimes(1) + // Should NOT call processor since we have pre-processed data + expect(processor).not.toHaveBeenCalled() expect(result.attachments).toHaveLength(1) expect(result.messageContent?.[0]).toMatchObject({ type: 'image', - image: 'base64-data', + image: 'pre-processed-base64-data', + mediaType: 'image/png', + }) + }) + + test('processes inline image paths that are not in pendingImages', async () => { + const pendingImages = [createPendingImage('/tmp/pic.png', { + base64: 'pre-processed-base64-data', + mediaType: 'image/png', + })] + const processor = mock(async () => ({ + success: true, + imagePart: { + type: 'image' as const, + image: 'inline-base64-data', + mediaType: 'image/jpeg', + }, + })) + + const result = await processImagesForMessage({ + content: 'Here is another image @/tmp/other.jpg', + pendingImages, + projectRoot: '/repo', + processor: processor as MockProcessor, + }) + + // Should call processor only for the inline path + expect(processor).toHaveBeenCalledTimes(1) + expect(processor).toHaveBeenCalledWith('/tmp/other.jpg', '/repo') + expect(result.messageContent).toHaveLength(2) + expect(result.messageContent?.[0]).toMatchObject({ + type: 'image', + image: 'pre-processed-base64-data', + }) + expect(result.messageContent?.[1]).toMatchObject({ + type: 'image', + image: 'inline-base64-data', }) }) - test('logs warnings when processing fails', async () => { + test('backwards compatibility: processes from disk when processedImage is missing', async () => { + // This tests the edge case where processedImage is missing but status is 'ready' + const pendingImages = [createPendingImage('/tmp/pic.png')] // No processedImage const warn = mock(() => {}) - const pendingImages = [createPendingImage('/tmp/fail.png')] + const processor = mock(async () => ({ + success: true, + imagePart: { + type: 'image' as const, + image: 'disk-base64-data', + mediaType: 'image/png', + }, + })) + + const result = await processImagesForMessage({ + content: '', + pendingImages, + projectRoot: '/repo', + processor: processor as MockProcessor, + log: { warn, error: () => {}, debug: () => {}, info: () => {}, fatal: () => {} } as TestLogger, + }) + + // Should warn about missing processedImage and fall back to disk + expect(warn).toHaveBeenCalled() + expect(processor).toHaveBeenCalledTimes(1) + expect(result.messageContent?.[0]).toMatchObject({ + type: 'image', + image: 'disk-base64-data', + }) + }) + + test('skips images with processing or error status', async () => { + const pendingImages: PendingImageAttachment[] = [ + { kind: 'image', path: '/tmp/processing.png', filename: 'processing.png', status: 'processing' }, + { kind: 'image', path: '/tmp/error.png', filename: 'error.png', status: 'error', note: 'failed' }, + createPendingImage('/tmp/ready.png', { base64: 'ready-data', mediaType: 'image/png' }), + ] + const processor = mock(async () => ({ + success: true, + imagePart: { + type: 'image' as const, + image: 'should-not-be-used', + mediaType: 'image/png', + }, + })) + + const result = await processImagesForMessage({ + content: '', + pendingImages, + projectRoot: '/repo', + processor: processor as MockProcessor, + }) + + // Should not call processor at all (ready image has processedImage) + expect(processor).not.toHaveBeenCalled() + // Only the ready image should be in messageContent + expect(result.messageContent).toHaveLength(1) + expect(result.messageContent?.[0]).toMatchObject({ + type: 'image', + image: 'ready-data', + }) + }) + + test('logs warnings when inline path processing fails', async () => { + const warn = mock(() => {}) + const pendingImages: PendingImageAttachment[] = [] const processor = mock(async () => ({ success: false, error: 'boom', })) const result = await processImagesForMessage({ - content: '', + content: 'Here is an image @/tmp/fail.png', pendingImages, projectRoot: '/repo', - processor: processor as any, - log: { warn } as any, + processor: processor as MockProcessor, + log: { warn, error: () => {}, debug: () => {}, info: () => {}, fatal: () => {} } as TestLogger, }) expect(warn).toHaveBeenCalled() expect(result.messageContent).toBeUndefined() }) + + test('deduplicates: does not process inline path that matches pending image path', async () => { + const pendingImages = [createPendingImage('/tmp/pic.png', { + base64: 'pre-processed-data', + mediaType: 'image/png', + })] + const processor = mock(async () => ({ + success: true, + imagePart: { + type: 'image' as const, + image: 'disk-data', + mediaType: 'image/png', + }, + })) + + const result = await processImagesForMessage({ + content: 'Here is the same image @/tmp/pic.png and again /tmp/pic.png', + pendingImages, + projectRoot: '/repo', + processor: processor as MockProcessor, + }) + + // Should not call processor since the path is already in pendingImages + expect(processor).not.toHaveBeenCalled() + // Should only have one image in messageContent (no duplicates) + expect(result.messageContent).toHaveLength(1) + expect(result.messageContent?.[0]).toMatchObject({ + type: 'image', + image: 'pre-processed-data', + }) + }) }) diff --git a/cli/src/utils/__tests__/implementor-helpers.test.ts b/cli/src/utils/__tests__/implementor-helpers.test.ts index 97dd00b359..44793c4086 100644 --- a/cli/src/utils/__tests__/implementor-helpers.test.ts +++ b/cli/src/utils/__tests__/implementor-helpers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' + import { extractValueForKey, extractFilePath, @@ -10,8 +11,21 @@ import { isImplementorAgent, getImplementorDisplayName, getImplementorIndex, + groupConsecutiveBlocks, + groupConsecutiveImplementors, + groupConsecutiveNonImplementorAgents, + groupConsecutiveToolBlocks, + getMultiPromptProgress, + getMultiPromptPreview, + shouldShowEditDiff, } from '../implementor-helpers' -import type { ToolContentBlock, ContentBlock, AgentContentBlock, TextContentBlock } from '../../types/chat' + +import type { + ToolContentBlock, + ContentBlock, + AgentContentBlock, + TextContentBlock, +} from '../../types/chat' describe('extractValueForKey', () => { test('extracts simple key-value pairs', () => { @@ -96,9 +110,7 @@ describe('extractDiff', () => { toolCallId: 'test-1', toolName: 'str_replace', input: { - replacements: [ - { old: 'const x = 1', new: 'const x = 2' } - ] + replacements: [{ oldString: 'const x = 1', newString: 'const x = 2' }], }, } const diff = extractDiff(block) @@ -106,6 +118,82 @@ describe('extractDiff', () => { expect(diff).toContain('+ const x = 2') }) + test('constructs diff from successful str_replace input when output omits diff', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'str_replace', + input: { + replacements: [{ oldString: 'const x = 1', newString: 'const x = 2' }], + }, + output: 'message: String replace applied successfully.', + } + const diff = extractDiff(block) + expect(diff).toContain('- const x = 1') + expect(diff).toContain('+ const x = 2') + }) + + test('constructs diff from successful str_replace input with warning output', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'str_replace', + input: { + replacements: [{ oldString: 'const x = 1', newString: 'const x = 2' }], + }, + output: `message: | + Matched with indentation modification + + String replace applied successfully.`, + } + const diff = extractDiff(block) + expect(diff).toContain('- const x = 1') + expect(diff).toContain('+ const x = 2') + }) + + test('uses patch content from successful str_replace input when output omits diff', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'str_replace', + input: { type: 'patch', content: '- const x = 1\n+ const x = 2' }, + output: 'message: String replace applied successfully.', + } + expect(extractDiff(block)).toBe('- const x = 1\n+ const x = 2') + }) + + test('returns null for failed str_replace output without a diff', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'str_replace', + input: { + replacements: [{ oldString: 'const x = 1', newString: 'const x = 2' }], + }, + output: 'No change to the file', + } + expect(extractDiff(block)).toBeNull() + }) + + test('returns null for failed str_replace output even when it includes patch input', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'str_replace', + input: { type: 'patch', content: '- const x = 1\n+ const x = 2' }, + outputRaw: [ + { + type: 'json', + value: { + errorMessage: 'Failed to apply patch.', + patch: '- const x = 1\n+ const x = 2', + }, + }, + ], + } + expect(extractDiff(block)).toBeNull() + }) + test('constructs diff from write_file input', () => { const block: ToolContentBlock = { type: 'tool', @@ -117,15 +205,36 @@ describe('extractDiff', () => { expect(diff).toBe('+ line1\n+ line2') }) + test('constructs diff from successful write_file input when output omits diff', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'write_file', + input: { content: 'line1\nline2' }, + output: 'message: Overwrote file successfully.', + } + const diff = extractDiff(block) + expect(diff).toBe('+ line1\n+ line2') + }) + + test('returns null for failed write_file output without a diff', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'write_file', + input: { content: 'line1\nline2' }, + output: 'Failed to write to file', + } + expect(extractDiff(block)).toBeNull() + }) + test('constructs diff from propose_str_replace input', () => { const block: ToolContentBlock = { type: 'tool', toolCallId: 'test-1', toolName: 'propose_str_replace', input: { - replacements: [ - { old: 'const x = 1', new: 'const x = 2' } - ] + replacements: [{ oldString: 'const x = 1', newString: 'const x = 2' }], }, } const diff = extractDiff(block) @@ -170,8 +279,16 @@ describe('parseDiffStats', () => { }) test('handles empty diff', () => { - expect(parseDiffStats(undefined)).toEqual({ linesAdded: 0, linesRemoved: 0, hunks: 0 }) - expect(parseDiffStats('')).toEqual({ linesAdded: 0, linesRemoved: 0, hunks: 0 }) + expect(parseDiffStats(undefined)).toEqual({ + linesAdded: 0, + linesRemoved: 0, + hunks: 0, + }) + expect(parseDiffStats('')).toEqual({ + linesAdded: 0, + linesRemoved: 0, + hunks: 0, + }) }) test('ignores +++ and --- headers', () => { @@ -198,6 +315,17 @@ describe('getFileChangeType', () => { expect(getFileChangeType(block)).toBe('A') }) + test('returns A for successful file creation', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'write_file', + input: {}, + output: 'message: Created file successfully.', + } + expect(getFileChangeType(block)).toBe('A') + }) + test('returns M for write_file modification', () => { const block: ToolContentBlock = { type: 'tool', @@ -241,6 +369,82 @@ describe('getFileChangeType', () => { }) }) +describe('shouldShowEditDiff', () => { + test('does not show pending str_replace diffs before the result arrives', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'str_replace', + input: { + replacements: [{ oldString: 'const x = 1', newString: 'const x = 2' }], + }, + } + + expect(shouldShowEditDiff(block)).toBe(false) + }) + + test('shows str_replace diffs after a successful result', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'str_replace', + input: { + replacements: [{ oldString: 'const x = 1', newString: 'const x = 2' }], + }, + output: 'file: src/existing.ts\nmessage: String replace applied successfully.', + } + + expect(shouldShowEditDiff(block)).toBe(true) + }) + + test('does not show pending write_file diffs before the result arrives', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'write_file', + input: { path: 'src/new.ts', content: 'const x = 1\n' }, + } + + expect(extractDiff(block)).toBe('+ const x = 1\n+ ') + expect(shouldShowEditDiff(block)).toBe(false) + }) + + test('shows write_file diffs after an overwrite result', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'write_file', + input: { path: 'src/existing.ts', content: 'const x = 2\n' }, + output: 'file: src/existing.ts\nmessage: Overwrote file successfully.', + } + + expect(shouldShowEditDiff(block)).toBe(true) + }) + + test('does not show write_file diffs after a create result', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'write_file', + input: { path: 'src/new.ts', content: 'const x = 1\n' }, + output: 'file: src/new.ts\nmessage: Created file successfully.', + } + + expect(shouldShowEditDiff(block)).toBe(false) + }) + + test('continues to show pending proposed write_file diffs', () => { + const block: ToolContentBlock = { + type: 'tool', + toolCallId: 'test-1', + toolName: 'propose_write_file', + input: { path: 'src/new.ts', content: 'const x = 1\n' }, + } + + expect(shouldShowEditDiff(block)).toBe(true) + }) +}) + describe('getFileStatsFromBlocks', () => { test('aggregates stats for same file', () => { const blocks: ContentBlock[] = [ @@ -256,7 +460,9 @@ describe('getFileStatsFromBlocks', () => { toolCallId: 'test-2', toolName: 'str_replace', input: { path: 'file.ts' }, - outputRaw: [{ type: 'json', value: { unifiedDiff: '+line3\n-removed' } }], + outputRaw: [ + { type: 'json', value: { unifiedDiff: '+line3\n-removed' } }, + ], }, ] const stats = getFileStatsFromBlocks(blocks) @@ -299,6 +505,25 @@ describe('getFileStatsFromBlocks', () => { const stats = getFileStatsFromBlocks(blocks) expect(stats).toHaveLength(0) }) + + test('ignores failed edit tools', () => { + const blocks: ContentBlock[] = [ + { + type: 'tool', + toolCallId: 'test-1', + toolName: 'str_replace', + input: { + path: 'file.ts', + replacements: [ + { oldString: 'const x = 1', newString: 'const x = 2' }, + ], + }, + output: 'No change to the file', + }, + ] + const stats = getFileStatsFromBlocks(blocks) + expect(stats).toHaveLength(0) + }) }) describe('buildActivityTimeline', () => { @@ -346,20 +571,53 @@ describe('buildActivityTimeline', () => { expect(timeline).toHaveLength(1) expect(timeline[0].content).toBe('Normal text') }) + + test('skips failed edit tools', () => { + const blocks: ContentBlock[] = [ + { + type: 'text', + content: 'Trying an edit', + } as TextContentBlock, + { + type: 'tool', + toolCallId: 'test-1', + toolName: 'write_file', + input: { path: 'file.ts', content: 'new content' }, + output: 'Failed to write to file', + }, + ] + const timeline = buildActivityTimeline(blocks) + expect(timeline).toHaveLength(1) + expect(timeline[0].type).toBe('commentary') + }) }) describe('isImplementorAgent', () => { test('identifies implementor agents', () => { - expect(isImplementorAgent({ agentType: 'editor-implementor', blocks: [] })).toBe(true) - expect(isImplementorAgent({ agentType: 'editor-implementor-opus', blocks: [] })).toBe(true) - expect(isImplementorAgent({ agentType: 'editor-implementor-gpt-5', blocks: [] })).toBe(true) - expect(isImplementorAgent({ agentType: 'editor-implementor2', blocks: [] })).toBe(true) + expect( + isImplementorAgent({ agentType: 'editor-implementor', blocks: [] }), + ).toBe(true) + expect( + isImplementorAgent({ agentType: 'editor-implementor-opus', blocks: [] }), + ).toBe(true) + expect( + isImplementorAgent({ agentType: 'editor-implementor-gpt-5', blocks: [] }), + ).toBe(true) + expect( + isImplementorAgent({ agentType: 'editor-implementor2', blocks: [] }), + ).toBe(true) }) test('rejects non-implementor agents', () => { - expect(isImplementorAgent({ agentType: 'file-picker', blocks: [] })).toBe(false) - expect(isImplementorAgent({ agentType: 'commander', blocks: [] })).toBe(false) - expect(isImplementorAgent({ agentType: 'best-of-n-selector', blocks: [] })).toBe(false) + expect(isImplementorAgent({ agentType: 'file-picker', blocks: [] })).toBe( + false, + ) + expect(isImplementorAgent({ agentType: 'commander', blocks: [] })).toBe( + false, + ) + expect( + isImplementorAgent({ agentType: 'best-of-n-selector', blocks: [] }), + ).toBe(false) }) }) @@ -368,20 +626,48 @@ describe('getImplementorDisplayName', () => { expect(getImplementorDisplayName('editor-implementor')).toBe('Sonnet') expect(getImplementorDisplayName('editor-implementor-opus')).toBe('Opus') expect(getImplementorDisplayName('editor-implementor-gpt-5')).toBe('GPT-5') - expect(getImplementorDisplayName('editor-implementor-gemini')).toBe('Gemini') + expect(getImplementorDisplayName('editor-implementor-gemini')).toBe( + 'Gemini', + ) }) test('adds index when provided', () => { expect(getImplementorDisplayName('editor-implementor', 0)).toBe('Sonnet #1') - expect(getImplementorDisplayName('editor-implementor-opus', 2)).toBe('Opus #3') + expect(getImplementorDisplayName('editor-implementor-opus', 2)).toBe( + 'Opus #3', + ) }) }) describe('getImplementorIndex', () => { test('returns index among same-type siblings', () => { - const agent1 = { type: 'agent', agentId: 'a1', agentName: 'Impl 1', agentType: 'editor-implementor', content: '', status: 'complete', blocks: [] } as AgentContentBlock - const agent2 = { type: 'agent', agentId: 'a2', agentName: 'Impl 2', agentType: 'editor-implementor', content: '', status: 'complete', blocks: [] } as AgentContentBlock - const agent3 = { type: 'agent', agentId: 'a3', agentName: 'Impl 3', agentType: 'editor-implementor-opus', content: '', status: 'complete', blocks: [] } as AgentContentBlock + const agent1 = { + type: 'agent', + agentId: 'a1', + agentName: 'Impl 1', + agentType: 'editor-implementor', + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock + const agent2 = { + type: 'agent', + agentId: 'a2', + agentName: 'Impl 2', + agentType: 'editor-implementor', + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock + const agent3 = { + type: 'agent', + agentId: 'a3', + agentName: 'Impl 3', + agentType: 'editor-implementor-opus', + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock const siblings: ContentBlock[] = [agent1, agent2, agent3] expect(getImplementorIndex(agent1, siblings)).toBe(0) @@ -390,9 +676,783 @@ describe('getImplementorIndex', () => { }) test('returns undefined for non-implementor', () => { - const filePicker = { type: 'agent', agentId: 'fp1', agentName: 'File Picker', agentType: 'file-picker', content: '', status: 'complete', blocks: [] } as AgentContentBlock + const filePicker = { + type: 'agent', + agentId: 'fp1', + agentName: 'File Picker', + agentType: 'file-picker', + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock const siblings: ContentBlock[] = [filePicker] expect(getImplementorIndex(filePicker, siblings)).toBeUndefined() }) }) + +describe('groupConsecutiveBlocks', () => { + const createTextBlock = (content: string): TextContentBlock => + ({ + type: 'text', + content, + }) as TextContentBlock + + const createToolBlock = (toolName: string): ToolContentBlock => ({ + type: 'tool', + toolCallId: `tool-${toolName}`, + toolName: toolName as ToolContentBlock['toolName'], + input: {}, + }) + + const createAgentBlock = ( + agentType: string, + agentId: string, + ): AgentContentBlock => + ({ + type: 'agent', + agentId, + agentName: agentType, + agentType, + content: '', + status: 'complete', + blocks: [], + }) as AgentContentBlock + + test('groups consecutive matching blocks from start', () => { + const blocks: ContentBlock[] = [ + createTextBlock('text1'), + createTextBlock('text2'), + createToolBlock('str_replace'), + ] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 0, isText) + + expect(result.group).toHaveLength(2) + expect(result.group[0].content).toBe('text1') + expect(result.group[1].content).toBe('text2') + expect(result.nextIndex).toBe(2) + }) + + test('groups from middle of array', () => { + const blocks: ContentBlock[] = [ + createToolBlock('read_files'), + createTextBlock('text1'), + createTextBlock('text2'), + createTextBlock('text3'), + createToolBlock('write_file'), + ] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 1, isText) + + expect(result.group).toHaveLength(3) + expect(result.nextIndex).toBe(4) + }) + + test('returns empty group when first block does not match', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace'), + createTextBlock('text1'), + ] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 0, isText) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('handles empty blocks array', () => { + const blocks: ContentBlock[] = [] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 0, isText) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('handles startIndex at end of array', () => { + const blocks: ContentBlock[] = [createTextBlock('text1')] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 1, isText) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(1) + }) + + test('handles startIndex beyond array length', () => { + const blocks: ContentBlock[] = [createTextBlock('text1')] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 10, isText) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(10) + }) + + test('groups all blocks when all match', () => { + const blocks: ContentBlock[] = [ + createTextBlock('text1'), + createTextBlock('text2'), + createTextBlock('text3'), + ] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 0, isText) + + expect(result.group).toHaveLength(3) + expect(result.nextIndex).toBe(3) + }) + + test('groups single matching block', () => { + const blocks: ContentBlock[] = [ + createTextBlock('text1'), + createToolBlock('str_replace'), + ] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 0, isText) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('works with complex predicates', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace'), + createToolBlock('write_file'), + createToolBlock('read_files'), + createTextBlock('done'), + ] + const isEditTool = (b: ContentBlock): b is ToolContentBlock => + b.type === 'tool' && + ['str_replace', 'write_file'].includes(b.toolName as string) + const result = groupConsecutiveBlocks(blocks, 0, isEditTool) + + expect(result.group).toHaveLength(2) + expect(result.group[0].toolName).toBe('str_replace') + expect(result.group[1].toolName).toBe('write_file') + expect(result.nextIndex).toBe(2) + }) +}) + +describe('groupConsecutiveImplementors', () => { + const createImplementorAgent = ( + id: string, + agentType = 'editor-implementor', + ): AgentContentBlock => + ({ + type: 'agent', + agentId: id, + agentName: 'Implementor', + agentType, + content: '', + status: 'complete', + blocks: [], + }) as AgentContentBlock + + const createNonImplementorAgent = ( + id: string, + agentType: string, + ): AgentContentBlock => + ({ + type: 'agent', + agentId: id, + agentName: agentType, + agentType, + content: '', + status: 'complete', + blocks: [], + }) as AgentContentBlock + + const createTextBlock = (content: string): TextContentBlock => + ({ + type: 'text', + content, + }) as TextContentBlock + + test('groups consecutive implementor agents', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createImplementorAgent('impl-2', 'editor-implementor-opus'), + createImplementorAgent('impl-3', 'editor-implementor-gpt-5'), + createNonImplementorAgent('fp-1', 'file-picker'), + ] + const result = groupConsecutiveImplementors(blocks, 0) + + expect(result.group).toHaveLength(3) + expect(result.group[0].agentId).toBe('impl-1') + expect(result.group[1].agentId).toBe('impl-2') + expect(result.group[2].agentId).toBe('impl-3') + expect(result.nextIndex).toBe(3) + }) + + test('stops at non-implementor agent', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createNonImplementorAgent('cmd-1', 'commander'), + createImplementorAgent('impl-2'), + ] + const result = groupConsecutiveImplementors(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('stops at non-agent block', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createTextBlock('some text'), + createImplementorAgent('impl-2'), + ] + const result = groupConsecutiveImplementors(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('returns empty group when starting at non-implementor', () => { + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createImplementorAgent('impl-1'), + ] + const result = groupConsecutiveImplementors(blocks, 0) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('handles agents with proposed tools as implementors', () => { + const agentWithProposedTools: AgentContentBlock = { + type: 'agent', + agentId: 'custom-1', + agentName: 'Custom Agent', + agentType: 'custom-agent', + content: '', + status: 'complete', + blocks: [ + { + type: 'tool', + toolCallId: 'tool-1', + toolName: 'propose_str_replace', + input: {}, + }, + ], + } as AgentContentBlock + + const blocks: ContentBlock[] = [ + agentWithProposedTools, + createImplementorAgent('impl-1'), + ] + const result = groupConsecutiveImplementors(blocks, 0) + + expect(result.group).toHaveLength(2) + expect(result.group[0].agentId).toBe('custom-1') + expect(result.group[1].agentId).toBe('impl-1') + }) + + test('handles empty blocks array', () => { + const result = groupConsecutiveImplementors([], 0) + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) +}) + +describe('groupConsecutiveNonImplementorAgents', () => { + const createImplementorAgent = (id: string): AgentContentBlock => + ({ + type: 'agent', + agentId: id, + agentName: 'Implementor', + agentType: 'editor-implementor', + content: '', + status: 'complete', + blocks: [], + }) as AgentContentBlock + + const createNonImplementorAgent = ( + id: string, + agentType: string, + ): AgentContentBlock => + ({ + type: 'agent', + agentId: id, + agentName: agentType, + agentType, + content: '', + status: 'complete', + blocks: [], + }) as AgentContentBlock + + const createTextBlock = (content: string): TextContentBlock => + ({ + type: 'text', + content, + }) as TextContentBlock + + test('groups consecutive non-implementor agents', () => { + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('cmd-1', 'commander'), + createNonImplementorAgent('cs-1', 'code-searcher'), + createImplementorAgent('impl-1'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(3) + expect(result.group[0].agentType).toBe('file-picker') + expect(result.group[1].agentType).toBe('commander') + expect(result.group[2].agentType).toBe('code-searcher') + expect(result.nextIndex).toBe(3) + }) + + test('stops at implementor agent', () => { + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createImplementorAgent('impl-1'), + createNonImplementorAgent('cmd-1', 'commander'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('stops at non-agent block', () => { + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createTextBlock('some text'), + createNonImplementorAgent('cmd-1', 'commander'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('returns empty group when starting at implementor', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createNonImplementorAgent('fp-1', 'file-picker'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('returns empty group when starting at text block', () => { + const blocks: ContentBlock[] = [ + createTextBlock('some text'), + createNonImplementorAgent('fp-1', 'file-picker'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('groups from middle of array', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('cmd-1', 'commander'), + createTextBlock('done'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 1) + + expect(result.group).toHaveLength(2) + expect(result.group[0].agentType).toBe('file-picker') + expect(result.group[1].agentType).toBe('commander') + expect(result.nextIndex).toBe(3) + }) + + test('handles mixed agent types', () => { + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('think-1', 'thinker'), + createNonImplementorAgent('rev-1', 'reviewer'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(3) + expect(result.nextIndex).toBe(3) + }) + + test('handles empty blocks array', () => { + const result = groupConsecutiveNonImplementorAgents([], 0) + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) +}) + +describe('getMultiPromptProgress', () => { + const createImplementorAgent = ( + id: string, + status: 'running' | 'complete' | 'failed' | 'cancelled' = 'complete', + ): AgentContentBlock => + ({ + type: 'agent', + agentId: id, + agentName: 'Implementor', + agentType: 'editor-implementor-opus', + content: '', + status, + blocks: [], + }) as AgentContentBlock + + const createSelectorAgent = ( + status: 'running' | 'complete' = 'running', + ): AgentContentBlock => + ({ + type: 'agent', + agentId: 'selector-1', + agentName: 'Selector', + agentType: 'best-of-n-selector2', + content: '', + status, + blocks: [], + }) as AgentContentBlock + + test('returns null for empty blocks', () => { + expect(getMultiPromptProgress([])).toBeNull() + expect(getMultiPromptProgress(undefined)).toBeNull() + }) + + test('returns null when no implementors present', () => { + const blocks: ContentBlock[] = [ + { type: 'text', content: 'some text' } as TextContentBlock, + ] + expect(getMultiPromptProgress(blocks)).toBeNull() + }) + + test('counts total and completed implementors', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'running'), + createImplementorAgent('impl-3', 'complete'), + ] + const progress = getMultiPromptProgress(blocks) + expect(progress).toEqual({ + total: 3, + completed: 2, + failed: 0, + isSelecting: false, + isSelectorComplete: false, + }) + }) + + test('counts failed implementors separately', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'failed'), + createImplementorAgent('impl-3', 'cancelled'), + ] + const progress = getMultiPromptProgress(blocks) + expect(progress).toEqual({ + total: 3, + completed: 1, + failed: 2, + isSelecting: false, + isSelectorComplete: false, + }) + }) + + test('detects selector running state', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'complete'), + createSelectorAgent('running'), + ] + const progress = getMultiPromptProgress(blocks) + expect(progress?.isSelecting).toBe(true) + expect(progress?.isSelectorComplete).toBe(false) + }) + + test('detects selector complete state', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'complete'), + createSelectorAgent('complete'), + ] + const progress = getMultiPromptProgress(blocks) + expect(progress?.isSelecting).toBe(false) + expect(progress?.isSelectorComplete).toBe(true) + }) + + test('treats failed as finished for progress calculation', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'failed'), + createImplementorAgent('impl-3', 'running'), + ] + const progress = getMultiPromptProgress(blocks) + // 1 complete + 1 failed = 2 finished out of 3 + expect(progress?.completed).toBe(1) + expect(progress?.failed).toBe(1) + expect(progress?.total).toBe(3) + }) +}) + +describe('getMultiPromptPreview', () => { + const createImplementorAgent = ( + id: string, + status: 'running' | 'complete' | 'failed' | 'cancelled' = 'complete', + ): AgentContentBlock => + ({ + type: 'agent', + agentId: id, + agentName: 'Implementor', + agentType: 'editor-implementor-opus', + content: '', + status, + blocks: [], + }) as AgentContentBlock + + const createSelectorAgent = ( + status: 'running' | 'complete' = 'running', + ): AgentContentBlock => + ({ + type: 'agent', + agentId: 'selector-1', + agentName: 'Selector', + agentType: 'best-of-n-selector2', + content: '', + status, + blocks: [], + }) as AgentContentBlock + + const createSetOutputBlock = (reason?: string): ToolContentBlock => ({ + type: 'tool', + toolCallId: 'set-output-1', + toolName: 'set_output', + input: reason + ? { data: { chosenStrategy: 'strategy A', reason } } + : { data: { chosenStrategy: 'strategy A' } }, + }) + + test('returns null for empty blocks', () => { + expect(getMultiPromptPreview([])).toBeNull() + expect(getMultiPromptPreview(undefined)).toBeNull() + }) + + test('shows generating message when no implementors complete', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'running'), + createImplementorAgent('impl-2', 'running'), + createImplementorAgent('impl-3', 'running'), + ] + expect(getMultiPromptPreview(blocks)).toBe('Generating 3 proposals...') + }) + + test('shows progress when some implementors complete', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'running'), + createImplementorAgent('impl-3', 'complete'), + ] + expect(getMultiPromptPreview(blocks)).toBe('2/3 proposals complete...') + }) + + test('shows selecting message when selector is running', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'complete'), + createImplementorAgent('impl-3', 'complete'), + createSelectorAgent('running'), + ] + expect(getMultiPromptPreview(blocks)).toBe( + '3 proposals complete • Selecting best...', + ) + }) + + test('shows applying message when selector is complete but agent not done', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'complete'), + createSelectorAgent('complete'), + ] + expect(getMultiPromptPreview(blocks, false)).toBe( + 'Applying selected changes...', + ) + }) + + test('shows evaluation count when agent is complete without reason', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'complete'), + createImplementorAgent('impl-3', 'complete'), + ] + expect(getMultiPromptPreview(blocks, true)).toBe('3 proposals evaluated') + }) + + test('shows evaluation count with reason when agent is complete', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'complete'), + createSetOutputBlock('best implementation with proper error handling'), + ] + const preview = getMultiPromptPreview(blocks, true) + expect(preview).toBe( + '2 proposals evaluated\nBest implementation with proper error handling', + ) + }) + + test('capitalizes first letter of reason', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createSetOutputBlock('simple and clean'), + ] + const preview = getMultiPromptPreview(blocks, true) + expect(preview).toContain('Simple and clean') + }) + + test('shows failure count when some implementors fail', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'failed'), + createImplementorAgent('impl-3', 'running'), + ] + expect(getMultiPromptPreview(blocks)).toBe('1/3 complete, 1 failed...') + }) + + test('shows all finished with failures when all done but some failed', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'complete'), + createImplementorAgent('impl-2', 'complete'), + createImplementorAgent('impl-3', 'failed'), + ] + expect(getMultiPromptPreview(blocks)).toBe( + '2/3 proposals complete (1 failed)', + ) + }) + + test('treats failed implementors as finished for progress', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'cancelled'), + createImplementorAgent('impl-2', 'failed'), + createImplementorAgent('impl-3', 'complete'), + ] + // All 3 are finished (1 complete + 2 failed/cancelled), so should show completion message + expect(getMultiPromptPreview(blocks)).toBe( + '1/3 proposals complete (2 failed)', + ) + }) +}) + +describe('groupConsecutiveToolBlocks', () => { + const createToolBlock = (toolName: string, id: string): ToolContentBlock => ({ + type: 'tool', + toolCallId: id, + toolName: toolName as ToolContentBlock['toolName'], + input: {}, + }) + + const createTextBlock = (content: string): TextContentBlock => + ({ + type: 'text', + content, + }) as TextContentBlock + + const createAgentBlock = (id: string): AgentContentBlock => + ({ + type: 'agent', + agentId: id, + agentName: 'Test Agent', + agentType: 'file-picker', + content: '', + status: 'complete', + blocks: [], + }) as AgentContentBlock + + test('groups consecutive tool blocks', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createToolBlock('write_file', 'tool-2'), + createToolBlock('read_files', 'tool-3'), + createTextBlock('done'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(3) + expect(result.group[0].toolCallId).toBe('tool-1') + expect(result.group[1].toolCallId).toBe('tool-2') + expect(result.group[2].toolCallId).toBe('tool-3') + expect(result.nextIndex).toBe(3) + }) + + test('stops at non-tool block', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createTextBlock('some text'), + createToolBlock('write_file', 'tool-2'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('stops at agent block', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createAgentBlock('agent-1'), + createToolBlock('write_file', 'tool-2'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('returns empty group when starting at non-tool block', () => { + const blocks: ContentBlock[] = [ + createTextBlock('some text'), + createToolBlock('str_replace', 'tool-1'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('groups from middle of array', () => { + const blocks: ContentBlock[] = [ + createTextBlock('start'), + createToolBlock('str_replace', 'tool-1'), + createToolBlock('write_file', 'tool-2'), + createTextBlock('end'), + ] + const result = groupConsecutiveToolBlocks(blocks, 1) + + expect(result.group).toHaveLength(2) + expect(result.group[0].toolCallId).toBe('tool-1') + expect(result.group[1].toolCallId).toBe('tool-2') + expect(result.nextIndex).toBe(3) + }) + + test('handles empty blocks array', () => { + const result = groupConsecutiveToolBlocks([], 0) + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('groups all tool blocks when all match', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createToolBlock('write_file', 'tool-2'), + createToolBlock('read_files', 'tool-3'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(3) + expect(result.nextIndex).toBe(3) + }) + + test('handles single tool block', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createTextBlock('done'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) +}) diff --git a/cli/src/utils/__tests__/keyboard-actions.test.ts b/cli/src/utils/__tests__/keyboard-actions.test.ts index d21f2ce791..c518b47ea7 100644 --- a/cli/src/utils/__tests__/keyboard-actions.test.ts +++ b/cli/src/utils/__tests__/keyboard-actions.test.ts @@ -54,17 +54,6 @@ describe('resolveChatKeyboardAction', () => { }) }) - test('escape in referral mode exits mode even while streaming', () => { - const state: ChatKeyboardState = { - ...defaultState, - inputMode: 'referral', - isStreaming: true, - } - expect(resolveChatKeyboardAction(escapeKey, state)).toEqual({ - type: 'exit-input-mode', - }) - }) - test('escape in usage mode exits mode', () => { const state: ChatKeyboardState = { ...defaultState, @@ -568,4 +557,64 @@ describe('resolveChatKeyboardAction', () => { }) }) }) + + describe('toggle all (Ctrl+T)', () => { + const ctrlT = createKey({ name: 't', ctrl: true }) + + test('Ctrl+T triggers toggle-all', () => { + expect(resolveChatKeyboardAction(ctrlT, defaultState)).toEqual({ + type: 'toggle-all', + }) + }) + + test('Ctrl+T works while streaming', () => { + const state: ChatKeyboardState = { + ...defaultState, + isStreaming: true, + } + expect(resolveChatKeyboardAction(ctrlT, state)).toEqual({ + type: 'toggle-all', + }) + }) + + test('Ctrl+T works with text in input', () => { + const state: ChatKeyboardState = { + ...defaultState, + inputValue: 'some text', + } + expect(resolveChatKeyboardAction(ctrlT, state)).toEqual({ + type: 'toggle-all', + }) + }) + + test('Ctrl+T works in bash mode', () => { + const state: ChatKeyboardState = { + ...defaultState, + inputMode: 'bash', + } + expect(resolveChatKeyboardAction(ctrlT, state)).toEqual({ + type: 'toggle-all', + }) + }) + + test('Ctrl+T blocked in feedback mode', () => { + const state: ChatKeyboardState = { + ...defaultState, + feedbackMode: true, + } + expect(resolveChatKeyboardAction(ctrlT, state)).toEqual({ + type: 'none', + }) + }) + + test('Ctrl+T blocked in outOfCredits mode', () => { + const state: ChatKeyboardState = { + ...defaultState, + inputMode: 'outOfCredits', + } + expect(resolveChatKeyboardAction(ctrlT, state)).toEqual({ + type: 'none', + }) + }) + }) }) diff --git a/cli/src/utils/__tests__/layout-helpers.test.ts b/cli/src/utils/__tests__/layout-helpers.test.ts index a0d3008339..fbbd785eb4 100644 --- a/cli/src/utils/__tests__/layout-helpers.test.ts +++ b/cli/src/utils/__tests__/layout-helpers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' + import { computeSmartColumns } from '../layout-helpers' describe('computeSmartColumns', () => { diff --git a/cli/src/utils/__tests__/markdown-renderer.test.tsx b/cli/src/utils/__tests__/markdown-renderer.test.tsx index 26f9697a25..36ea688fe6 100644 --- a/cli/src/utils/__tests__/markdown-renderer.test.tsx +++ b/cli/src/utils/__tests__/markdown-renderer.test.tsx @@ -4,10 +4,12 @@ import React from 'react' import { renderMarkdown, renderStreamingMarkdown } from '../markdown-renderer' -const flattenNodes = (input: React.ReactNode): React.ReactNode[] => { +type El = React.ReactElement> + +const flattenNodes = (input: unknown): React.ReactNode[] => { const result: React.ReactNode[] = [] - const visit = (value: React.ReactNode): void => { + const visit = (value: unknown): void => { if (value === null || value === undefined || typeof value === 'boolean') { return } @@ -18,18 +20,18 @@ const flattenNodes = (input: React.ReactNode): React.ReactNode[] => { } if (React.isValidElement(value) && value.type === React.Fragment) { - visit(value.props.children) + visit((value as El).props.children) return } - result.push(value) + result.push(value as React.ReactNode) } visit(input) return result } -const flattenChildren = (value: React.ReactNode): React.ReactNode[] => +const flattenChildren = (value: unknown): React.ReactNode[] => flattenNodes(value) describe('markdown renderer', () => { @@ -39,13 +41,13 @@ describe('markdown renderer', () => { expect(nodes[0]).toBe('Hello ') - const bold = nodes[1] as React.ReactElement + const bold = nodes[1] as El expect(bold.props.attributes).toBe(TextAttributes.BOLD) expect(flattenChildren(bold.props.children)).toEqual(['bold']) expect(nodes[2]).toBe(' and ') - const italic = nodes[3] as React.ReactElement + const italic = nodes[3] as El expect(italic.props.attributes).toBe(TextAttributes.ITALIC) expect(flattenChildren(italic.props.children)).toEqual(['italic']) @@ -58,7 +60,7 @@ describe('markdown renderer', () => { expect(nodes[0]).toBe('Use ') - const inlineCode = nodes[1] as React.ReactElement + const inlineCode = nodes[1] as El expect(inlineCode.props.fg).toBe('#86efac') expect(inlineCode.props.bg).toBe('#0d1117') expect(flattenChildren(inlineCode.props.children)).toEqual([' ls ']) @@ -70,7 +72,7 @@ describe('markdown renderer', () => { const output = renderMarkdown('# Heading One') const nodes = flattenNodes(output) - const heading = nodes[0] as React.ReactElement + const heading = nodes[0] as El expect(heading.props.attributes).toBe(TextAttributes.BOLD) expect(heading.props.fg).toBe('magenta') expect(flattenChildren(heading.props.children)).toEqual(['Heading One']) @@ -82,12 +84,12 @@ describe('markdown renderer', () => { ) const nodes = flattenNodes(output) - const heading = nodes[0] as React.ReactElement + const heading = nodes[0] as El const contents = flattenChildren(heading.props.children) expect(contents[0]).toBe('Other') - const strong = contents[1] as React.ReactElement + const strong = contents[1] as El expect(strong.props.attributes).toBe(TextAttributes.BOLD) expect(flattenChildren(strong.props.children)).toEqual(['.github/']) @@ -98,11 +100,11 @@ describe('markdown renderer', () => { const output = renderMarkdown('> note') const nodes = flattenNodes(output) - const prefixSpan = nodes[0] as React.ReactElement + const prefixSpan = nodes[0] as El expect(prefixSpan.props.fg).toBe('gray') expect(flattenChildren(prefixSpan.props.children)).toEqual(['> ']) - const textSpan = nodes[1] as React.ReactElement + const textSpan = nodes[1] as El expect(textSpan.props.fg).toBe('gray') expect(flattenChildren(textSpan.props.children)).toEqual(['note']) }) @@ -112,10 +114,10 @@ describe('markdown renderer', () => { const nodes = flattenNodes(output) const bulletSpans = nodes.filter( - (node): node is React.ReactElement => + (node): node is El => React.isValidElement(node) && node.type === 'span' && - flattenChildren(node.props.children).join('') === '- ', + flattenChildren((node as El).props.children).join('') === '- ', ) expect(bulletSpans).toHaveLength(2) @@ -135,10 +137,10 @@ describe('markdown renderer', () => { const nodes = flattenNodes(output) const boldNode = nodes.find( - (node): node is React.ReactElement => + (node): node is El => React.isValidElement(node) && - node.props !== undefined && - node.props.attributes === TextAttributes.BOLD, + (node as El).props !== undefined && + (node as El).props.attributes === TextAttributes.BOLD, ) expect(boldNode).toBeDefined() @@ -152,7 +154,7 @@ describe('markdown renderer', () => { expect(nodes[0]).toBe('This is ') - const strikethrough = nodes[1] as React.ReactElement + const strikethrough = nodes[1] as El expect(strikethrough.props.attributes).toBe(TextAttributes.DIM) expect(flattenChildren(strikethrough.props.children)).toEqual(['deleted']) @@ -164,11 +166,11 @@ describe('markdown renderer', () => { const nodes = flattenNodes(output) const checkboxSpans = nodes.filter( - (node): node is React.ReactElement => + (node): node is El => React.isValidElement(node) && node.type === 'span' && - (flattenChildren(node.props.children).join('') === '[ ] ' || - flattenChildren(node.props.children).join('') === '[x] '), + (flattenChildren((node as El).props.children).join('') === '[ ] ' || + flattenChildren((node as El).props.children).join('') === '[x] '), ) expect(checkboxSpans).toHaveLength(2) @@ -187,7 +189,7 @@ describe('markdown renderer', () => { .map((node) => { if (typeof node === 'string') return node if (React.isValidElement(node)) { - return flattenChildren(node.props.children).join('') + return flattenChildren((node as El).props.children).join('') } return '' }) @@ -217,7 +219,7 @@ codebuff "add a new feature to handle user authentication" .map((node) => { if (typeof node === 'string') return node if (React.isValidElement(node)) { - return flattenChildren(node.props.children).join('') + return flattenChildren((node as El).props.children).join('') } return '' }) @@ -241,7 +243,7 @@ codebuff "add a new feature to handle user authentication" expect(nodes[0]).toBe('Use ') - const inlineCode = nodes[1] as React.ReactElement + const inlineCode = nodes[1] as El expect(inlineCode.props.fg).toBe('#86efac') const inlineContent = flattenChildren(inlineCode.props.children).join('') expect(inlineContent).toContain('codebuff "fix bug"') @@ -271,7 +273,7 @@ console.log("world") .map((node) => { if (typeof node === 'string') return node if (React.isValidElement(node)) { - return flattenChildren(node.props.children).join('') + return flattenChildren((node as El).props.children).join('') } return '' }) @@ -299,7 +301,7 @@ codebuff "implement feature" --verbose .map((node) => { if (typeof node === 'string') return node if (React.isValidElement(node)) { - return flattenChildren(node.props.children).join('') + return flattenChildren((node as El).props.children).join('') } return '' }) @@ -315,7 +317,7 @@ codebuff "implement feature" --verbose const output = renderMarkdown(markdown) const nodes = flattenNodes(output) - const inlineCode = nodes[1] as React.ReactElement + const inlineCode = nodes[1] as El const inlineContent = flattenChildren(inlineCode.props.children).join('') // Should preserve quotes and special characters within inline code @@ -323,13 +325,13 @@ codebuff "implement feature" --verbose expect(nodes[2]).toBe(' to commit.') }) - test('truncates table columns when content exceeds available width', () => { - // Table with very long content that should be truncated - const markdown = `| ID | This is a very long column header that should be truncated | -| -- | ---------------------------------------------------------- | + test('wraps table columns when content exceeds available width', () => { + // Table with very long content that should be wrapped + const markdown = `| ID | This is a very long column header that should wrap | +| -- | -------------------------------------------------- | | 1 | This cell has extremely long content that definitely exceeds the width |` - // Use a narrow codeBlockWidth to force truncation + // Use a narrow codeBlockWidth to force wrapping const output = renderMarkdown(markdown, { codeBlockWidth: 50 }) const nodes = flattenNodes(output) @@ -337,30 +339,34 @@ codebuff "implement feature" --verbose .map((node) => { if (typeof node === 'string') return node if (React.isValidElement(node)) { - return flattenChildren(node.props.children).join('') + return flattenChildren((node as El).props.children).join('') } return '' }) .join('') - // Should contain ellipsis indicating truncation of the long column - expect(textContent).toContain('…') - // The short column content should be present (ID and 1 are short enough) + // Should NOT contain ellipsis - content wraps instead of truncating + expect(textContent).not.toContain('…') + // The short column content should be present expect(textContent).toContain('ID') expect(textContent).toContain('1') // Box-drawing characters should still be present expect(textContent).toContain('│') expect(textContent).toContain('─') - // The long header should be truncated (not fully present) - expect(textContent).not.toContain('This is a very long column header that should be truncated') + // The full content should be present across wrapped lines + expect(textContent).toContain('long') + expect(textContent).toContain('header') + expect(textContent).toContain('wrap') + expect(textContent).toContain('extremely') + expect(textContent).toContain('exceeds') }) - test('does not truncate table columns when content fits available width', () => { + test('does not wrap table columns when content fits available width', () => { const markdown = `| Name | Age | | ---- | --- | | John | 30 |` - // Use a wide codeBlockWidth so no truncation is needed + // Use a wide codeBlockWidth so no wrapping is needed const output = renderMarkdown(markdown, { codeBlockWidth: 80 }) const nodes = flattenNodes(output) @@ -368,14 +374,12 @@ codebuff "implement feature" --verbose .map((node) => { if (typeof node === 'string') return node if (React.isValidElement(node)) { - return flattenChildren(node.props.children).join('') + return flattenChildren((node as El).props.children).join('') } return '' }) .join('') - // Should NOT contain ellipsis when content fits - expect(textContent).not.toContain('…') // All content should be present in full expect(textContent).toContain('Name') expect(textContent).toContain('Age') @@ -383,13 +387,13 @@ codebuff "implement feature" --verbose expect(textContent).toContain('30') }) - test('proportionally shrinks table columns when table is too wide', () => { + test('wraps and shows full content when table is too wide', () => { // Three columns of roughly equal width const markdown = `| Column One | Column Two | Column Three | | ---------- | ---------- | ------------ | | Value1 | Value2 | Value3 |` - // Very narrow width to force significant shrinking + // Very narrow width to force significant wrapping const output = renderMarkdown(markdown, { codeBlockWidth: 30 }) const nodes = flattenNodes(output) @@ -397,7 +401,7 @@ codebuff "implement feature" --verbose .map((node) => { if (typeof node === 'string') return node if (React.isValidElement(node)) { - return flattenChildren(node.props.children).join('') + return flattenChildren((node as El).props.children).join('') } return '' }) @@ -407,7 +411,11 @@ codebuff "implement feature" --verbose expect(textContent).toContain('│') expect(textContent).toContain('┌') expect(textContent).toContain('└') - // With such narrow width, some content should be truncated - expect(textContent).toContain('…') + // Full content should still be visible (wrapped, not truncated) + expect(textContent).not.toContain('…') + // All values should be present + expect(textContent).toContain('Value1') + expect(textContent).toContain('Value2') + expect(textContent).toContain('Value3') }) }) diff --git a/cli/src/utils/__tests__/message-block-helpers.test.ts b/cli/src/utils/__tests__/message-block-helpers.test.ts index 2da61a928f..55d66522bd 100644 --- a/cli/src/utils/__tests__/message-block-helpers.test.ts +++ b/cli/src/utils/__tests__/message-block-helpers.test.ts @@ -18,7 +18,13 @@ import { moveSpawnAgentBlock, } from '../message-block-helpers' -import type { ContentBlock } from '../../types/chat' +import type { + ContentBlock, + AgentContentBlock, + AskUserContentBlock, + TextContentBlock, + ToolContentBlock, +} from '../../types/chat' describe('getAgentBaseName', () => { test('extracts base name from scoped versioned name', () => { @@ -33,6 +39,10 @@ describe('getAgentBaseName', () => { expect(getAgentBaseName('file-picker')).toBe('file-picker') }) + test('normalizes direct tool aliases to canonical agent names', () => { + expect(getAgentBaseName('code_reviewer_lite')).toBe('code-reviewer-lite') + }) + test('handles scoped name without version', () => { expect(getAgentBaseName('codebuff/file-picker')).toBe('file-picker') }) @@ -119,7 +129,7 @@ describe('autoCollapseBlocks', () => { { type: 'text', content: 'thinking', thinkingId: 'think-1' }, ] const result = autoCollapseBlocks(blocks) - expect(result[0]).toHaveProperty('isCollapsed', true) + expect(result[0]).toHaveProperty('thinkingCollapseState', 'hidden') }) test('preserves user-opened text blocks', () => { @@ -178,7 +188,7 @@ describe('autoCollapseBlocks', () => { ] const result = autoCollapseBlocks(blocks) expect(result[0]).toHaveProperty('isCollapsed', true) - expect((result[0] as any).blocks[0]).toHaveProperty('isCollapsed', true) + expect((result[0] as AgentContentBlock).blocks![0]).toHaveProperty('isCollapsed', true) }) test('collapses tool blocks', () => { @@ -366,6 +376,23 @@ describe('extractSpawnAgentResultContent', () => { hasError: false, }) }) + + test('uses an empty structuredOutput message as no display content', () => { + const result = extractSpawnAgentResultContent({ + type: 'structuredOutput', + value: { + message: '', + results: [ + { + stdout: 'Found 1 match\n./file.ts:\nLine 1: needle', + message: 'Exit code: 0', + }, + ], + }, + }) + + expect(result).toEqual({ content: '', hasError: false }) + }) }) describe('appendInterruptionNotice', () => { @@ -388,7 +415,7 @@ describe('appendInterruptionNotice', () => { status: 'running', thinkingId: 'think-1', userOpened: true, - isCollapsed: true, + thinkingCollapseState: 'hidden', }, ] const result = appendInterruptionNotice(blocks) @@ -397,7 +424,7 @@ describe('appendInterruptionNotice', () => { status: 'running', thinkingId: 'think-1', userOpened: true, - isCollapsed: true, + thinkingCollapseState: 'hidden', content: 'Hello\n\n[response interrupted]', }) }) @@ -510,7 +537,7 @@ describe('updateBlocksRecursively', () => { ...block, status: 'complete' as const, })) - expect((result[0] as any).status).toBe('complete') + expect((result[0] as AgentContentBlock).status).toBe('complete') }) test('updates nested block', () => { @@ -541,7 +568,7 @@ describe('updateBlocksRecursively', () => { ...block, status: 'complete' as const, })) - expect((result[0] as any).blocks[0].status).toBe('complete') + expect((result[0] as AgentContentBlock).blocks![0]).toMatchObject({ status: 'complete' }) }) test('returns original array if target not found', () => { @@ -593,7 +620,10 @@ describe('updateBlocksRecursively', () => { ...block, content: 'updated', })) - expect((result[0] as any).blocks[0].blocks[0].content).toBe('updated') + const level1 = result[0] as AgentContentBlock + const level2 = level1.blocks![0] as AgentContentBlock + const level3 = level2.blocks![0] as AgentContentBlock + expect(level3.content).toBe('updated') }) }) @@ -618,8 +648,8 @@ describe('nestBlockUnderParent', () => { childBlock, ) expect(parentFound).toBe(true) - expect((result[0] as any).blocks).toHaveLength(1) - expect((result[0] as any).blocks[0]).toEqual(childBlock) + expect((result[0] as AgentContentBlock).blocks).toHaveLength(1) + expect((result[0] as AgentContentBlock).blocks![0]).toEqual(childBlock) }) test('returns parentFound false when parent not found', () => { @@ -654,8 +684,8 @@ describe('nestBlockUnderParent', () => { childBlock, ) expect(parentFound).toBe(true) - expect((result[0] as any).blocks).toHaveLength(2) - expect((result[0] as any).blocks[1]).toEqual(childBlock) + expect((result[0] as AgentContentBlock).blocks).toHaveLength(2) + expect((result[0] as AgentContentBlock).blocks![1]).toEqual(childBlock) }) test('nests under deeply nested parent', () => { @@ -689,8 +719,10 @@ describe('nestBlockUnderParent', () => { childBlock, ) expect(parentFound).toBe(true) - expect((result[0] as any).blocks[0].blocks).toHaveLength(1) - expect((result[0] as any).blocks[0].blocks[0]).toEqual(childBlock) + const grandparent = result[0] as AgentContentBlock + const parent = grandparent.blocks![0] as AgentContentBlock + expect(parent.blocks).toHaveLength(1) + expect(parent.blocks![0]).toEqual(childBlock) }) }) @@ -709,7 +741,7 @@ describe('moveSpawnAgentBlock', () => { }, ] const result = moveSpawnAgentBlock(blocks, 'temp', 'real') - expect((result[0] as any).agentId).toBe('real') + expect((result[0] as AgentContentBlock).agentId).toBe('real') }) test('nests extracted block under parent when found', () => { @@ -737,9 +769,9 @@ describe('moveSpawnAgentBlock', () => { }, ] const result = moveSpawnAgentBlock(blocks, 'temp', 'real', 'parent') - const parent = result[0] as any + const parent = result[0] as AgentContentBlock expect(parent.blocks).toHaveLength(1) - expect(parent.blocks[0].agentId).toBe('real') + expect((parent.blocks![0] as AgentContentBlock).agentId).toBe('real') }) test('updates in place when parent missing to preserve order', () => { @@ -831,7 +863,7 @@ describe('extractBlockById', () => { expect(remainingBlocks).toHaveLength(1) expect(remainingBlocks[0].type).toBe('text') expect(extractedBlock).not.toBeNull() - expect((extractedBlock as any).agentId).toBe('extract-me') + expect((extractedBlock as AgentContentBlock).agentId).toBe('extract-me') }) test('returns null when block not found', () => { @@ -872,9 +904,9 @@ describe('extractBlockById', () => { blocks, 'nested-child', ) - expect((remainingBlocks[0] as any).blocks).toHaveLength(0) + expect((remainingBlocks[0] as AgentContentBlock).blocks).toHaveLength(0) expect(extractedBlock).not.toBeNull() - expect((extractedBlock as any).agentId).toBe('nested-child') + expect((extractedBlock as AgentContentBlock).agentId).toBe('nested-child') }) test('handles empty blocks array', () => { @@ -913,9 +945,10 @@ describe('extractBlockById', () => { blocks, 'extract-me', ) - expect((remainingBlocks[0] as any).blocks).toHaveLength(2) - expect((remainingBlocks[0] as any).blocks[0].content).toBe('Keep this') - expect((remainingBlocks[0] as any).blocks[1].content).toBe('Keep this too') + const parentBlock = remainingBlocks[0] as AgentContentBlock + expect(parentBlock.blocks).toHaveLength(2) + expect((parentBlock.blocks![0] as TextContentBlock).content).toBe('Keep this') + expect((parentBlock.blocks![1] as TextContentBlock).content).toBe('Keep this too') expect(extractedBlock).not.toBeNull() }) }) @@ -927,17 +960,18 @@ describe('transformAskUserBlocks', () => { type: 'tool', toolCallId: 'tool-123', toolName: 'ask_user', - input: { questions: [{ question: 'Pick one', options: ['A', 'B'] }] }, + input: { questions: [{ question: 'Pick one', options: [{ label: 'A' }, { label: 'B' }] }] }, }, ] const result = transformAskUserBlocks(blocks, { toolCallId: 'tool-123', - resultValue: { answers: ['A'] }, + resultValue: { answers: [{ questionIndex: 0, selectedOption: 'A' }] }, }) expect(result[0].type).toBe('ask-user') - expect((result[0] as any).answers).toEqual(['A']) - expect((result[0] as any).questions).toEqual([ - { question: 'Pick one', options: ['A', 'B'] }, + const askUserBlock = result[0] as AskUserContentBlock + expect(askUserBlock.answers).toEqual([{ questionIndex: 0, selectedOption: 'A' }]) + expect(askUserBlock.questions).toEqual([ + { question: 'Pick one', options: [{ label: 'A' }, { label: 'B' }] }, ]) }) @@ -947,7 +981,7 @@ describe('transformAskUserBlocks', () => { type: 'tool', toolCallId: 'tool-123', toolName: 'ask_user', - input: { questions: [{ question: 'Pick one', options: ['A', 'B'] }] }, + input: { questions: [{ question: 'Pick one', options: [{ label: 'A' }, { label: 'B' }] }] }, }, ] const result = transformAskUserBlocks(blocks, { @@ -955,7 +989,7 @@ describe('transformAskUserBlocks', () => { resultValue: { skipped: true }, }) expect(result[0].type).toBe('ask-user') - expect((result[0] as any).skipped).toBe(true) + expect((result[0] as AskUserContentBlock).skipped).toBe(true) }) test('keeps tool block when no result data', () => { @@ -985,7 +1019,7 @@ describe('transformAskUserBlocks', () => { ] const result = transformAskUserBlocks(blocks, { toolCallId: 'different-id', - resultValue: { answers: ['A'] }, + resultValue: { answers: [{ questionIndex: 0, selectedOption: 'A' }] }, }) expect(result[0].type).toBe('tool') }) @@ -1014,14 +1048,14 @@ describe('transformAskUserBlocks', () => { toolCallId: 'tool-123', resultValue: { answers: ['Yes'] }, }) - expect((result[0] as any).blocks[0].type).toBe('ask-user') + expect((result[0] as AgentContentBlock).blocks![0].type).toBe('ask-user') }) test('returns same reference when nothing changes', () => { const blocks: ContentBlock[] = [{ type: 'text', content: 'Hello' }] const result = transformAskUserBlocks(blocks, { toolCallId: 'tool-123', - resultValue: { answers: ['A'] }, + resultValue: { answers: [{ questionIndex: 0, selectedOption: 'A' }] }, }) expect(result[0]).toBe(blocks[0]) }) @@ -1041,7 +1075,7 @@ describe('updateToolBlockWithOutput', () => { toolCallId: 'tool-123', toolOutput: [{ type: 'text', value: 'file contents' }], }) - expect((result[0] as any).output).toBeDefined() + expect((result[0] as ToolContentBlock).output).toBeDefined() }) test('formats terminal command output specially', () => { @@ -1057,7 +1091,7 @@ describe('updateToolBlockWithOutput', () => { toolCallId: 'tool-123', toolOutput: [{ value: { stdout: 'hi\n', stderr: '' } }], }) - expect((result[0] as any).output).toBe('hi\n') + expect((result[0] as ToolContentBlock).output).toBe('hi\n') }) test('combines stdout and stderr for terminal commands', () => { @@ -1073,7 +1107,7 @@ describe('updateToolBlockWithOutput', () => { toolCallId: 'tool-123', toolOutput: [{ value: { stdout: 'out', stderr: 'err' } }], }) - expect((result[0] as any).output).toBe('outerr') + expect((result[0] as ToolContentBlock).output).toBe('outerr') }) test('does not update non-matching tool block', () => { @@ -1089,7 +1123,7 @@ describe('updateToolBlockWithOutput', () => { toolCallId: 'different-id', toolOutput: [{ value: 'output' }], }) - expect((result[0] as any).output).toBeUndefined() + expect((result[0] as ToolContentBlock).output).toBeUndefined() }) test('updates nested tool blocks in agent', () => { @@ -1116,7 +1150,7 @@ describe('updateToolBlockWithOutput', () => { toolCallId: 'tool-123', toolOutput: [{ type: 'text', value: 'contents' }], }) - expect((result[0] as any).blocks[0].output).toBeDefined() + expect(((result[0] as AgentContentBlock).blocks![0] as ToolContentBlock).output).toBeDefined() }) test('returns same reference for unchanged nested blocks', () => { diff --git a/cli/src/utils/__tests__/message-updater.test.ts b/cli/src/utils/__tests__/message-updater.test.ts index 1c46c5e675..5670ea1c8d 100644 --- a/cli/src/utils/__tests__/message-updater.test.ts +++ b/cli/src/utils/__tests__/message-updater.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test' +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' import { createMessageUpdater, @@ -6,7 +6,13 @@ import { DEFAULT_FLUSH_INTERVAL_MS, } from '../message-updater' -import type { ChatMessage, ContentBlock } from '../../types/chat' +import type { ChatMessage, ContentBlock, TextContentBlock } from '../../types/chat' + +// Type for metadata with runState for testing +interface TestMessageMetadata { + bashCwd?: string + runState?: { id: string } +} const baseMessages: ChatMessage[] = [ { @@ -50,15 +56,15 @@ describe('createMessageUpdater', () => { expect(state[0].blocks?.[0]).toEqual(block) expect(state[0].isComplete).toBe(true) - expect((state[0].metadata as any).runState).toEqual({ id: 'run-1' }) + expect((state[0].metadata as TestMessageMetadata).runState).toEqual({ id: 'run-1' }) }) - test('setError preserves blocks and marks complete', () => { + test('setError preserves content and blocks, sets userError, and marks complete', () => { let state: ChatMessage[] = [ { id: 'ai-1', variant: 'ai', - content: '', + content: 'original content', blocks: [{ type: 'text', content: 'existing block' }], timestamp: 'now', }, @@ -70,10 +76,53 @@ describe('createMessageUpdater', () => { updater.setError('boom') - expect(state[0].content).toBe('boom') + // setError stores error in userError field, preserving content + expect(state[0].content).toBe('original content') + expect(state[0].userError).toBe('boom') expect(state[0].isComplete).toBe(true) expect(state[0].blocks).toHaveLength(1) - expect((state[0].blocks![0] as any).content).toBe('existing block') + expect((state[0].blocks![0] as TextContentBlock).content).toBe('existing block') + }) + + test('clearUserError removes userError field from message', () => { + let state: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'original content', + userError: 'previous error', + timestamp: 'now', + }, + ] + + const updater = createMessageUpdater('ai-1', (fn) => { + state = fn(state) + }) + + updater.clearUserError() + + expect(state[0].content).toBe('original content') + expect(state[0].userError).toBeUndefined() + }) + + test('clearUserError is a no-op if no userError exists', () => { + let state: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'original content', + timestamp: 'now', + }, + ] + + const updater = createMessageUpdater('ai-1', (fn) => { + state = fn(state) + }) + + updater.clearUserError() + + expect(state[0].content).toBe('original content') + expect(state[0].userError).toBeUndefined() }) }) @@ -132,8 +181,8 @@ describe('createBatchedMessageUpdater', () => { expect(setMessagesCallCount).toBe(1) expect(state[0].content).toBe('first') expect(state[0].blocks).toHaveLength(2) - expect((state[0].blocks![0] as any).content).toBe('block1') - expect((state[0].blocks![1] as any).content).toBe('block2') + expect((state[0].blocks![0] as TextContentBlock).content).toBe('block1') + expect((state[0].blocks![1] as TextContentBlock).content).toBe('block2') updater.dispose() }) @@ -164,12 +213,12 @@ describe('createBatchedMessageUpdater', () => { expect(state[0].credits).toBe(0.5) }) - test('setError discards pending updates but preserves existing blocks', () => { + test('setError flushes pending updates and preserves existing content and blocks', () => { let state: ChatMessage[] = [ { id: 'ai-1', variant: 'ai', - content: '', + content: 'original content', blocks: [{ type: 'text', content: 'existing block' }], timestamp: 'now', }, @@ -185,18 +234,21 @@ describe('createBatchedMessageUpdater', () => { 1000, ) - // Queue an update (will be discarded by error) + // Queue an update that should be flushed before applying the error updater.addBlock({ type: 'text', content: 'pending block' }) updater.setError('something went wrong') - // Should have 1 call: setError (pending updates discarded, not flushed) - expect(setMessagesCallCount).toBe(1) - expect(state[0].content).toBe('something went wrong') + // Should have 2 calls: flush + setError + expect(setMessagesCallCount).toBe(2) + // setError stores error in userError field, preserving content + expect(state[0].content).toBe('original content') + expect(state[0].userError).toBe('something went wrong') expect(state[0].isComplete).toBe(true) - // Existing blocks are preserved, but pending block was discarded - expect(state[0].blocks).toHaveLength(1) - expect((state[0].blocks![0] as any).content).toBe('existing block') + // Existing blocks are preserved and pending block was flushed + expect(state[0].blocks).toHaveLength(2) + expect((state[0].blocks![0] as TextContentBlock).content).toBe('existing block') + expect((state[0].blocks![1] as TextContentBlock).content).toBe('pending block') }) test('updates after dispose are applied immediately', () => { @@ -312,7 +364,7 @@ describe('createBatchedMessageUpdater', () => { // Both existing and new metadata should be present expect(state[0].metadata?.bashCwd).toBe('/existing/path') - expect(state[0].metadata?.runState).toEqual({ id: 'run-123' }) + expect((state[0].metadata as TestMessageMetadata)?.runState).toEqual({ id: 'run-123' }) expect(state[0].isComplete).toBe(true) }) @@ -506,6 +558,74 @@ describe('createBatchedMessageUpdater timer behavior', () => { expect(clearedIntervals).toContain(intervalId) }) + test('clearUserError applies immediately (bypasses batch queue)', () => { + let state: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'content', + userError: 'previous error', + timestamp: 'now', + }, + ] + let setMessagesCallCount = 0 + + const updater = createBatchedMessageUpdater( + 'ai-1', + (fn) => { + setMessagesCallCount++ + state = fn(state) + }, + 1000, // Long interval so it won't auto-flush + ) + + // Queue an update (should NOT be applied yet) + updater.updateAiMessage((msg) => ({ ...msg, content: 'updated' })) + expect(setMessagesCallCount).toBe(0) + expect(state[0].content).toBe('content') + + // clearUserError should apply immediately + updater.clearUserError() + + // Should have 1 call from clearUserError (applied immediately) + expect(setMessagesCallCount).toBe(1) + expect(state[0].userError).toBeUndefined() + // Content should still be 'content' since the queued update wasn't flushed + expect(state[0].content).toBe('content') + + updater.dispose() + }) + + test('clearUserError is a no-op if no userError exists', () => { + let state: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'content', + timestamp: 'now', + }, + ] + let setMessagesCallCount = 0 + + const updater = createBatchedMessageUpdater( + 'ai-1', + (fn) => { + setMessagesCallCount++ + state = fn(state) + }, + 1000, + ) + + updater.clearUserError() + + // Should have 1 call but message unchanged + expect(setMessagesCallCount).toBe(1) + expect(state[0].userError).toBeUndefined() + expect(state[0].content).toBe('content') + + updater.dispose() + }) + test('no stray timers after all termination methods', () => { // Test that each termination method properly cleans up const updater1 = createBatchedMessageUpdater('ai-1', () => {}, 100) diff --git a/cli/src/utils/__tests__/pending-attachments.test.ts b/cli/src/utils/__tests__/pending-attachments.test.ts index 2cc6d0f6d7..9725ec031d 100644 --- a/cli/src/utils/__tests__/pending-attachments.test.ts +++ b/cli/src/utils/__tests__/pending-attachments.test.ts @@ -1,9 +1,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test' -import { - useChatStore, - type PendingImageAttachment, -} from '../../state/chat-store' +import { useChatStore } from '../../state/chat-store' +import type { PendingImageAttachment } from '../../types/store' import { addClipboardPlaceholder, addPendingImageFromBase64, diff --git a/cli/src/utils/__tests__/run-state-storage.test.ts b/cli/src/utils/__tests__/run-state-storage.test.ts new file mode 100644 index 0000000000..c3237d73f2 --- /dev/null +++ b/cli/src/utils/__tests__/run-state-storage.test.ts @@ -0,0 +1,366 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' + +import { + getAllToggleIdsFromMessages, + getRunStatePath, + getChatMessagesPath, + saveChatState, + loadMostRecentChatState, + clearChatState, +} from '../run-state-storage' +import type { ChatMessage, ContentBlock } from '../../types/chat' +import type { RunState } from '@codebuff/sdk' + +// Mock the project-files module +const mockProjectDataDir = path.join(os.tmpdir(), 'codebuff-test-project') +const mockCurrentChatDir = path.join(mockProjectDataDir, 'chats', 'test-chat-123') + +// Mock the module before importing +const originalGetProjectDataDir = () => mockProjectDataDir +const originalGetCurrentChatDir = () => mockCurrentChatDir + +describe('run-state-storage', () => { + beforeEach(() => { + // Create test directories + if (fs.existsSync(mockProjectDataDir)) { + fs.rmSync(mockProjectDataDir, { recursive: true }) + } + fs.mkdirSync(mockCurrentChatDir, { recursive: true }) + }) + + afterEach(() => { + // Clean up test directories + if (fs.existsSync(mockProjectDataDir)) { + fs.rmSync(mockProjectDataDir, { recursive: true }) + } + }) + + describe('getAllToggleIdsFromMessages', () => { + test('extracts agent IDs from messages', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'agent', + content: '', + timestamp: new Date().toISOString(), + blocks: [ + { type: 'agent', agentId: 'agent-1', agentName: 'TestAgent', agentType: 'inline', content: '', status: 'complete', blocks: [] }, + ], + }, + ] + + const ids = getAllToggleIdsFromMessages(messages) + + expect(ids).toContain('agent-1') + }) + + test('extracts tool call IDs from messages', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'agent', + content: '', + timestamp: new Date().toISOString(), + blocks: [ + { type: 'tool', toolCallId: 'tool-1', toolName: 'glob', input: {}, output: '' }, + ], + }, + ] + + const ids = getAllToggleIdsFromMessages(messages) + + expect(ids).toContain('tool-1') + }) + + test('recursively extracts IDs from nested agent blocks', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'agent', + content: '', + timestamp: new Date().toISOString(), + blocks: [ + { + type: 'agent', + agentId: 'parent-agent', + agentName: 'ParentAgent', + agentType: 'inline', + content: '', + status: 'complete', + blocks: [ + { type: 'tool', toolCallId: 'nested-tool', toolName: 'glob', input: {}, output: '' }, + { + type: 'agent', + agentId: 'child-agent', + agentName: 'ChildAgent', + agentType: 'inline', + content: '', + status: 'complete', + blocks: [ + { type: 'tool', toolCallId: 'deep-tool', toolName: 'glob', input: {}, output: '' }, + ], + }, + ], + }, + ], + }, + ] + + const ids = getAllToggleIdsFromMessages(messages) + + expect(ids).toContain('parent-agent') + expect(ids).toContain('nested-tool') + expect(ids).toContain('child-agent') + expect(ids).toContain('deep-tool') + }) + + test('handles messages with no blocks', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'user', + content: '', + timestamp: new Date().toISOString(), + blocks: [], + }, + ] + + const ids = getAllToggleIdsFromMessages(messages) + + expect(ids).toHaveLength(0) + }) + + test('handles empty messages array', () => { + const ids = getAllToggleIdsFromMessages([]) + expect(ids).toHaveLength(0) + }) + + test('handles mixed block types in single message', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'agent', + content: '', + timestamp: new Date().toISOString(), + blocks: [ + { type: 'text', content: 'Some text' }, + { type: 'agent', agentId: 'agent-1', agentName: 'TestAgent', agentType: 'inline', content: '', status: 'complete', blocks: [] }, + { type: 'tool', toolCallId: 'tool-1', toolName: 'glob', input: {}, output: '' }, + ], + }, + ] + + const ids = getAllToggleIdsFromMessages(messages) + + expect(ids).toContain('agent-1') + expect(ids).toContain('tool-1') + expect(ids).toHaveLength(2) + }) + + test('does not deduplicate IDs (returns all occurrences)', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'agent', + content: '', + timestamp: new Date().toISOString(), + blocks: [ + { type: 'agent', agentId: 'shared-id', agentName: 'TestAgent', agentType: 'inline', content: '', status: 'complete', blocks: [] }, + ], + }, + { + id: 'msg-2', + variant: 'agent', + content: '', + timestamp: new Date().toISOString(), + blocks: [ + { type: 'tool', toolCallId: 'shared-id', toolName: 'glob', input: {}, output: '' }, + ], + }, + ] + + const ids = getAllToggleIdsFromMessages(messages) + + // Current implementation returns all occurrences without deduplication + expect(ids.filter(id => id === 'shared-id')).toHaveLength(2) + }) + }) + + describe('getRunStatePath', () => { + test('returns path with correct filename', () => { + // We need to mock the internal functions + // This is a simplified test - in reality we'd need to mock the module + const testPath = path.join(mockCurrentChatDir, 'run-state.json') + expect(testPath).toContain('run-state.json') + }) + }) + + describe('getChatMessagesPath', () => { + test('returns path with correct filename', () => { + const testPath = path.join(mockCurrentChatDir, 'chat-messages.json') + expect(testPath).toContain('chat-messages.json') + }) + }) + + describe('file serialization format', () => { + test('run state JSON structure is preserved through serialization', () => { + const runState: RunState = { + output: { + type: 'error', + message: 'Test output', + }, + } as unknown as RunState + + const runStatePath = path.join(mockCurrentChatDir, 'run-state.json') + fs.writeFileSync(runStatePath, JSON.stringify(runState, null, 2)) + + const savedRunState = JSON.parse(fs.readFileSync(runStatePath, 'utf8')) + expect(savedRunState.output.type).toBe('error') + expect(savedRunState.output.message).toBe('Test output') + }) + + test('messages JSON structure is preserved through serialization', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'user', + content: 'Hello', + timestamp: new Date().toISOString(), + blocks: [{ type: 'text', content: 'Hello' }], + }, + ] + + const messagesPath = path.join(mockCurrentChatDir, 'chat-messages.json') + fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2)) + + const savedMessages = JSON.parse(fs.readFileSync(messagesPath, 'utf8')) + expect(savedMessages).toHaveLength(1) + expect(savedMessages[0].variant).toBe('user') + }) + + test('nested message structure is preserved through serialization', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'agent', + content: '', + timestamp: new Date().toISOString(), + blocks: [ + { + type: 'agent', + agentId: 'nested-agent', + agentName: 'NestedAgent', + agentType: 'inline', + content: '', + status: 'complete', + blocks: [ + { type: 'text', content: 'Nested content' }, + { type: 'tool', toolCallId: 'tool-xyz', toolName: 'glob', input: {}, output: '' }, + ], + }, + ], + }, + ] + + const messagesPath = path.join(mockCurrentChatDir, 'chat-messages.json') + fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2)) + + const savedMessages = JSON.parse(fs.readFileSync(messagesPath, 'utf8')) + expect(savedMessages[0].blocks[0].type).toBe('agent') + expect(savedMessages[0].blocks[0].blocks).toHaveLength(2) + }) + }) + + describe('edge cases', () => { + test('handles empty blocks array', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'agent', + content: '', + timestamp: new Date().toISOString(), + blocks: [], + }, + ] + + const ids = getAllToggleIdsFromMessages(messages) + expect(ids).toHaveLength(0) + }) + + test('handles deeply nested structure', () => { + const deepBlock: ContentBlock = { + type: 'agent', + agentId: 'level-0', + agentName: 'Level0Agent', + agentType: 'inline', + content: '', + status: 'complete', + blocks: [ + { + type: 'agent', + agentId: 'level-1', + agentName: 'Level1Agent', + agentType: 'inline', + content: '', + status: 'complete', + blocks: [ + { + type: 'agent', + agentId: 'level-2', + agentName: 'Level2Agent', + agentType: 'inline', + content: '', + status: 'complete', + blocks: [ + { type: 'tool', toolCallId: 'deep-tool', toolName: 'glob', input: {}, output: '' }, + ], + }, + ], + }, + ], + } + + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'agent', + content: '', + timestamp: new Date().toISOString(), + blocks: [deepBlock], + }, + ] + + const ids = getAllToggleIdsFromMessages(messages) + + expect(ids).toContain('level-0') + expect(ids).toContain('level-1') + expect(ids).toContain('level-2') + expect(ids).toContain('deep-tool') + }) + + test('preserves order of IDs as encountered', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + variant: 'agent', + content: '', + timestamp: new Date().toISOString(), + blocks: [ + { type: 'agent', agentId: 'first', agentName: 'FirstAgent', agentType: 'inline', content: '', status: 'complete', blocks: [] }, + { type: 'tool', toolCallId: 'second', toolName: 'glob', input: {}, output: '' }, + { type: 'agent', agentId: 'third', agentName: 'ThirdAgent', agentType: 'inline', content: '', status: 'complete', blocks: [] }, + ], + }, + ] + + const ids = getAllToggleIdsFromMessages(messages) + + expect(ids[0]).toBe('first') + expect(ids[1]).toBe('second') + expect(ids[2]).toBe('third') + }) + }) +}) diff --git a/cli/src/utils/__tests__/sdk-event-handlers.test.ts b/cli/src/utils/__tests__/sdk-event-handlers.test.ts index 3248d7d2c0..d5a6ecfbf3 100644 --- a/cli/src/utils/__tests__/sdk-event-handlers.test.ts +++ b/cli/src/utils/__tests__/sdk-event-handlers.test.ts @@ -8,9 +8,41 @@ import { } from '../sdk-event-handlers' import type { StreamStatus } from '../../hooks/use-message-queue' -import type { ChatMessage } from '../../types/chat' +import type { AgentContentBlock, ChatMessage } from '../../types/chat' import type { AgentMode } from '../constants' import type { EventHandlerState } from '../sdk-event-handlers' +import type { Logger } from '@codebuff/common/types/contracts/logger' + +// Type for spawn agent info stored in the map +interface SpawnAgentInfo { + index: number + agentType: string +} + +// SDK event types for testing +interface SubagentStartEvent { + type: 'subagent_start' + agentId: string + agentType: string + displayName: string + onlyChild: boolean + parentAgentId: string | undefined + params: Record | undefined + prompt: string | undefined +} + +interface ToolResultEvent { + type: 'tool_result' + toolCallId: string + toolName: string + output: Array<{ + type: 'json' + value: Array<{ + agentName: string + value: any + }> + }> +} const createStreamRefs = (): { controller: EventHandlerState['streaming']['streamRefs'] @@ -20,7 +52,7 @@ const createStreamRefs = (): { rootStreamSeen: boolean planExtracted: boolean wasAbortedByUser: boolean - spawnAgentsMap: Map + spawnAgentsMap: Map } } => { const state = { @@ -29,7 +61,7 @@ const createStreamRefs = (): { rootStreamSeen: false, planExtracted: false, wasAbortedByUser: false, - spawnAgentsMap: new Map(), + spawnAgentsMap: new Map(), } const controller = { @@ -57,7 +89,7 @@ const createStreamRefs = (): { setWasAbortedByUser: (value: boolean) => { state.wasAbortedByUser = value }, - setSpawnAgentInfo: (agentId: string, info: any) => { + setSpawnAgentInfo: (agentId: string, info: SpawnAgentInfo) => { state.spawnAgentsMap.set(agentId, info) }, removeSpawnAgentInfo: (agentId: string) => { @@ -121,7 +153,7 @@ const createTestContext = (agentMode: AgentMode = 'DEFAULT') => { warn: () => {}, error: () => {}, debug: () => {}, - } as any, + } as Logger, setIsRetrying: () => {}, } @@ -162,7 +194,7 @@ describe('sdk-event-handlers', () => { }) const handleEvent = createEventHandler(ctx) - handleEvent({ + const startEvent: SubagentStartEvent = { type: 'subagent_start', agentId: 'agent-real', agentType: 'codebuff/file-picker@1.0.0', @@ -171,14 +203,132 @@ describe('sdk-event-handlers', () => { parentAgentId: undefined, params: undefined, prompt: undefined, - } as any) + } + handleEvent(startEvent) - const agentBlock = (getMessages()[0].blocks ?? [])[0] - expect((agentBlock as any).agentId).toBe('agent-real') + const agentBlock = (getMessages()[0].blocks ?? [])[0] as AgentContentBlock + expect(agentBlock.agentId).toBe('agent-real') expect(getStreamingAgents().has('agent-real')).toBe(true) expect(getStreamingAgents().has('tool-1-0')).toBe(false) }) + test('matches underscore direct-tool aliases to hyphenated agent ids', () => { + const { ctx, getMessages, getStreamingAgents } = createTestContext() + const handleEvent = createEventHandler(ctx) + const handleChunk = createStreamChunkHandler(ctx) + + handleEvent({ + type: 'tool_call', + toolCallId: 'tool-1', + toolName: 'spawn_agents', + input: { + agents: [ + { + agent_type: 'code_reviewer_lite', + prompt: 'Review this change', + }, + ], + }, + agentId: 'main-agent', + parentAgentId: undefined, + } as any) + + handleEvent({ + type: 'subagent_start', + agentId: 'agent-real', + agentType: 'code-reviewer-lite', + displayName: 'Code Reviewer Lite', + onlyChild: true, + parentAgentId: undefined, + params: undefined, + prompt: 'Review this change', + }) + + handleChunk({ + type: 'subagent_chunk', + agentId: 'agent-real', + agentType: 'code-reviewer-lite', + chunk: 'streamed review', + }) + + handleEvent({ + type: 'subagent_finish', + agentId: 'agent-real', + agentType: 'code-reviewer-lite', + displayName: 'Code Reviewer Lite', + onlyChild: true, + parentAgentId: undefined, + params: undefined, + prompt: 'Review this change', + }) + + handleEvent({ + type: 'tool_result', + toolCallId: 'tool-1', + toolName: 'spawn_agents', + output: [ + { + type: 'json', + value: [ + { + agentName: 'code-reviewer-lite', + agentType: 'code-reviewer-lite', + value: 'streamed review', + }, + ], + }, + ], + } as any) + + const blocks = getMessages()[0].blocks ?? [] + expect(blocks).toHaveLength(1) + const agentBlock = blocks[0] as AgentContentBlock + expect(agentBlock.agentId).toBe('agent-real') + expect(agentBlock.agentName).toBe('code-reviewer-lite') + expect(agentBlock.agentType).toBe('code-reviewer-lite') + expect(agentBlock.status).toBe('complete') + expect(agentBlock.blocks).toHaveLength(1) + expect(agentBlock.blocks?.[0]).toMatchObject({ + type: 'text', + content: 'streamed review', + }) + expect(getStreamingAgents().size).toBe(0) + }) + + test('preserves spawn_agents params on placeholder agent blocks', () => { + const { ctx, getMessages, getStreamingAgents } = createTestContext() + const handleEvent = createEventHandler(ctx) + + handleEvent({ + type: 'tool_call', + toolCallId: 'tool-1', + toolName: 'spawn_agents', + input: { + agents: [ + { + agent_type: 'basher', + params: { + command: 'git status --short', + what_to_summarize: 'Report whether the worktree is clean', + }, + }, + ], + }, + agentId: 'main-agent', + parentAgentId: undefined, + } as any) + + const agentBlock = (getMessages()[0].blocks ?? [])[0] as AgentContentBlock + expect(agentBlock.agentId).toBe('tool-1-0') + expect(agentBlock.agentType).toBe('basher') + expect(agentBlock.initialPrompt).toBe('') + expect(agentBlock.params).toEqual({ + command: 'git status --short', + what_to_summarize: 'Report whether the worktree is clean', + }) + expect(getStreamingAgents().has('tool-1-0')).toBe(true) + }) + test('handles spawn_agents tool results and clears streaming agents', () => { const { ctx, getMessages, getStreamingAgents } = createTestContext() ctx.message.updater.addBlock( @@ -192,12 +342,13 @@ describe('sdk-event-handlers', () => { ctx.streaming.setStreamingAgents(() => new Set(['tool-1-0'])) const handleEvent = createEventHandler(ctx) - handleEvent({ + const toolResultEvent: ToolResultEvent = { type: 'tool_result', toolCallId: 'tool-1', toolName: 'spawn_agents', output: [ { + type: 'json', value: [ { agentName: 'child', @@ -206,14 +357,251 @@ describe('sdk-event-handlers', () => { ], }, ], - } as any) + } + handleEvent(toolResultEvent) - const agentBlock = (getMessages()[0].blocks ?? [])[0] - expect((agentBlock as any).status).toBe('complete') - expect((agentBlock as any).blocks?.[0]).toMatchObject({ + const agentBlock = (getMessages()[0].blocks ?? [])[0] as AgentContentBlock + expect(agentBlock.status).toBe('complete') + expect(agentBlock.blocks?.[0]).toMatchObject({ type: 'text', content: 'child result', }) expect(getStreamingAgents().size).toBe(0) }) + + test('hides spawn_agents error placeholders with no user-facing output', () => { + const { ctx, getMessages, getStreamingAgents } = createTestContext() + ctx.message.updater.addBlock( + createAgentBlock({ + agentId: 'tool-1-0', + agentType: 'basher', + spawnToolCallId: 'tool-1', + spawnIndex: 0, + }), + ) + ctx.streaming.setStreamingAgents(() => new Set(['tool-1-0'])) + + const handleEvent = createEventHandler(ctx) + const toolResultEvent: ToolResultEvent = { + type: 'tool_result', + toolCallId: 'tool-1', + toolName: 'spawn_agents', + output: [ + { + type: 'json', + value: [ + { + agentName: 'basher', + value: { + errorMessage: + 'Error spawning agent: Invalid params for agent basher', + }, + }, + ], + }, + ], + } + handleEvent(toolResultEvent) + + expect(getMessages()[0].blocks).toEqual([]) + expect(getStreamingAgents().size).toBe(0) + }) + + test('renders spawn_agents error content when agent already streamed output', () => { + const { ctx, getMessages, getStreamingAgents } = createTestContext() + ctx.message.updater.updateAiMessageBlocks(() => [ + { + type: 'agent', + agentId: 'tool-1-0', + agentName: 'Basher', + agentType: 'basher', + content: '', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Checking files...', + textType: 'text', + }, + ], + initialPrompt: '', + spawnToolCallId: 'tool-1', + spawnIndex: 0, + } as any, + ]) + ctx.streaming.setStreamingAgents(() => new Set(['tool-1-0'])) + + const handleEvent = createEventHandler(ctx) + const toolResultEvent: ToolResultEvent = { + type: 'tool_result', + toolCallId: 'tool-1', + toolName: 'spawn_agents', + output: [ + { + type: 'json', + value: [ + { + agentName: 'basher', + value: { + errorMessage: + 'Error spawning agent: Invalid params for agent basher', + }, + }, + ], + }, + ], + } + handleEvent(toolResultEvent) + + const agentBlock = (getMessages()[0].blocks ?? [])[0] as AgentContentBlock + expect(agentBlock.status).toBe('complete') + expect(agentBlock.blocks).toHaveLength(2) + expect(agentBlock.blocks?.[0]).toMatchObject({ + type: 'text', + content: 'Checking files...', + }) + expect(agentBlock.blocks?.[1]).toMatchObject({ + type: 'text', + content: 'Error spawning agent: Invalid params for agent basher', + }) + expect(getStreamingAgents().size).toBe(0) + }) + + test('handles spawn_agents tool results for agents with tool blocks (lastMessage mode)', () => { + const { ctx, getMessages, getStreamingAgents } = createTestContext() + + // Create an agent block with an existing tool block (simulating thinker agent's read_files) + ctx.message.updater.updateAiMessageBlocks(() => [ + { + type: 'agent', + agentId: 'tool-1-0', + agentName: 'Thinker', + agentType: 'thinker-with-files-gemini', + content: '', + status: 'running', + blocks: [ + { + type: 'tool', + toolCallId: 'read-1', + toolName: 'read_files', + input: { paths: ['package.json'] }, + output: 'package contents', + }, + ], + initialPrompt: 'Think about this', + spawnToolCallId: 'tool-1', + spawnIndex: 0, + } as any, + ]) + ctx.streaming.setStreamingAgents(() => new Set(['tool-1-0'])) + + const handleEvent = createEventHandler(ctx) + const toolResultEvent: ToolResultEvent = { + type: 'tool_result', + toolCallId: 'tool-1', + toolName: 'spawn_agents', + output: [ + { + type: 'json', + value: [ + { + agentName: 'thinker-with-files-gemini', + value: { + type: 'lastMessage', + value: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Here is the analysis result.' }, + ], + }, + ], + }, + }, + ], + }, + ], + } + handleEvent(toolResultEvent) + + const agentBlock = (getMessages()[0].blocks ?? [])[0] as AgentContentBlock + expect(agentBlock.status).toBe('complete') + // Should have the tool block AND the final text content + expect(agentBlock.blocks).toHaveLength(2) + expect(agentBlock.blocks?.[0]).toMatchObject({ + type: 'tool', + toolName: 'read_files', + }) + expect(agentBlock.blocks?.[1]).toMatchObject({ + type: 'text', + content: 'Here is the analysis result.', + }) + expect(getStreamingAgents().size).toBe(0) + }) + + test('preserves streamed text content and skips duplicate final content', () => { + const { ctx, getMessages, getStreamingAgents } = createTestContext() + + // Create an agent block with existing text blocks (simulating streamed output like basher) + ctx.message.updater.updateAiMessageBlocks(() => [ + { + type: 'agent', + agentId: 'tool-1-0', + agentName: 'Basher', + agentType: 'basher', + content: '', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Streamed output from basher', + textType: 'text', + }, + ], + initialPrompt: 'Run a command', + spawnToolCallId: 'tool-1', + spawnIndex: 0, + } as any, + ]) + ctx.streaming.setStreamingAgents(() => new Set(['tool-1-0'])) + + const handleEvent = createEventHandler(ctx) + const toolResultEvent: ToolResultEvent = { + type: 'tool_result', + toolCallId: 'tool-1', + toolName: 'spawn_agents', + output: [ + { + type: 'json', + value: [ + { + agentName: 'basher', + value: { + type: 'lastMessage', + value: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Streamed output from basher' }, + ], + }, + ], + }, + }, + ], + }, + ], + } + handleEvent(toolResultEvent) + + const agentBlock = (getMessages()[0].blocks ?? [])[0] as AgentContentBlock + expect(agentBlock.status).toBe('complete') + // Should NOT duplicate the streamed text — only the original text block + expect(agentBlock.blocks).toHaveLength(1) + expect(agentBlock.blocks?.[0]).toMatchObject({ + type: 'text', + content: 'Streamed output from basher', + }) + expect(getStreamingAgents().size).toBe(0) + }) }) diff --git a/cli/src/utils/__tests__/send-message-helpers.test.ts b/cli/src/utils/__tests__/send-message-helpers.test.ts index 6e86c9efcc..00f95b899f 100644 --- a/cli/src/utils/__tests__/send-message-helpers.test.ts +++ b/cli/src/utils/__tests__/send-message-helpers.test.ts @@ -1,6 +1,15 @@ import { describe, test, expect } from 'bun:test' -import { appendTextToRootStream } from '../block-operations' +import { + appendTextToRootStream, + appendTextToAgentBlock, + appendToolToAgentBlock, + isNativeReasoningBlock, + closeNativeReasoningBlock, + closeNativeReasoningInAgent, + markAgentComplete, + markRunningAgentsAsCancelled, +} from '../block-operations' import { updateBlocksRecursively, scrubPlanTags, @@ -29,7 +38,11 @@ import { import type { ContentBlock, AgentContentBlock, + AskUserContentBlock, ChatMessage, + ModeDividerContentBlock, + TextContentBlock, + ToolContentBlock, } from '../../types/chat' // ============================================================================ @@ -149,7 +162,7 @@ describe('scrubPlanTagsInBlocks', () => { ] const result = scrubPlanTagsInBlocks(blocks) - expect((result[0] as any).content).toBe('Hello World') + expect((result[0] as TextContentBlock).content).toBe('Hello World') }) test('filters out empty text blocks after scrubbing', () => { @@ -160,7 +173,7 @@ describe('scrubPlanTagsInBlocks', () => { const result = scrubPlanTagsInBlocks(blocks) expect(result).toHaveLength(1) - expect((result[0] as any).content).toBe('Keep this') + expect((result[0] as TextContentBlock).content).toBe('Keep this') }) test('preserves non-text blocks', () => { @@ -192,7 +205,7 @@ describe('createModeDividerMessage', () => { expect(message.content).toBe('') expect(message.blocks).toHaveLength(1) expect(message.blocks![0].type).toBe('mode-divider') - expect((message.blocks![0] as any).mode).toBe('MAX') + expect((message.blocks![0] as ModeDividerContentBlock).mode).toBe('MAX') expect(message.id).toMatch(/^divider-/) }) }) @@ -239,7 +252,7 @@ describe('autoCollapseBlocks', () => { ] const result = autoCollapseBlocks(blocks) - expect((result[0] as any).isCollapsed).toBe(true) + expect((result[0] as TextContentBlock).thinkingCollapseState).toBe('hidden') }) test('does not collapse user-opened blocks', () => { @@ -253,7 +266,7 @@ describe('autoCollapseBlocks', () => { ] const result = autoCollapseBlocks(blocks) - expect((result[0] as any).isCollapsed).toBeUndefined() + expect((result[0] as TextContentBlock).isCollapsed).toBeUndefined() }) test('collapses agent blocks', () => { @@ -269,7 +282,7 @@ describe('autoCollapseBlocks', () => { ] const result = autoCollapseBlocks(blocks) - expect((result[0] as any).isCollapsed).toBe(true) + expect((result[0] as AgentContentBlock).isCollapsed).toBe(true) }) test('collapses tool blocks', () => { @@ -283,7 +296,7 @@ describe('autoCollapseBlocks', () => { ] const result = autoCollapseBlocks(blocks) - expect((result[0] as any).isCollapsed).toBe(true) + expect((result[0] as ToolContentBlock).isCollapsed).toBe(true) }) test('recursively collapses nested agent blocks', () => { @@ -339,7 +352,7 @@ describe('autoCollapsePreviousMessages', () => { ] const result = autoCollapsePreviousMessages(messages, 'ai-123') - expect((result[0].blocks![0] as any).isCollapsed).toBeUndefined() + expect((result[0].blocks![0] as AgentContentBlock).isCollapsed).toBeUndefined() }) test('collapses previous messages', () => { @@ -370,7 +383,7 @@ describe('autoCollapsePreviousMessages', () => { ] const result = autoCollapsePreviousMessages(messages, 'ai-new') - expect((result[0].blocks![0] as any).isCollapsed).toBe(true) + expect((result[0].blocks![0] as AgentContentBlock).isCollapsed).toBe(true) }) test('respects user-opened agent messages', () => { @@ -399,7 +412,7 @@ describe('appendTextToRootStream', () => { expect(result).toHaveLength(1) expect(result[0].type).toBe('text') - expect((result[0] as any).content).toBe('Hello') + expect((result[0] as TextContentBlock).content).toBe('Hello') }) test('appends to existing text block of same type', () => { @@ -413,7 +426,7 @@ describe('appendTextToRootStream', () => { }) expect(result).toHaveLength(1) - expect((result[0] as any).content).toBe('Hello World') + expect((result[0] as TextContentBlock).content).toBe('Hello World') }) test('creates new block for different text type', () => { @@ -427,8 +440,8 @@ describe('appendTextToRootStream', () => { }) expect(result).toHaveLength(2) - expect((result[1] as any).textType).toBe('reasoning') - expect((result[1] as any).isCollapsed).toBe(true) + expect((result[1] as TextContentBlock).textType).toBe('reasoning') + expect((result[1] as TextContentBlock).thinkingCollapseState).toBe('preview') }) test('returns original blocks for empty text', () => { @@ -447,10 +460,10 @@ describe('appendTextToRootStream', () => { }) expect(result).toHaveLength(2) - expect((result[0] as any).content).toBe('Before ') - expect((result[1] as any).content).toBe('unclosed thoughts') - expect((result[1] as any).textType).toBe('reasoning') - expect((result[1] as any).thinkingOpen).toBe(true) + expect((result[0] as TextContentBlock).content).toBe('Before ') + expect((result[1] as TextContentBlock).content).toBe('unclosed thoughts') + expect((result[1] as TextContentBlock).textType).toBe('reasoning') + expect((result[1] as TextContentBlock).thinkingOpen).toBe(true) }) test('continues appending to open thinking block', () => { @@ -470,8 +483,8 @@ describe('appendTextToRootStream', () => { }) expect(result).toHaveLength(1) - expect((result[0] as any).content).toBe('initial thoughts more thoughts') - expect((result[0] as any).textType).toBe('reasoning') + expect((result[0] as TextContentBlock).content).toBe('initial thoughts more thoughts') + expect((result[0] as TextContentBlock).textType).toBe('reasoning') }) test('closes thinking block when close tag received', () => { @@ -491,11 +504,11 @@ describe('appendTextToRootStream', () => { }) expect(result).toHaveLength(2) - expect((result[0] as any).content).toBe('initial thoughts final') - expect((result[0] as any).textType).toBe('reasoning') - expect((result[0] as any).thinkingOpen).toBe(false) - expect((result[1] as any).content).toBe(' regular text') - expect((result[1] as any).textType).toBe('text') + expect((result[0] as TextContentBlock).content).toBe('initial thoughts final') + expect((result[0] as TextContentBlock).textType).toBe('reasoning') + expect((result[0] as TextContentBlock).thinkingOpen).toBe(false) + expect((result[1] as TextContentBlock).content).toBe(' regular text') + expect((result[1] as TextContentBlock).textType).toBe('text') }) test('text without think tags works normally', () => { @@ -505,8 +518,8 @@ describe('appendTextToRootStream', () => { }) expect(result).toHaveLength(1) - expect((result[0] as any).content).toBe('Just regular text without tags') - expect((result[0] as any).textType).toBe('text') + expect((result[0] as TextContentBlock).content).toBe('Just regular text without tags') + expect((result[0] as TextContentBlock).textType).toBe('text') }) test('closes thinking block when receiving just tag', () => { @@ -526,9 +539,9 @@ describe('appendTextToRootStream', () => { }) expect(result).toHaveLength(1) - expect((result[0] as any).content).toBe('thoughts') - expect((result[0] as any).textType).toBe('reasoning') - expect((result[0] as any).thinkingOpen).toBe(false) + expect((result[0] as TextContentBlock).content).toBe('thoughts') + expect((result[0] as TextContentBlock).textType).toBe('reasoning') + expect((result[0] as TextContentBlock).thinkingOpen).toBe(false) }) test('closes thinking block and adds text after ', () => { @@ -548,11 +561,11 @@ describe('appendTextToRootStream', () => { }) expect(result).toHaveLength(2) - expect((result[0] as any).content).toBe('thoughts') - expect((result[0] as any).textType).toBe('reasoning') - expect((result[0] as any).thinkingOpen).toBe(false) - expect((result[1] as any).content).toBe('after') - expect((result[1] as any).textType).toBe('text') + expect((result[0] as TextContentBlock).content).toBe('thoughts') + expect((result[0] as TextContentBlock).textType).toBe('reasoning') + expect((result[0] as TextContentBlock).thinkingOpen).toBe(false) + expect((result[1] as TextContentBlock).content).toBe('after') + expect((result[1] as TextContentBlock).textType).toBe('text') }) // Streaming simulation tests @@ -565,9 +578,9 @@ describe('appendTextToRootStream', () => { }) expect(afterFirstChunk).toHaveLength(1) - expect((afterFirstChunk[0] as any).textType).toBe('reasoning') - expect((afterFirstChunk[0] as any).content).toBe('My thoughts') - expect((afterFirstChunk[0] as any).thinkingOpen).toBe(true) + expect((afterFirstChunk[0] as TextContentBlock).textType).toBe('reasoning') + expect((afterFirstChunk[0] as TextContentBlock).content).toBe('My thoughts') + expect((afterFirstChunk[0] as TextContentBlock).thinkingOpen).toBe(true) // Second chunk: ' after' should close the block, not create a duplicate const afterSecondChunk = appendTextToRootStream(afterFirstChunk, { @@ -576,11 +589,662 @@ describe('appendTextToRootStream', () => { }) expect(afterSecondChunk).toHaveLength(2) - expect((afterSecondChunk[0] as any).textType).toBe('reasoning') - expect((afterSecondChunk[0] as any).content).toBe('My thoughts') - expect((afterSecondChunk[0] as any).thinkingOpen).toBe(false) - expect((afterSecondChunk[1] as any).textType).toBe('text') - expect((afterSecondChunk[1] as any).content).toBe(' after') + expect((afterSecondChunk[0] as TextContentBlock).textType).toBe('reasoning') + expect((afterSecondChunk[0] as TextContentBlock).content).toBe('My thoughts') + expect((afterSecondChunk[0] as TextContentBlock).thinkingOpen).toBe(false) + expect((afterSecondChunk[1] as TextContentBlock).textType).toBe('text') + expect((afterSecondChunk[1] as TextContentBlock).content).toBe(' after') + }) + + // Native reasoning tests + test('closes native reasoning block when text arrives', () => { + // Native reasoning block (thinkingOpen === undefined) + const blocks: ContentBlock[] = [ + { + type: 'text', + content: 'Thinking...', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + // Note: thinkingOpen is undefined for native reasoning + }, + ] + + const result = appendTextToRootStream(blocks, { + type: 'text', + text: 'Regular text', + }) + + expect(result).toHaveLength(2) + // Native reasoning block should be closed + expect((result[0] as TextContentBlock).thinkingOpen).toBe(false) + // New text block added + expect((result[1] as TextContentBlock).content).toBe('Regular text') + expect((result[1] as TextContentBlock).textType).toBe('text') + }) + + test('appends to existing native reasoning block', () => { + const blocks: ContentBlock[] = [ + { + type: 'text', + content: 'First thought', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + // thinkingOpen is undefined for native reasoning + }, + ] + + const result = appendTextToRootStream(blocks, { + type: 'reasoning', + text: ' second thought', + }) + + expect(result).toHaveLength(1) + expect((result[0] as TextContentBlock).content).toBe('First thought second thought') + expect((result[0] as TextContentBlock).textType).toBe('reasoning') + }) +}) + +// ============================================================================ +// Native Reasoning Block Tests (from block-operations) +// ============================================================================ + +describe('isNativeReasoningBlock', () => { + test('returns true for native reasoning block (thinkingOpen undefined)', () => { + const block: ContentBlock = { + type: 'text', + content: 'Thinking...', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + } + + expect(isNativeReasoningBlock(block)).toBe(true) + }) + + test('returns false for closed native reasoning block (thinkingOpen false)', () => { + const block: ContentBlock = { + type: 'text', + content: 'Thinking...', + textType: 'reasoning', + isCollapsed: true, + thinkingOpen: false, + thinkingId: 'think-1', + } + + expect(isNativeReasoningBlock(block)).toBe(false) + }) + + test('returns false for tag block (thinkingOpen true)', () => { + const block: ContentBlock = { + type: 'text', + content: 'Thinking...', + textType: 'reasoning', + isCollapsed: true, + thinkingOpen: true, + thinkingId: 'think-1', + } + + expect(isNativeReasoningBlock(block)).toBe(false) + }) + + test('returns false for regular text block', () => { + const block: ContentBlock = { + type: 'text', + content: 'Hello', + textType: 'text', + } + + expect(isNativeReasoningBlock(block)).toBe(false) + }) + + test('returns false for non-text blocks', () => { + const agentBlock: ContentBlock = { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: '', + status: 'running', + } + + expect(isNativeReasoningBlock(agentBlock)).toBe(false) + }) + + test('returns false for undefined', () => { + expect(isNativeReasoningBlock(undefined)).toBe(false) + }) +}) + +describe('closeNativeReasoningBlock', () => { + test('closes native reasoning block by setting thinkingOpen to false', () => { + const blocks: ContentBlock[] = [ + { + type: 'text', + content: 'Thinking...', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + }, + ] + + const result = closeNativeReasoningBlock(blocks) + + expect(result).toHaveLength(1) + expect((result[0] as TextContentBlock).thinkingOpen).toBe(false) + expect((result[0] as TextContentBlock).content).toBe('Thinking...') + }) + + test('returns original blocks if no native reasoning block exists', () => { + const blocks: ContentBlock[] = [ + { type: 'text', content: 'Hello', textType: 'text' }, + ] + + const result = closeNativeReasoningBlock(blocks) + + expect(result).toBe(blocks) // Same reference + }) + + test('does not close already-closed reasoning blocks', () => { + const blocks: ContentBlock[] = [ + { + type: 'text', + content: 'Already closed', + textType: 'reasoning', + isCollapsed: true, + thinkingOpen: false, + thinkingId: 'think-1', + }, + ] + + const result = closeNativeReasoningBlock(blocks) + + expect(result).toBe(blocks) // Same reference, no change + }) + + test('does not close tag blocks (thinkingOpen true)', () => { + const blocks: ContentBlock[] = [ + { + type: 'text', + content: 'Think tag block', + textType: 'reasoning', + isCollapsed: true, + thinkingOpen: true, + thinkingId: 'think-1', + }, + ] + + const result = closeNativeReasoningBlock(blocks) + + expect(result).toBe(blocks) // Same reference, no change + }) + + test('finds native reasoning block even when not at end', () => { + const blocks: ContentBlock[] = [ + { + type: 'text', + content: 'Native reasoning', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + }, + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: '', + status: 'running', + }, + ] + + const result = closeNativeReasoningBlock(blocks) + + expect((result[0] as TextContentBlock).thinkingOpen).toBe(false) + expect(result[1]).toEqual(blocks[1]) // Agent block unchanged + }) +}) + +describe('closeNativeReasoningInAgent', () => { + test('closes native reasoning in specific agent', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: '', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Agent thinking...', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + }, + ], + }, + ] + + const result = closeNativeReasoningInAgent(blocks, 'agent-1') + + const agentBlock = result[0] as AgentContentBlock + expect((agentBlock.blocks![0] as TextContentBlock).thinkingOpen).toBe(false) + }) + + test('does not modify other agents', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test 1', + agentType: 'test', + content: '', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Agent 1 thinking...', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + }, + ], + }, + { + type: 'agent', + agentId: 'agent-2', + agentName: 'Test 2', + agentType: 'test', + content: '', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Agent 2 thinking...', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-2', + }, + ], + }, + ] + + const result = closeNativeReasoningInAgent(blocks, 'agent-1') + + const agent1 = result[0] as AgentContentBlock + const agent2 = result[1] as AgentContentBlock + expect((agent1.blocks![0] as TextContentBlock).thinkingOpen).toBe(false) + // Agent 2 should still have undefined thinkingOpen + expect((agent2.blocks![0] as TextContentBlock).thinkingOpen).toBeUndefined() + }) + + test('returns original blocks if agent not found', () => { + const blocks: ContentBlock[] = [ + { type: 'text', content: 'Hello' }, + ] + + const result = closeNativeReasoningInAgent(blocks, 'nonexistent') + + expect(result).toBe(blocks) + }) +}) + +describe('appendTextToAgentBlock with native reasoning', () => { + test('creates native reasoning block when textType is reasoning', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: '', + status: 'running', + blocks: [], + }, + ] + + const result = appendTextToAgentBlock(blocks, 'agent-1', 'Thinking...', 'reasoning') + + const agentBlock = result[0] as AgentContentBlock + expect(agentBlock.blocks).toHaveLength(1) + expect((agentBlock.blocks![0] as TextContentBlock).textType).toBe('reasoning') + expect((agentBlock.blocks![0] as TextContentBlock).content).toBe('Thinking...') + expect((agentBlock.blocks![0] as TextContentBlock).thinkingCollapseState).toBe('preview') + // Native reasoning has thinkingOpen undefined + expect((agentBlock.blocks![0] as TextContentBlock).thinkingOpen).toBeUndefined() + }) + + test('appends to existing open native reasoning block', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: 'First', + status: 'running', + blocks: [ + { + type: 'text', + content: 'First', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + }, + ], + }, + ] + + const result = appendTextToAgentBlock(blocks, 'agent-1', ' second', 'reasoning') + + const agentBlock = result[0] as AgentContentBlock + expect(agentBlock.blocks).toHaveLength(1) + expect((agentBlock.blocks![0] as TextContentBlock).content).toBe('First second') + }) + + test('does NOT append to closed native reasoning block', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: 'Closed', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Closed', + textType: 'reasoning', + isCollapsed: true, + thinkingOpen: false, // Already closed + thinkingId: 'think-1', + }, + ], + }, + ] + + const result = appendTextToAgentBlock(blocks, 'agent-1', 'New thought', 'reasoning') + + const agentBlock = result[0] as AgentContentBlock + // Should create a NEW reasoning block, not append to closed one + expect(agentBlock.blocks).toHaveLength(2) + expect((agentBlock.blocks![0] as TextContentBlock).content).toBe('Closed') + expect((agentBlock.blocks![1] as TextContentBlock).content).toBe('New thought') + }) + + test('does NOT append to tag block', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: 'Think tag', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Think tag', + textType: 'reasoning', + isCollapsed: true, + thinkingOpen: true, // tag block + thinkingId: 'think-1', + }, + ], + }, + ] + + const result = appendTextToAgentBlock(blocks, 'agent-1', 'Native thought', 'reasoning') + + const agentBlock = result[0] as AgentContentBlock + // Should create a NEW native reasoning block, not append to block + expect(agentBlock.blocks).toHaveLength(2) + expect((agentBlock.blocks![0] as TextContentBlock).thinkingOpen).toBe(true) + expect((agentBlock.blocks![1] as TextContentBlock).thinkingOpen).toBeUndefined() + }) + + test('closes native reasoning when regular text arrives', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: 'Thinking', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Thinking', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + }, + ], + }, + ] + + const result = appendTextToAgentBlock(blocks, 'agent-1', 'Regular text', 'text') + + const agentBlock = result[0] as AgentContentBlock + expect(agentBlock.blocks).toHaveLength(2) + // Native reasoning should be closed + expect((agentBlock.blocks![0] as TextContentBlock).thinkingOpen).toBe(false) + // New text block added + expect((agentBlock.blocks![1] as TextContentBlock).content).toBe('Regular text') + expect((agentBlock.blocks![1] as TextContentBlock).textType).toBe('text') + }) +}) + +describe('appendToolToAgentBlock closes native reasoning', () => { + test('closes native reasoning when tool is appended', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: 'Thinking', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Thinking', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + }, + ], + }, + ] + + const toolBlock: ToolContentBlock = { + type: 'tool', + toolCallId: 'tool-1', + toolName: 'read_files', + input: { paths: ['test.ts'] }, + } + + const result = appendToolToAgentBlock(blocks, 'agent-1', toolBlock) + + const agentBlock = result[0] as AgentContentBlock + expect(agentBlock.blocks).toHaveLength(2) + // Native reasoning should be closed + expect((agentBlock.blocks![0] as TextContentBlock).thinkingOpen).toBe(false) + // Tool block added + expect(agentBlock.blocks![1].type).toBe('tool') + }) +}) + +describe('markAgentComplete closes native reasoning', () => { + test('closes native reasoning when agent completes', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: 'Thinking', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Thinking', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + }, + ], + }, + ] + + const result = markAgentComplete(blocks, 'agent-1') + + const agentBlock = result[0] as AgentContentBlock + expect(agentBlock.status).toBe('complete') + expect((agentBlock.blocks![0] as TextContentBlock).thinkingOpen).toBe(false) + }) +}) + +describe('markRunningAgentsAsCancelled closes native reasoning', () => { + test('closes native reasoning in cancelled agents', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: 'Thinking', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Thinking', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + }, + ], + }, + ] + + const result = markRunningAgentsAsCancelled(blocks) + + const agentBlock = result[0] as AgentContentBlock + expect(agentBlock.status).toBe('cancelled') + expect((agentBlock.blocks![0] as TextContentBlock).thinkingOpen).toBe(false) + }) + + test('closes native reasoning in nested cancelled agents', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'parent', + agentName: 'Parent', + agentType: 'parent', + content: '', + status: 'running', + blocks: [ + { + type: 'agent', + agentId: 'child', + agentName: 'Child', + agentType: 'child', + content: 'Child thinking', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Child thinking', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-child', + }, + ], + }, + ], + }, + ] + + const result = markRunningAgentsAsCancelled(blocks) + + const parentBlock = result[0] as AgentContentBlock + const childBlock = parentBlock.blocks![0] as AgentContentBlock + + expect(parentBlock.status).toBe('cancelled') + expect(childBlock.status).toBe('cancelled') + expect((childBlock.blocks![0] as TextContentBlock).thinkingOpen).toBe(false) + }) + + test('closes native reasoning even in non-running agents during cancellation', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: '', + status: 'complete', // Already complete + blocks: [ + { + type: 'agent', + agentId: 'child', + agentName: 'Child', + agentType: 'child', + content: 'Thinking', + status: 'running', + blocks: [ + { + type: 'text', + content: 'Thinking', + textType: 'reasoning', + isCollapsed: true, + thinkingId: 'think-1', + }, + ], + }, + ], + }, + ] + + const result = markRunningAgentsAsCancelled(blocks) + + const parentBlock = result[0] as AgentContentBlock + const childBlock = parentBlock.blocks![0] as AgentContentBlock + + // Parent stays complete + expect(parentBlock.status).toBe('complete') + // Child is cancelled + expect(childBlock.status).toBe('cancelled') + // Child's reasoning is closed + expect((childBlock.blocks![0] as TextContentBlock).thinkingOpen).toBe(false) + }) + + test('does not modify agents without native reasoning blocks', () => { + const blocks: ContentBlock[] = [ + { + type: 'agent', + agentId: 'agent-1', + agentName: 'Test', + agentType: 'test', + content: 'Hello', + status: 'running', + blocks: [ + { type: 'text', content: 'Hello', textType: 'text' }, + ], + }, + ] + + const result = markRunningAgentsAsCancelled(blocks) + + const agentBlock = result[0] as AgentContentBlock + expect(agentBlock.status).toBe('cancelled') + // Text block should be unchanged + expect((agentBlock.blocks![0] as TextContentBlock).thinkingOpen).toBeUndefined() }) }) @@ -661,6 +1325,10 @@ describe('getAgentBaseName', () => { test('returns simple name unchanged', () => { expect(getAgentBaseName('file-picker')).toBe('file-picker') }) + + test('normalizes direct tool aliases to canonical agent names', () => { + expect(getAgentBaseName('code_reviewer_lite')).toBe('code-reviewer-lite') + }) }) describe('agentTypesMatch', () => { @@ -704,7 +1372,7 @@ describe('updateToolBlockWithOutput', () => { toolOutput: ['File contents'], }) - expect((result[0] as any).output).toBe('File contents') + expect((result[0] as ToolContentBlock).output).toBe('File contents') }) test('updates nested tool block', () => { @@ -732,7 +1400,7 @@ describe('updateToolBlockWithOutput', () => { toolOutput: ['File contents'], }) const agent = result[0] as AgentContentBlock - expect((agent.blocks![0] as any).output).toBe('File contents') + expect((agent.blocks![0] as ToolContentBlock).output).toBe('File contents') }) test('returns same reference if no match', () => { @@ -764,11 +1432,11 @@ describe('transformAskUserBlocks', () => { const result = transformAskUserBlocks(blocks, { toolCallId: 'tool-1', - resultValue: { answers: [{ selectedOption: 'A' }] }, + resultValue: { answers: [{ questionIndex: 0, selectedOption: 'A' }] }, }) expect(result[0].type).toBe('ask-user') - expect((result[0] as any).answers).toEqual([{ selectedOption: 'A' }]) + expect((result[0] as AskUserContentBlock).answers).toEqual([{ questionIndex: 0, selectedOption: 'A' }]) }) test('keeps tool block if no answers or skipped', () => { @@ -805,7 +1473,7 @@ describe('transformAskUserBlocks', () => { }) expect(result[0].type).toBe('ask-user') - expect((result[0] as any).skipped).toBe(true) + expect((result[0] as AskUserContentBlock).skipped).toBe(true) }) }) @@ -821,7 +1489,7 @@ describe('appendInterruptionNotice', () => { const result = appendInterruptionNotice(blocks) - expect((result[0] as any).content).toBe( + expect((result[0] as TextContentBlock).content).toBe( 'Partial response\n\n[response interrupted]', ) }) @@ -832,7 +1500,7 @@ describe('appendInterruptionNotice', () => { const result = appendInterruptionNotice(blocks) expect(result).toHaveLength(1) - expect((result[0] as any).content).toBe('[response interrupted]') + expect((result[0] as TextContentBlock).content).toBe('[response interrupted]') }) test('creates new block if last block is not text', () => { @@ -867,8 +1535,8 @@ describe('createSpawnAgentBlocks', () => { expect(result).toHaveLength(2) expect(result[0].type).toBe('agent') - expect((result[0] as any).agentId).toBe('tool-1-0') - expect((result[1] as any).agentId).toBe('tool-1-1') + expect((result[0] as AgentContentBlock).agentId).toBe('tool-1-0') + expect((result[1] as AgentContentBlock).agentId).toBe('tool-1-1') }) test('filters out hidden agents', () => { diff --git a/cli/src/utils/__tests__/send-message-timer.test.ts b/cli/src/utils/__tests__/send-message-timer.test.ts index 3772d41637..d5343afbcd 100644 --- a/cli/src/utils/__tests__/send-message-timer.test.ts +++ b/cli/src/utils/__tests__/send-message-timer.test.ts @@ -15,7 +15,7 @@ describe('createSendMessageTimerController', () => { } const controller = createSendMessageTimerController({ - mainAgentTimer: mainAgentTimer as any, + mainAgentTimer: mainAgentTimer as unknown as Parameters[0]['mainAgentTimer'], onTimerEvent: (event) => events.push(event), now: () => nowValue, }) @@ -46,7 +46,7 @@ describe('createSendMessageTimerController', () => { } const controller = createSendMessageTimerController({ - mainAgentTimer: mainAgentTimer as any, + mainAgentTimer: mainAgentTimer as unknown as Parameters[0]['mainAgentTimer'], onTimerEvent: () => {}, now: () => nowValue, }) diff --git a/cli/src/utils/__tests__/strings.test.ts b/cli/src/utils/__tests__/strings.test.ts new file mode 100644 index 0000000000..e87d50e589 --- /dev/null +++ b/cli/src/utils/__tests__/strings.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from 'bun:test' + +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', () => { + expect(MAX_COLLAPSED_LINES).toBe(3) + }) +}) + +describe('truncateToLines', () => { + test('returns empty string unchanged', () => { + expect(truncateToLines('', 3)).toBe('') + }) + + test('returns falsy values unchanged', () => { + expect(truncateToLines(null, 3)).toBe(null) + expect(truncateToLines(undefined, 3)).toBe(undefined) + }) + + test('returns single line unchanged', () => { + expect(truncateToLines('single line', 3)).toBe('single line') + }) + + test('returns text with fewer lines than max unchanged', () => { + const text = 'line 1\nline 2' + expect(truncateToLines(text, 3)).toBe('line 1\nline 2') + }) + + test('returns text with exact max lines unchanged', () => { + const text = 'line 1\nline 2\nline 3' + expect(truncateToLines(text, 3)).toBe('line 1\nline 2\nline 3') + }) + + test('truncates text exceeding max lines and adds ellipsis', () => { + const text = 'line 1\nline 2\nline 3\nline 4' + expect(truncateToLines(text, 3)).toBe('line 1\nline 2\nline 3...') + }) + + test('truncates text with many lines', () => { + const text = 'line 1\nline 2\nline 3\nline 4\nline 5\nline 6' + expect(truncateToLines(text, 3)).toBe('line 1\nline 2\nline 3...') + }) + + test('handles maxLines of 1', () => { + const text = 'line 1\nline 2\nline 3' + expect(truncateToLines(text, 1)).toBe('line 1...') + }) + + test('trims trailing whitespace before adding ellipsis', () => { + const text = 'line 1\nline 2 \nline 3\nline 4' + expect(truncateToLines(text, 2)).toBe('line 1\nline 2...') + }) + + test('handles text with empty lines', () => { + const text = 'line 1\n\nline 3\nline 4' + expect(truncateToLines(text, 3)).toBe('line 1\n\nline 3...') + }) + + test('handles text ending with newline', () => { + const text = 'line 1\nline 2\nline 3\n' + // 4 lines when split (last is empty), but only 3 visible lines of content + 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/agent-display.ts b/cli/src/utils/agent-display.ts new file mode 100644 index 0000000000..b91545cea3 --- /dev/null +++ b/cli/src/utils/agent-display.ts @@ -0,0 +1,87 @@ +import { getAgentBaseName } from './message-block-helpers' + +import type { + AgentContentBlock, + TextContentBlock, + ToolContentBlock, +} from '../types/chat' + +const DEFAULT_BASHER_OUTPUT_PREVIEW_MAX_LENGTH = 120 +const PREVIEW_ELLIPSIS = '...' + +export function truncateToSingleLinePreview( + text: string, + maxLength = DEFAULT_BASHER_OUTPUT_PREVIEW_MAX_LENGTH, +): string | undefined { + const singleLine = text.replace(/\s+/g, ' ').trim() + if (!singleLine) { + return undefined + } + + if (singleLine.length <= maxLength) { + return singleLine + } + + const previewLength = Math.max(0, maxLength - PREVIEW_ELLIPSIS.length) + return `${singleLine.slice(0, previewLength).trimEnd()}${PREVIEW_ELLIPSIS}` +} + +export function getAgentDisplayPrompt( + agentBlock: AgentContentBlock, +): string | undefined { + const initialPrompt = agentBlock.initialPrompt?.trim() + if (initialPrompt) { + return initialPrompt + } + + if (getAgentBaseName(agentBlock.agentType) !== 'basher') { + return undefined + } + + const whatToSummarize = agentBlock.params?.what_to_summarize + return typeof whatToSummarize === 'string' && whatToSummarize.trim() + ? whatToSummarize.trim() + : undefined +} + +export function getBasherFinishedOutputPreview( + agentBlock: AgentContentBlock, + maxLength = DEFAULT_BASHER_OUTPUT_PREVIEW_MAX_LENGTH, +): string | undefined { + if ( + getAgentBaseName(agentBlock.agentType) !== 'basher' || + agentBlock.status === 'running' + ) { + return undefined + } + + const blocks = agentBlock.blocks ?? [] + return ( + truncateToSingleLinePreview(getTextOutput(blocks), maxLength) ?? + truncateToSingleLinePreview(getCommandOutput(blocks), maxLength) + ) +} + +function getTextOutput( + blocks: NonNullable, +): string { + return blocks + .filter( + (block): block is TextContentBlock => + block.type === 'text' && block.textType !== 'reasoning', + ) + .map((block) => block.content) + .join('\n') +} + +function getCommandOutput( + blocks: NonNullable, +): string { + return blocks + .filter( + (block): block is ToolContentBlock => + block.type === 'tool' && block.toolName === 'run_terminal_command', + ) + .map((block) => block.output ?? '') + .join('\n') +} diff --git a/cli/src/utils/agent-helpers.ts b/cli/src/utils/agent-helpers.ts index 943dae9411..b79e984927 100644 --- a/cli/src/utils/agent-helpers.ts +++ b/cli/src/utils/agent-helpers.ts @@ -19,6 +19,8 @@ export function getAgentStatusInfo( return { indicator: '✗', label: 'failed', color: 'red', text: '✗ failed' } case 'complete': return { indicator: '✓', label: 'completed', color: theme.foreground, text: 'completed ✓' } + case 'cancelled': + return { indicator: '⊘', label: 'cancelled', color: 'red', text: '⊘ cancelled' } default: return { indicator: '○', label: 'waiting', color: theme.muted, text: '○ waiting' } } diff --git a/cli/src/utils/analytics.ts b/cli/src/utils/analytics.ts index 7596fd3089..7fdfa639cb 100644 --- a/cli/src/utils/analytics.ts +++ b/cli/src/utils/analytics.ts @@ -9,6 +9,7 @@ import { IS_PROD as defaultIsProd, DEBUG_ANALYTICS, } from '@codebuff/common/env' +import { shouldTrackAnalyticsEvent } from '@codebuff/common/util/analytics-sampling' import type { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' @@ -88,16 +89,18 @@ function logAnalyticsDebug(message: string, data: Record) { if (!DEBUG_ANALYTICS) { return } - void loadLogger() + loadLogger() .then(({ logger }) => { logger.debug(data, message) }) - .catch(() => { + .catch((error) => { try { console.debug(message, data) } catch { // Ignore console errors in restricted environments } + // Log the error to help diagnose logger issues in debug mode + console.debug('Failed to load logger for analytics:', error) }) } @@ -209,6 +212,10 @@ export function trackEvent( return } + if (!shouldTrackAnalyticsEvent({ event, distinctId, properties })) { + return + } + try { client.capture({ distinctId, diff --git a/cli/src/utils/auth.ts b/cli/src/utils/auth.ts index 05c322289c..b77a880e20 100644 --- a/cli/src/utils/auth.ts +++ b/cli/src/utils/auth.ts @@ -2,15 +2,16 @@ import fs from 'fs' import os from 'os' import path from 'path' -import { getCiEnv } from '@codebuff/common/env-ci' import { env } from '@codebuff/common/env' +import { getCiEnv } from '@codebuff/common/env-ci' import { z } from 'zod' -import type { CiEnv } from '@codebuff/common/types/contracts/env' import { getApiClient, setApiClientAuthToken } from './codebuff-api' import { logger } from './logger' +import type { CiEnv } from '@codebuff/common/types/contracts/env' + // User schema const userSchema = z.object({ id: z.string().optional(), @@ -24,20 +25,9 @@ const userSchema = z.object({ export type User = z.infer -// Claude OAuth credentials schema (for passthrough, not strict validation here) -const claudeOAuthSchema = z - .object({ - accessToken: z.string(), - refreshToken: z.string(), - expiresAt: z.number(), - connectedAt: z.number(), - }) - .optional() - const credentialsSchema = z .object({ default: userSchema.optional(), - claudeOAuth: claudeOAuthSchema, }) .catchall(z.unknown()) diff --git a/cli/src/utils/bash-context-processor.ts b/cli/src/utils/bash-context-processor.ts index b121c7745e..02cedff874 100644 --- a/cli/src/utils/bash-context-processor.ts +++ b/cli/src/utils/bash-context-processor.ts @@ -4,7 +4,7 @@ import { formatBashContextForPrompt, } from './bash-messages' -import type { PendingBashMessage } from '../state/chat-store' +import type { PendingBashMessage } from '../types/store' import type { ChatMessage } from '../types/chat' // Turns pending bash executions into chat history messages and prompt context. diff --git a/cli/src/utils/bash-messages.ts b/cli/src/utils/bash-messages.ts index ad6529dff8..d06f150885 100644 --- a/cli/src/utils/bash-messages.ts +++ b/cli/src/utils/bash-messages.ts @@ -1,6 +1,6 @@ import { formatTimestamp } from './helpers' -import type { PendingBashMessage } from '../state/chat-store' +import type { PendingBashMessage } from '../types/store' import type { ChatMessage, ContentBlock } from '../types/chat' import type { ToolResultOutput } from '@codebuff/common/types/messages/content-part' diff --git a/cli/src/utils/block-margins.ts b/cli/src/utils/block-margins.ts new file mode 100644 index 0000000000..12c36cc528 --- /dev/null +++ b/cli/src/utils/block-margins.ts @@ -0,0 +1,35 @@ +import type { ContentBlock, TextContentBlock } from '../types/chat' + +/** + * Margin calculation result for a content block. + */ +export interface BlockMargins { + marginTop: number + marginBottom: number +} + +/** Extracts margins for a text block, suppressing top margin after tool/agent blocks. */ +export function extractTextBlockMargins( + block: TextContentBlock, + prevBlock: ContentBlock | null, +): BlockMargins { + const prevBlockSuppressesMargin = + prevBlock !== null && + (prevBlock.type === 'tool' || prevBlock.type === 'agent') + + const marginTop = prevBlockSuppressesMargin ? 0 : (block.marginTop ?? 0) + const marginBottom = block.marginBottom ?? 0 + + return { marginTop, marginBottom } +} + +/** Extracts margins for an HTML block using explicit values without context adjustments. */ +export function extractHtmlBlockMargins(block: { + marginTop?: number + marginBottom?: number +}): BlockMargins { + return { + marginTop: block.marginTop ?? 0, + marginBottom: block.marginBottom ?? 0, + } +} diff --git a/cli/src/utils/block-operations.ts b/cli/src/utils/block-operations.ts index 07dca8a653..1f1a86234c 100644 --- a/cli/src/utils/block-operations.ts +++ b/cli/src/utils/block-operations.ts @@ -11,7 +11,6 @@ import type { ToolContentBlock, TextContentBlock, } from '../types/chat' -import { logger } from './logger' let thinkingIdCounter = 0 const generateThinkingId = (): string => { @@ -20,7 +19,7 @@ const generateThinkingId = (): string => { } type AgentTextUpdate = - | { type: 'text'; mode: 'append'; content: string } + | { type: 'text'; mode: 'append'; content: string; textType: 'text' | 'reasoning' } | { type: 'text'; mode: 'replace'; content: string } const updateAgentText = ( @@ -67,9 +66,21 @@ const updateAgentText = ( return block } - // Use think tag parsing for agent blocks too + // Handle native reasoning chunks for agent blocks + if (update.textType === 'reasoning') { + const updatedAgentBlocks = appendNativeReasoningToBlocks(agentBlocks, text) + const updatedContent = (block.content ?? '') + text + return { + ...block, + content: updatedContent, + blocks: updatedAgentBlocks, + } + } + + // For regular text: first close any open native reasoning block, then use think tag parsing + const blocksWithClosedReasoning = closeNativeReasoningBlock(agentBlocks) const updatedAgentBlocks = appendTextWithThinkParsingToBlocks( - agentBlocks, + blocksWithClosedReasoning, text, ) const updatedContent = (block.content ?? '') + text @@ -102,7 +113,7 @@ const createReasoningBlock = ( type: 'text', content, textType: 'reasoning', - isCollapsed: true, + thinkingCollapseState: 'preview', thinkingOpen, thinkingId, }) @@ -272,6 +283,112 @@ const appendTextWithThinkParsingToBlocks = ( return nextBlocks } +/** + * Appends native reasoning content to blocks array (for agent blocks). + * Similar to how appendTextToRootStream handles reasoning for root. + */ +const appendNativeReasoningToBlocks = ( + blocks: ContentBlock[], + text: string, +): ContentBlock[] => { + if (!text) { + return blocks + } + + const nextBlocks = [...blocks] + const lastBlock = nextBlocks[nextBlocks.length - 1] + + // If last block is already an open native reasoning block, append to it + // Only append if it's a native reasoning block (thinkingOpen === undefined), + // not a closed one or a tag block + if (isNativeReasoningBlock(lastBlock) && lastBlock.type === 'text') { + const updatedBlock: ContentBlock = { + ...lastBlock, + content: lastBlock.content + text, + } + nextBlocks[nextBlocks.length - 1] = updatedBlock + return nextBlocks + } + + // Create a new native reasoning block + const newBlock: ContentBlock = { + type: 'text', + content: text, + textType: 'reasoning', + thinkingCollapseState: 'preview', + thinkingId: generateThinkingId(), + } + + return [...nextBlocks, newBlock] +} + +/** + * Checks if a block is a native reasoning block (not from tags). + * Native reasoning blocks have textType === 'reasoning' but thinkingOpen === undefined. + */ +export const isNativeReasoningBlock = (block: ContentBlock | undefined): boolean => { + if (!block || block.type !== 'text') { + return false + } + return block.textType === 'reasoning' && block.thinkingOpen === undefined +} + +/** + * Closes native reasoning blocks within a specific agent's blocks. + * Used when a tool call happens for a subagent. + */ +export const closeNativeReasoningInAgent = ( + blocks: ContentBlock[], + agentId: string, +): ContentBlock[] => { + return updateBlocksRecursively(blocks, agentId, (block) => { + if (block.type !== 'agent') { + return block + } + const closedBlocks = block.blocks ? closeNativeReasoningBlock(block.blocks) : undefined + if (closedBlocks && closedBlocks !== block.blocks) { + return { ...block, blocks: closedBlocks } + } + return block + }) +} + +/** + * Marks the last native reasoning block as complete by setting thinkingOpen: false. + * This triggers the UI to collapse the thinking block. + * + * Note: We search backwards through all blocks because agent/tool blocks may have + * been added after the reasoning block but before text output starts. + */ +export const closeNativeReasoningBlock = ( + blocks: ContentBlock[], +): ContentBlock[] => { + // Find the last native reasoning block (not just the last block) + let lastReasoningIndex = -1 + for (let i = blocks.length - 1; i >= 0; i--) { + if (isNativeReasoningBlock(blocks[i])) { + lastReasoningIndex = i + break + } + } + + if (lastReasoningIndex === -1) { + return blocks + } + + const reasoningBlock = blocks[lastReasoningIndex] + if (reasoningBlock.type !== 'text') { + return blocks + } + + const nextBlocks = [...blocks] + nextBlocks[lastReasoningIndex] = { + ...reasoningBlock, + thinkingOpen: false, + } + return nextBlocks +} + export const appendTextToRootStream = ( blocks: ContentBlock[], delta: { type: 'text' | 'reasoning'; text: string }, @@ -302,26 +419,29 @@ export const appendTextToRootStream = ( type: 'text', content: delta.text, textType: 'reasoning', - isCollapsed: true, + thinkingCollapseState: 'preview', thinkingId: generateThinkingId(), } return [...nextBlocks, newBlock] } - // For text type, parse for tags - return appendTextWithThinkParsingToBlocks(blocks, delta.text) + // For text type: first close any open native reasoning block, then parse for tags + const blocksWithClosedReasoning = closeNativeReasoningBlock(blocks) + return appendTextWithThinkParsingToBlocks(blocksWithClosedReasoning, delta.text) } export const appendTextToAgentBlock = ( blocks: ContentBlock[], agentId: string, text: string, + textType: 'text' | 'reasoning' = 'text', ) => updateAgentText(blocks, agentId, { type: 'text', mode: 'append', content: text, + textType, }) export const replaceTextInAgentBlock = ( @@ -344,7 +464,8 @@ export const appendToolToAgentBlock = ( if (block.type !== 'agent') { return block } - const agentBlocks = block.blocks ? [...block.blocks] : [] + // Close any open native reasoning blocks before adding the tool + const agentBlocks = block.blocks ? closeNativeReasoningBlock([...block.blocks]) : [] return { ...block, blocks: [...agentBlocks, toolBlock] } }) @@ -353,5 +474,50 @@ export const markAgentComplete = (blocks: ContentBlock[], agentId: string) => if (block.type !== 'agent') { return block } - return { ...block, status: 'complete' as const } + // Close any open native reasoning blocks when the agent completes + const closedBlocks = block.blocks ? closeNativeReasoningBlock(block.blocks) : undefined + return { + ...block, + status: 'complete' as const, + ...(closedBlocks && { blocks: closedBlocks }), + } }) + +/** + * Recursively marks all agent blocks with status 'running' as 'cancelled'. + * Used when the user interrupts a response to indicate subagents were stopped. + * Also closes any open native reasoning blocks so they don't appear "streaming". + */ +export const markRunningAgentsAsCancelled = ( + blocks: ContentBlock[], +): ContentBlock[] => { + return blocks.map((block) => { + if (block.type !== 'agent') { + return block + } + + // First recursively process nested agents, then close any reasoning blocks + let updatedBlocks = block.blocks + ? markRunningAgentsAsCancelled(block.blocks) + : undefined + + // Close any open native reasoning blocks in this agent + if (updatedBlocks) { + updatedBlocks = closeNativeReasoningBlock(updatedBlocks) + } + + if (block.status === 'running') { + return { + ...block, + status: 'cancelled' as const, + ...(updatedBlocks && { blocks: updatedBlocks }), + } + } + + if (updatedBlocks && updatedBlocks !== block.blocks) { + return { ...block, blocks: updatedBlocks } + } + + return block + }) +} diff --git a/cli/src/utils/block-processor.ts b/cli/src/utils/block-processor.ts new file mode 100644 index 0000000000..acc2075140 --- /dev/null +++ b/cli/src/utils/block-processor.ts @@ -0,0 +1,213 @@ + +import { shouldCollapseByDefault } from './constants' +import { + isImplementorAgent, + groupConsecutiveImplementors, + groupConsecutiveNonImplementorAgents, + groupConsecutiveToolBlocks, +} from './implementor-helpers' +import { isImageBlock } from '../types/chat' + +import type { + ContentBlock, + AgentContentBlock, + ToolContentBlock, + TextContentBlock, + ImageContentBlock, +} from '../types/chat' +import type { ReactNode } from 'react' + +/** + * Type guard for reasoning text blocks (thinking blocks) + */ +export function isReasoningTextBlock( + block: ContentBlock, +): block is Extract { + return block.type === 'text' && block.textType === 'reasoning' +} + +/** + * Handler callbacks for processing different block types. + * Each handler receives the block(s) and relevant indices, and returns a ReactNode. + */ +export interface BlockProcessorHandlers { + /** Handle a group of consecutive reasoning text blocks */ + onReasoningGroup: ( + blocks: TextContentBlock[], + startIndex: number, + ) => ReactNode + + /** Handle an image block (optional - if not provided, images are skipped) */ + onImageBlock?: (block: ImageContentBlock, index: number) => ReactNode + + /** Handle a group of consecutive tool blocks */ + onToolGroup: ( + blocks: ToolContentBlock[], + startIndex: number, + nextIndex: number, + ) => ReactNode + + /** Handle a group of consecutive implementor agent blocks */ + onImplementorGroup: ( + blocks: AgentContentBlock[], + startIndex: number, + nextIndex: number, + ) => ReactNode + + /** Handle a group of consecutive non-implementor agent blocks */ + onAgentGroup: ( + blocks: AgentContentBlock[], + startIndex: number, + nextIndex: number, + ) => ReactNode + + /** Handle a single block that doesn't fit into any group category */ + onSingleBlock: (block: ContentBlock, index: number) => ReactNode +} + +/** + * Split an array of items into sub-groups based on agent size. + * Consecutive "small" agents (collapsed by default) are grouped together + * so they can share a grid row. Each "large" agent gets its own sub-group + * so it renders at full width. + */ +export function splitByAgentSize( + items: T[], + getAgentType: (item: T) => string, +): T[][] { + if (items.length <= 1) return [items] + + const subGroups: T[][] = [] + let currentSmallGroup: T[] = [] + + for (const item of items) { + if (shouldCollapseByDefault(getAgentType(item))) { + currentSmallGroup.push(item) + } else { + if (currentSmallGroup.length > 0) { + subGroups.push(currentSmallGroup) + currentSmallGroup = [] + } + subGroups.push([item]) + } + } + + if (currentSmallGroup.length > 0) { + subGroups.push(currentSmallGroup) + } + + return subGroups +} + +/** Convenience wrapper for splitting AgentContentBlock arrays by size. */ +export function splitAgentsBySize( + agents: AgentContentBlock[], +): AgentContentBlock[][] { + return splitByAgentSize(agents, (a) => a.agentType) +} + +/** + * Process a list of content blocks, grouping consecutive blocks of the same type + * and calling the appropriate handler for each group or single block. + * + * This utility abstracts the common iteration pattern used by BlocksRenderer and AgentBody. + * + * @param blocks - The array of content blocks to process + * @param handlers - Callback handlers for each block type + * @returns An array of ReactNode elements + */ +export function processBlocks( + blocks: ContentBlock[], + handlers: BlockProcessorHandlers, +): ReactNode[] { + const nodes: ReactNode[] = [] + + for (let i = 0; i < blocks.length; ) { + const block = blocks[i] + + // Handle reasoning text blocks (thinking) + if (isReasoningTextBlock(block)) { + const start = i + const reasoningBlocks: TextContentBlock[] = [] + while (i < blocks.length) { + const currentBlock = blocks[i] + if (!isReasoningTextBlock(currentBlock)) break + reasoningBlocks.push(currentBlock) + i++ + } + + const node = handlers.onReasoningGroup(reasoningBlocks, start) + if (node !== null) { + nodes.push(node) + } + continue + } + + // Handle image blocks + if (isImageBlock(block)) { + if (handlers.onImageBlock) { + const node = handlers.onImageBlock(block, i) + if (node !== null) { + nodes.push(node) + } + } + i++ + continue + } + + // Handle tool blocks + if (block.type === 'tool') { + const start = i + const { group: toolBlocks, nextIndex } = groupConsecutiveToolBlocks( + blocks, + i, + ) + i = nextIndex + + const node = handlers.onToolGroup(toolBlocks, start, nextIndex) + if (node !== null) { + nodes.push(node) + } + continue + } + + // Handle agent blocks + if (block.type === 'agent') { + if (isImplementorAgent(block)) { + // Implementor agents + const start = i + const { group: implementors, nextIndex } = groupConsecutiveImplementors( + blocks, + i, + ) + i = nextIndex + + const node = handlers.onImplementorGroup(implementors, start, nextIndex) + if (node !== null) { + nodes.push(node) + } + } else { + // Non-implementor agents + const start = i + const { group: agentBlocks, nextIndex } = + groupConsecutiveNonImplementorAgents(blocks, i) + i = nextIndex + + const node = handlers.onAgentGroup(agentBlocks, start, nextIndex) + if (node !== null) { + nodes.push(node) + } + } + continue + } + + // Handle all other block types (text, html, etc.) + const node = handlers.onSingleBlock(block, i) + if (node !== null) { + nodes.push(node) + } + i++ + } + + return nodes +} diff --git a/cli/src/utils/chat-history.ts b/cli/src/utils/chat-history.ts index 9d582cf696..2a4a51612c 100644 --- a/cli/src/utils/chat-history.ts +++ b/cli/src/utils/chat-history.ts @@ -13,6 +13,10 @@ export interface ChatHistoryEntry { messageCount: number } +function getChatsDir(): string { + return path.join(getProjectDataDir(), 'chats') +} + /** * Get the first user message from a list of chat messages */ @@ -43,14 +47,14 @@ interface ChatDirInfo { */ export function getAllChats(maxChats: number = 500): ChatHistoryEntry[] { try { - const chatsDir = path.join(getProjectDataDir(), 'chats') - + const chatsDir = getChatsDir() + if (!fs.existsSync(chatsDir)) { return [] } const chatDirs = fs.readdirSync(chatsDir) - + // First pass: get mtime for all chat directories (fast, no file reading) const chatDirInfos: ChatDirInfo[] = [] for (const chatId of chatDirs) { @@ -58,7 +62,7 @@ export function getAllChats(maxChats: number = 500): ChatHistoryEntry[] { try { const stat = fs.statSync(chatPath) if (!stat.isDirectory()) continue - + chatDirInfos.push({ chatId, chatPath, @@ -69,14 +73,14 @@ export function getAllChats(maxChats: number = 500): ChatHistoryEntry[] { // Skip directories we can't stat } } - + // Sort by mtime first (most recent first) chatDirInfos.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) - + // Second pass: only read message content for the top N chats const chats: ChatHistoryEntry[] = [] const chatsToLoad = chatDirInfos.slice(0, maxChats) - + for (const info of chatsToLoad) { try { let messageCount = 0 @@ -89,16 +93,22 @@ export function getAllChats(maxChats: number = 500): ChatHistoryEntry[] { lastPrompt = getFirstUserPrompt(messages) } - chats.push({ - chatId: info.chatId, - lastPrompt, - timestamp: info.mtime, - messageCount, - }) + // Skip empty chats (no messages) + if (messageCount > 0) { + chats.push({ + chatId: info.chatId, + lastPrompt, + timestamp: info.mtime, + messageCount, + }) + } } catch (error) { logger.debug( - { chatId: info.chatId, error: error instanceof Error ? error.message : String(error) }, - 'Failed to read chat messages' + { + chatId: info.chatId, + error: error instanceof Error ? error.message : String(error), + }, + 'Failed to read chat messages', ) } } @@ -107,12 +117,55 @@ export function getAllChats(maxChats: number = 500): ChatHistoryEntry[] { } catch (error) { logger.error( { error: error instanceof Error ? error.message : String(error) }, - 'Failed to list chats' + 'Failed to list chats', ) return [] } } +/** + * Delete a saved chat session from local history. + */ +export function deleteChatSession(chatId: string): boolean { + try { + const safeChatId = chatId.trim() + if ( + !safeChatId || + safeChatId === '.' || + safeChatId === '..' || + path.basename(safeChatId) !== safeChatId + ) { + logger.warn({ chatId }, 'Refusing to delete invalid chat id') + return false + } + + const chatsDir = getChatsDir() + const chatPath = path.join(chatsDir, safeChatId) + + if (!fs.existsSync(chatPath)) { + return false + } + + const stat = fs.statSync(chatPath) + if (!stat.isDirectory()) { + logger.warn( + { chatId, chatPath }, + 'Refusing to delete non-directory chat path', + ) + return false + } + + fs.rmSync(chatPath, { recursive: true, force: false }) + return true + } catch (error) { + logger.error( + { chatId, error: error instanceof Error ? error.message : String(error) }, + 'Failed to delete chat session', + ) + return false + } +} + /** * Format a timestamp relative to now (e.g., "2 hours ago", "yesterday") */ diff --git a/cli/src/utils/chat-scroll-accel.ts b/cli/src/utils/chat-scroll-accel.ts index 2d1ff38689..582de735cc 100644 --- a/cli/src/utils/chat-scroll-accel.ts +++ b/cli/src/utils/chat-scroll-accel.ts @@ -1,9 +1,9 @@ import { Queue } from './arrays' -import { clamp } from './math' import { getCliEnv } from './env' +import { clamp } from './math' -import type { ScrollAcceleration } from '@opentui/core' import type { CliEnv } from '../types/env' +import type { ScrollAcceleration } from '@opentui/core' const ENVIRONMENT_TYPE_VARS = [ 'TERM_PROGRAM', diff --git a/cli/src/utils/chatgpt-oauth.ts b/cli/src/utils/chatgpt-oauth.ts new file mode 100644 index 0000000000..eb677aa26c --- /dev/null +++ b/cli/src/utils/chatgpt-oauth.ts @@ -0,0 +1,322 @@ +/** + * ChatGPT OAuth PKCE flow for connecting a user's ChatGPT subscription. + * Experimental and feature-flagged. + */ + +import crypto from 'crypto' +import http from 'http' + +import { + CHATGPT_OAUTH_AUTHORIZE_URL, + CHATGPT_OAUTH_CLIENT_ID, + CHATGPT_OAUTH_REDIRECT_URI, + CHATGPT_OAUTH_TOKEN_URL, +} from '@codebuff/common/constants/chatgpt-oauth' +import { + clearChatGptOAuthCredentials, + getChatGptOAuthCredentials, + isChatGptOAuthValid, + resetChatGptOAuthRateLimit, + saveChatGptOAuthCredentials, +} from '@codebuff/sdk' +import { safeOpen } from './open-url' + +import type { ChatGptOAuthCredentials } from '@codebuff/sdk' + +function parseOAuthTokenResponse(data: unknown): { + accessToken: string + refreshToken: string + expiresInMs: number +} { + if (!data || typeof data !== 'object') { + throw new Error('Invalid token response format from ChatGPT OAuth.') + } + + const tokenData = data as { + access_token?: unknown + refresh_token?: unknown + expires_in?: unknown + } + + if ( + typeof tokenData.access_token !== 'string' || + tokenData.access_token.trim().length === 0 + ) { + throw new Error('Token exchange did not return a valid access token.') + } + + const refreshToken = + typeof tokenData.refresh_token === 'string' ? tokenData.refresh_token : '' + const expiresInMs = + typeof tokenData.expires_in === 'number' && + Number.isFinite(tokenData.expires_in) && + tokenData.expires_in > 0 + ? tokenData.expires_in * 1000 + : 3600 * 1000 + + return { + accessToken: tokenData.access_token, + refreshToken, + expiresInMs, + } +} + +function toBase64Url(buffer: Buffer): string { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +function generateCodeVerifier(): string { + return toBase64Url(crypto.randomBytes(32)) +} + +function generateCodeChallenge(verifier: string): string { + return toBase64Url(crypto.createHash('sha256').update(verifier).digest()) +} + +let pendingCodeVerifier: string | null = null +let pendingState: string | null = null + +export function startChatGptOAuthFlow(): { codeVerifier: string; authUrl: string } { + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = codeVerifier + + pendingCodeVerifier = codeVerifier + pendingState = state + + const authUrl = new URL(CHATGPT_OAUTH_AUTHORIZE_URL) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('client_id', CHATGPT_OAUTH_CLIENT_ID) + authUrl.searchParams.set('redirect_uri', CHATGPT_OAUTH_REDIRECT_URI) + authUrl.searchParams.set('code_challenge', codeChallenge) + authUrl.searchParams.set('code_challenge_method', 'S256') + authUrl.searchParams.set('state', state) + authUrl.searchParams.set('scope', 'openid profile email offline_access') + authUrl.searchParams.set('id_token_add_organizations', 'true') + authUrl.searchParams.set('codex_cli_simplified_flow', 'true') + authUrl.searchParams.set('originator', 'codex_cli_rs') + + return { codeVerifier, authUrl: authUrl.toString() } +} + +const CALLBACK_SERVER_TIMEOUT_MS = 5 * 60 * 1000 + +let callbackServer: http.Server | null = null + +export function stopChatGptOAuthServer(): void { + if (callbackServer) { + try { callbackServer.close() } catch { /* ignore */ } + callbackServer = null + } + pendingCodeVerifier = null + pendingState = null +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +} + +function callbackPageHtml(success: boolean, errorMessage?: string): string { + const title = success ? 'Connected — Codebuff' : 'Connection Failed — Codebuff' + const heading = success ? '✓ Connected to ChatGPT' : 'Connection Failed' + const headingColor = success ? '#4ade80' : '#f87171' + const body = success + ? 'You can close this tab and return to Codebuff.' + : `${escapeHtml(errorMessage ?? 'Unknown error')}. Return to Codebuff and try /connect:chatgpt again.` + return ` +${title} + +

+

${heading}

+

${body}

+
` +} + +function startCallbackServer(codeVerifier: string): Promise { + const redirectUrl = new URL(CHATGPT_OAUTH_REDIRECT_URI) + const port = parseInt(redirectUrl.port, 10) + const callbackPath = redirectUrl.pathname + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + stopChatGptOAuthServer() + reject(new Error('Timeout waiting for ChatGPT authorization')) + }, CALLBACK_SERVER_TIMEOUT_MS) + + const server = http.createServer(async (req, res) => { + const reqUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) + + if (reqUrl.pathname !== callbackPath) { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not found') + return + } + + const code = reqUrl.searchParams.get('code') + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/html' }) + res.end(callbackPageHtml(false, 'No authorization code received.')) + clearTimeout(timeout) + stopChatGptOAuthServer() + reject(new Error('No authorization code in callback')) + return + } + + const state = reqUrl.searchParams.get('state') + if (pendingState && (!state || state !== pendingState)) { + res.writeHead(400, { 'Content-Type': 'text/html' }) + res.end(callbackPageHtml(false, 'OAuth state mismatch. Please try again.')) + clearTimeout(timeout) + stopChatGptOAuthServer() + reject(new Error('OAuth state mismatch in callback')) + return + } + + try { + const fullCallbackUrl = `${CHATGPT_OAUTH_REDIRECT_URI}${reqUrl.search}` + const credentials = await exchangeChatGptCodeForTokens(fullCallbackUrl, codeVerifier) + + res.writeHead(200, { 'Content-Type': 'text/html' }) + res.end(callbackPageHtml(true)) + + clearTimeout(timeout) + stopChatGptOAuthServer() + resolve(credentials) + } catch (err) { + const message = err instanceof Error ? err.message : 'Token exchange failed' + res.writeHead(500, { 'Content-Type': 'text/html' }) + res.end(callbackPageHtml(false, message)) + + clearTimeout(timeout) + stopChatGptOAuthServer() + reject(err instanceof Error ? err : new Error(message)) + } + }) + + server.on('error', (err) => { + clearTimeout(timeout) + callbackServer = null + reject(err) + }) + + server.listen(port, '127.0.0.1', () => { + callbackServer = server + }) + }) +} + +export function connectChatGptOAuth(): { + authUrl: string + credentials: Promise +} { + stopChatGptOAuthServer() + + const { codeVerifier, authUrl } = startChatGptOAuthFlow() + const credentials = startCallbackServer(codeVerifier) + + void safeOpen(authUrl) + + return { authUrl, credentials } +} + +function parseAuthCodeInput(input: string): { code: string; state?: string } { + const trimmed = input.trim() + + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + const callback = new URL(trimmed) + const code = callback.searchParams.get('code') + const state = callback.searchParams.get('state') ?? undefined + + if (!code) { + throw new Error('No authorization code found in callback URL.') + } + + return { code, state } + } + + return { code: trimmed } +} + +export async function exchangeChatGptCodeForTokens( + authCodeInput: string, + codeVerifier?: string, +): Promise { + const verifier = codeVerifier ?? pendingCodeVerifier + if (!verifier) { + throw new Error('No PKCE verifier found. Please run /connect:chatgpt again.') + } + + const { code, state } = parseAuthCodeInput(authCodeInput) + + if (pendingState && state && pendingState !== state) { + throw new Error('OAuth state mismatch. Please restart /connect:chatgpt.') + } + + const response = await fetch(CHATGPT_OAUTH_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: CHATGPT_OAUTH_CLIENT_ID, + redirect_uri: CHATGPT_OAUTH_REDIRECT_URI, + code, + code_verifier: verifier, + }), + }) + + if (!response.ok) { + throw new Error( + `Failed to exchange ChatGPT OAuth code (status ${response.status}). Please retry /connect:chatgpt.`, + ) + } + + const data = await response.json() + const tokenResponse = parseOAuthTokenResponse(data) + + const credentials: ChatGptOAuthCredentials = { + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + expiresAt: Date.now() + tokenResponse.expiresInMs, + connectedAt: Date.now(), + } + + saveChatGptOAuthCredentials(credentials) + resetChatGptOAuthRateLimit() + pendingCodeVerifier = null + pendingState = null + + return credentials +} + +export function disconnectChatGptOAuth(): void { + stopChatGptOAuthServer() + clearChatGptOAuthCredentials() + resetChatGptOAuthRateLimit() +} + +export function getChatGptOAuthStatus(): { + connected: boolean + expiresAt?: number + connectedAt?: number +} { + const credentials = getChatGptOAuthCredentials() + if (!credentials) { + return { connected: false } + } + + if (!isChatGptOAuthValid()) { + return { connected: false } + } + + return { + connected: true, + expiresAt: credentials.expiresAt, + connectedAt: credentials.connectedAt, + } +} diff --git a/cli/src/utils/claude-oauth.ts b/cli/src/utils/claude-oauth.ts deleted file mode 100644 index 80bea18418..0000000000 --- a/cli/src/utils/claude-oauth.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Claude OAuth PKCE flow implementation for connecting to user's Claude Pro/Max subscription. - */ - -import crypto from 'crypto' -import open from 'open' -import { CLAUDE_OAUTH_CLIENT_ID } from '@codebuff/common/constants/claude-oauth' -import { - saveClaudeOAuthCredentials, - clearClaudeOAuthCredentials, - getClaudeOAuthCredentials, - isClaudeOAuthValid, - resetClaudeOAuthRateLimit, -} from '@codebuff/sdk' - -import type { ClaudeOAuthCredentials } from '@codebuff/sdk' - -// PKCE code verifier and challenge generation -function generateCodeVerifier(): string { - // Generate 32 random bytes and encode as base64url - const buffer = crypto.randomBytes(32) - return buffer - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, '') -} - -function generateCodeChallenge(verifier: string): string { - // SHA256 hash of the verifier, encoded as base64url - const hash = crypto.createHash('sha256').update(verifier).digest() - return hash - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, '') -} - -// Store the code verifier and state during the OAuth flow -let pendingCodeVerifier: string | null = null - -/** - * Start the OAuth authorization flow. - * Opens the browser to Anthropic's authorization page. - * @returns The code verifier to be used when exchanging the authorization code - */ -export function startOAuthFlow(): { codeVerifier: string; authUrl: string } { - const codeVerifier = generateCodeVerifier() - const codeChallenge = generateCodeChallenge(codeVerifier) - - // Store the code verifier and state for later use - pendingCodeVerifier = codeVerifier - - // Build the authorization URL - // Use claude.ai for Max subscription (same as opencode) - const authUrl = new URL('https://claude.ai/oauth/authorize') - authUrl.searchParams.set('code', 'true') - authUrl.searchParams.set('client_id', CLAUDE_OAUTH_CLIENT_ID) - authUrl.searchParams.set('response_type', 'code') - authUrl.searchParams.set( - 'redirect_uri', - 'https://console.anthropic.com/oauth/code/callback', - ) - authUrl.searchParams.set( - 'scope', - 'org:create_api_key user:profile user:inference', - ) - authUrl.searchParams.set('code_challenge', codeChallenge) - authUrl.searchParams.set('code_challenge_method', 'S256') - authUrl.searchParams.set('state', codeVerifier) // opencode uses verifier as state - - return { codeVerifier, authUrl: authUrl.toString() } -} - -/** - * Open the browser to start OAuth flow. - */ -export async function openOAuthInBrowser(): Promise { - const { authUrl, codeVerifier } = startOAuthFlow() - await open(authUrl) - return codeVerifier -} - -/** - * Exchange an authorization code for access and refresh tokens. - */ -export async function exchangeCodeForTokens( - authorizationCode: string, - codeVerifier?: string, -): Promise { - const verifier = codeVerifier ?? pendingCodeVerifier - if (!verifier) { - throw new Error( - 'No code verifier found. Please start the OAuth flow again.', - ) - } - - // The authorization code from claude.ai comes in format: code#state - // We need to split it and send both parts - const splits = authorizationCode.trim().split('#') - const code = splits[0] - const state = splits[1] - - // Use the v1 OAuth token endpoint (same as opencode) - const response = await fetch('https://console.anthropic.com/v1/oauth/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - code: code, - state: state, - grant_type: 'authorization_code', - client_id: CLAUDE_OAUTH_CLIENT_ID, - redirect_uri: 'https://console.anthropic.com/oauth/code/callback', - code_verifier: verifier, - }), - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Failed to exchange code for tokens: ${errorText}`) - } - - const data = await response.json() - - // Clear the pending code verifier - pendingCodeVerifier = null - - const credentials: ClaudeOAuthCredentials = { - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresAt: Date.now() + data.expires_in * 1000, - connectedAt: Date.now(), - } - - // Save credentials to file - saveClaudeOAuthCredentials(credentials) - - // Reset any cached rate limit since user just reconnected - resetClaudeOAuthRateLimit() - - return credentials -} - -/** - * Disconnect from Claude OAuth (clear credentials). - */ -export function disconnectClaudeOAuth(): void { - clearClaudeOAuthCredentials() -} - -/** - * Get the current Claude OAuth connection status. - */ -export function getClaudeOAuthStatus(): { - connected: boolean - expiresAt?: number - connectedAt?: number -} { - if (!isClaudeOAuthValid()) { - return { connected: false } - } - - const credentials = getClaudeOAuthCredentials() - if (!credentials) { - return { connected: false } - } - - return { - connected: true, - expiresAt: credentials.expiresAt, - connectedAt: credentials.connectedAt, - } -} 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 9608dc840f..02d6f8eb28 100644 --- a/cli/src/utils/clipboard.ts +++ b/cli/src/utils/clipboard.ts @@ -1,7 +1,23 @@ +import { closeSync, openSync, writeSync } from 'fs' 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 @@ -81,27 +97,19 @@ export async function copyTextToClipboard( } try { - if (typeof navigator !== 'undefined' && navigator.clipboard) { - await navigator.clipboard.writeText(text) - } else if (typeof process !== 'undefined' && process.platform) { - // NOTE: Inline require() is used because this code path only runs in Node.js - // environments, and we need to check process.platform at runtime first - const { execSync } = require('child_process') as { - execSync: (command: string, options: { input: string }) => void - } - if (process.platform === 'darwin') { - execSync('pbcopy', { input: text }) - } else if (process.platform === 'linux') { - try { - execSync('xclip -selection clipboard', { input: text }) - } catch { - execSync('xsel --clipboard --input', { input: text }) - } - } else if (process.platform === 'win32') { - execSync('clip', { input: text }) - } + let copied: boolean + if (isRemoteSession()) { + // 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 { - return + // 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) { + throw new Error('No clipboard method available') } if (!suppressGlobalMessage) { @@ -131,3 +139,93 @@ export function clearClipboardMessage() { } emitClipboardMessage(null) } + + +// ============================================================================= +// OSC52 Clipboard Support +// ============================================================================= +// OSC52 writes to clipboard via terminal escape sequences - works over SSH +// because the client terminal handles clipboard. Format: ESC ] 52 ; c ; BEL +// tmux/screen require passthrough wrapping to forward the sequence. + +export function isRemoteSession(): boolean { + const env = getCliEnv() + return !!(env.SSH_CLIENT || env.SSH_TTY || env.SSH_CONNECTION) +} + +function tryCopyViaPlatformTool(text: string): boolean { + const { execSync } = require('child_process') as typeof import('child_process') + const opts = { input: text, stdio: ['pipe', 'ignore', 'ignore'] as ('pipe' | 'ignore')[] } + + try { + if (process.platform === 'darwin') { + execSync('pbcopy', opts) + } else if (process.platform === 'linux') { + try { + execSync('xclip -selection clipboard', opts) + } catch { + execSync('xsel --clipboard --input', opts) + } + } else if (process.platform === 'win32') { + execSync('clip', opts) + } else { + return false + } + return true + } catch { + return false + } +} + +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 + +function buildOsc52Sequence(text: string): string | null { + const env = getCliEnv() + if (env.TERM === 'dumb') return null + + const base64 = Buffer.from(text, 'utf8').toString('base64') + if (base64.length > OSC52_MAX_PAYLOAD) return null + + const osc = `\x1b]52;c;${base64}\x07` + + // tmux: wrap in DCS passthrough with doubled ESC + if (env.TMUX) { + return `\x1bPtmux;${osc.replace(/\x1b/g, '\x1b\x1b')}\x1b\\` + } + + // GNU screen: wrap in DCS passthrough + if (env.STY) { + return `\x1bP${osc}\x1b\\` + } + + return osc +} + +function tryCopyViaOsc52(text: string): boolean { + const sequence = buildOsc52Sequence(text) + if (!sequence) return false + + const ttyPath = process.platform === 'win32' ? 'CON' : '/dev/tty' + let fd: number | null = null + try { + fd = openSync(ttyPath, 'w') + writeSync(fd, sequence) + return true + } catch { + return false + } finally { + if (fd !== null) closeSync(fd) + } +} diff --git a/cli/src/utils/code-search-summary.ts b/cli/src/utils/code-search-summary.ts new file mode 100644 index 0000000000..307b1bd5df --- /dev/null +++ b/cli/src/utils/code-search-summary.ts @@ -0,0 +1,70 @@ +import { getAgentBaseName } from './message-block-helpers' + +import type { + AgentContentBlock, + ContentBlock, + ToolContentBlock, +} from '../types/chat' + +export function countCodeSearchResults(output?: string): number { + if (!output) { + return 0 + } + + const lines = output.split('\n') + const matchCountLine = lines.find((line) => + /^Found \d+ match(?:es)?$/.test(line.trim()), + ) + const parsedTotalResults = matchCountLine + ?.trim() + .match(/^Found (\d+) match(?:es)?$/)?.[1] + + if (parsedTotalResults !== undefined) { + return Number(parsedTotalResults) + } + + return lines.reduce((total, line) => { + const trimmed = line.trim() + return /^(?:Line\s+)?\d+:/.test(trimmed) ? total + 1 : total + }, 0) +} + +const pluralize = (count: number, singular: string, plural = `${singular}s`) => + `${count} ${count === 1 ? singular : plural}` + +const isCodeSearchToolBlock = ( + block: ContentBlock, +): block is ToolContentBlock => + block.type === 'tool' && block.toolName === 'code_search' + +export function getCodeSearcherCollapsedPreview( + agentBlock: AgentContentBlock, +): string | undefined { + if (getAgentBaseName(agentBlock.agentType) !== 'code-searcher') { + return undefined + } + + const toolBlocks = (agentBlock.blocks ?? []).filter(isCodeSearchToolBlock) + const searchQueries = Array.isArray(agentBlock.params?.searchQueries) + ? agentBlock.params.searchQueries + : [] + const searchCount = searchQueries.length || toolBlocks.length + + if (searchCount === 0) { + return undefined + } + + const completedToolBlocks = toolBlocks.filter((block) => block.output) + const searchLabel = pluralize(searchCount, 'search', 'searches') + + if (completedToolBlocks.length === 0) { + return searchLabel + } + + const totalResults = completedToolBlocks.reduce( + (total, block) => total + countCodeSearchResults(block.output), + 0, + ) + + return `${searchLabel} · ${pluralize(totalResults, 'result')}` +} diff --git a/cli/src/utils/codebuff-api.ts b/cli/src/utils/codebuff-api.ts index 78ad9c3f6c..8300688c3a 100644 --- a/cli/src/utils/codebuff-api.ts +++ b/cli/src/utils/codebuff-api.ts @@ -1,8 +1,10 @@ import { WEBSITE_URL } from '@codebuff/sdk' +import { getSystemProcessEnv } from './env' import type { PublishAgentsResponse, } from '@codebuff/common/types/api/agents/publish' +import type { FeedbackRequest } from '@codebuff/common/schemas/feedback' /** * API response types for consistent error handling. @@ -19,10 +21,10 @@ export type ApiResponse = // ============================================================================ /** User fields that can be fetched from /api/v1/me */ -export type UserField = 'id' | 'email' | 'discord_id' | 'referral_code' +export type UserField = 'id' | 'email' | 'discord_id' export type UserDetails = { - [K in T]: K extends 'discord_id' | 'referral_code' ? string | null : string + [K in T]: K extends 'discord_id' ? string | null : string } export interface UsageRequest { @@ -57,21 +59,16 @@ export interface LoginStatusResponse { user?: Record } -export interface ReferralRequest { - referralCode: string -} - -export interface ReferralResponse { - credits_redeemed?: number - error?: string -} - export interface LogoutRequest { userId?: string fingerprintId?: string fingerprintHash?: string } +export interface FeedbackResponse { + success: boolean +} + /** * Retry configuration */ @@ -107,6 +104,13 @@ export interface CodebuffApiClientConfig { defaultTimeoutMs?: number /** Default retry configuration */ retry?: RetryConfig + /** + * Proxy URL to use for all requests. + * If not set, falls back to HTTPS_PROXY / https_proxy / HTTP_PROXY / http_proxy + * environment variables. Set to null to explicitly disable proxy even if env + * vars are present. + */ + proxy?: string | null } /** @@ -186,9 +190,6 @@ export interface CodebuffApiClient { req: LoginStatusRequest, ): Promise> - /** Redeem a referral code via /api/referrals */ - referral(req: ReferralRequest): Promise> - /** Publish agents via /api/agents/publish */ publish( data: Record[], @@ -197,6 +198,26 @@ export interface CodebuffApiClient { /** Logout via /api/auth/cli/logout */ logout(req?: LogoutRequest): Promise> + + /** Submit feedback via /api/v1/feedback */ + feedback(req: FeedbackRequest): Promise> +} + +/** + * Resolve the proxy URL from standard environment variables. + * Priority: HTTPS_PROXY > https_proxy > HTTP_PROXY > http_proxy + * Returns undefined when no proxy is configured. + */ +export function resolveProxyUrl( + env: Record = getSystemProcessEnv(), +): string | undefined { + return ( + env['HTTPS_PROXY'] || + env['https_proxy'] || + env['HTTP_PROXY'] || + env['http_proxy'] || + undefined + ) } /** @@ -257,8 +278,16 @@ export function createCodebuffApiClient( fetch: fetchFn = fetch, defaultTimeoutMs = 30000, retry: defaultRetryConfig = {}, + proxy: proxyConfig, } = config + // Resolve proxy: explicit config wins, then env vars, then no proxy. + // Pass proxy: null to explicitly disable even when env vars are set. + const proxyUrl: string | undefined = + proxyConfig === null + ? undefined + : (proxyConfig ?? resolveProxyUrl()) + const mergedDefaultRetry: Required = { ...DEFAULT_RETRY_CONFIG, ...defaultRetryConfig, @@ -325,7 +354,12 @@ export function createCodebuffApiClient( const response = await fetchFn(url, { ...fetchOptions, signal: controller.signal, - }) + // Bun supports a `proxy` option on fetch. When a proxy URL is + // resolved (from config or env vars) we pass it here so that all + // API calls are tunnelled through the proxy. The cast is required + // because the WhatWG RequestInit type does not include `proxy`. + ...(proxyUrl ? { proxy: proxyUrl } : {}), + } as RequestInit) clearTimeout(timeoutId) @@ -393,7 +427,15 @@ export function createCodebuffApiClient( continue } - // Don't retry, throw the error + // Don't retry, throw the error with URL context + if (error instanceof Error) { + const enhancedError = new Error( + `${error.message} (${method} ${url})`, + ) + enhancedError.name = error.name + enhancedError.cause = error + throw enhancedError + } throw error } } @@ -480,17 +522,6 @@ export function createCodebuffApiClient( }) }, - referral(req: ReferralRequest): Promise> { - // Auth is sent via Authorization header (includeAuth defaults to true) - // Also include cookie for legacy web session support - return request( - 'POST', - '/api/referrals', - { referralCode: req.referralCode }, - { includeCookie: true }, - ) - }, - publish( data: Record[], allLocalAgentIds?: string[], @@ -510,6 +541,13 @@ export function createCodebuffApiClient( fingerprintHash: req.fingerprintHash, }) }, + + feedback(req: FeedbackRequest): Promise> { + return request('POST', '/api/v1/feedback', req, { + // Feedback submissions are not idempotent server-side yet, so avoid automatic retries. + retry: false, + }) + }, } } diff --git a/cli/src/utils/codebuff-client.ts b/cli/src/utils/codebuff-client.ts index e6adf46634..d06e6811f1 100644 --- a/cli/src/utils/codebuff-client.ts +++ b/cli/src/utils/codebuff-client.ts @@ -76,6 +76,7 @@ export async function getCodebuffClient(): Promise { apiKey, cwd: projectRoot, agentDefinitions, + logger, overrideTools: { ask_user: async (input: ClientToolCall<'ask_user'>['input']) => { const askUserResponse = await AskUserBridge.request( diff --git a/cli/src/utils/collapse-helpers.ts b/cli/src/utils/collapse-helpers.ts new file mode 100644 index 0000000000..0a05993eb1 --- /dev/null +++ b/cli/src/utils/collapse-helpers.ts @@ -0,0 +1,256 @@ +/** + * Pure utility functions for collapse/expand all functionality. + */ + +import type { ChatMessage, ContentBlock, TextContentBlock, ThinkingCollapseState } from '../types/chat' + +/** + * Type representing a block that supports collapsing. + * This includes: thinking blocks (text with thinkingId), agent blocks, tool blocks, and agent-list blocks. + */ +type CollapsibleBlock = ContentBlock & { + isCollapsed?: boolean + userOpened?: boolean +} + +/** + * Checks if a block is a thinking text block (text with thinkingId). + * These use thinkingCollapseState instead of isCollapsed. + */ +function isThinkingTextBlock(block: ContentBlock): block is TextContentBlock { + return block.type === 'text' && 'thinkingId' in block && !!block.thinkingId +} + +/** + * Checks if a content block is collapsible. + * Collapsible blocks are: thinking blocks (text with thinkingId), agent, tool, and agent-list blocks. + */ +function isCollapsibleBlock(block: ContentBlock): block is CollapsibleBlock { + if (block.type === 'text' && 'thinkingId' in block && block.thinkingId) { + return true + } + if (block.type === 'agent' || block.type === 'tool' || block.type === 'agent-list') { + return true + } + return false +} + +/** + * Checks if a collapsible block is explicitly expanded. + * Thinking blocks use thinkingCollapseState; others use isCollapsed. + */ +function isBlockExpanded(block: CollapsibleBlock): boolean { + if (isThinkingTextBlock(block)) { + return block.thinkingCollapseState === 'expanded' + } + return block.isCollapsed === false +} + +/** + * Gets the current collapsed state of a block. + * Thinking blocks use thinkingCollapseState; others use isCollapsed. + */ +function getBlockCollapsedState(block: CollapsibleBlock): boolean { + if (isThinkingTextBlock(block)) { + return block.thinkingCollapseState !== 'expanded' + } + return block.isCollapsed ?? true +} + +/** + * Creates an updated block with new collapsed state if different from current. + * Returns null if no change is needed. + * Thinking blocks use thinkingCollapseState; others use isCollapsed. + */ +function createUpdatedBlock( + block: CollapsibleBlock, + collapsed: boolean, +): CollapsibleBlock | null { + if (isThinkingTextBlock(block)) { + const targetState: ThinkingCollapseState = collapsed ? 'hidden' : 'expanded' + if (block.thinkingCollapseState === targetState) { + return null + } + return { + ...block, + thinkingCollapseState: targetState, + userOpened: !collapsed ? true : block.userOpened, + } + } + const currentCollapsed = getBlockCollapsedState(block) + if (currentCollapsed === collapsed) { + return null + } + return { + ...block, + isCollapsed: collapsed, + userOpened: !collapsed ? true : block.userOpened, + } +} + +/** + * Checks if any collapsible block in the given blocks array is expanded. + * Recursively checks nested blocks within agent blocks. + */ +function hasAnyExpandedBlocksRecursive(blocks: ContentBlock[]): boolean { + for (const block of blocks) { + if (isCollapsibleBlock(block)) { + if (isBlockExpanded(block)) { + return true + } + // Recursively check nested blocks in agent blocks + if (block.type === 'agent' && block.blocks) { + if (hasAnyExpandedBlocksRecursive(block.blocks)) { + return true + } + } + } + } + return false +} + +/** + * Checks if any collapsible block in the messages array is expanded. + * Returns true if at least one block is not collapsed. + * + * @param messages - The messages array to check + * @returns true if any block is expanded, false if all are collapsed + */ +export function hasAnyExpandedBlocks(messages: ChatMessage[]): boolean { + for (const message of messages) { + // Handle agent variant messages + if (message.variant === 'agent') { + if (message.metadata?.isCollapsed === false) { + return true + } + } + + // Handle blocks within messages + if (message.blocks && hasAnyExpandedBlocksRecursive(message.blocks)) { + return true + } + } + + return false +} + +/** + * Result type for recursive block update operation. + */ +interface UpdateBlocksResult { + blocks: ContentBlock[] + changed: boolean +} + +/** + * Recursively updates isCollapsed on all collapsible blocks. + * Returns both the updated blocks and whether any changes were made. + */ +function updateBlocksRecursively( + blocks: ContentBlock[], + collapsed: boolean, +): UpdateBlocksResult { + let anyChanged = false + const result = blocks.map((block) => { + if (!isCollapsibleBlock(block)) { + return block + } + + // Handle agent blocks specially due to nested blocks + if (block.type === 'agent') { + const currentCollapsed = getBlockCollapsedState(block) + let updatedBlock = block + let blockChanged = false + + // Check if this block's state needs updating + if (currentCollapsed !== collapsed) { + blockChanged = true + updatedBlock = { + ...block, + isCollapsed: collapsed, + userOpened: !collapsed ? true : block.userOpened, + } + } + + // Recursively update nested blocks + if (block.blocks) { + const nested = updateBlocksRecursively(block.blocks, collapsed) + if (nested.changed) { + blockChanged = true + updatedBlock = { + ...updatedBlock, + blocks: nested.blocks, + } + } + } + + if (blockChanged) { + anyChanged = true + return updatedBlock + } + return block + } + + // Handle all other collapsible blocks (tool, text with thinkingId, agent-list) + const updated = createUpdatedBlock(block, collapsed) + if (updated) { + anyChanged = true + return updated + } + return block + }) + + return { blocks: anyChanged ? result : blocks, changed: anyChanged } +} + +/** + * Updates all collapsible blocks in all messages to the specified collapsed state. + * This is a pure function that returns new message objects when changes are made. + * + * @param messages - The messages array to update + * @param collapsed - Whether blocks should be collapsed (true) or expanded (false) + * @returns Updated messages array with all collapsible blocks set to the specified state + */ +export function setAllBlocksCollapsedState( + messages: ChatMessage[], + collapsed: boolean, +): ChatMessage[] { + return messages.map((message) => { + let updatedMessage = message + let messageChanged = false + + // Handle agent variant messages (message-level isCollapsed) + if (message.variant === 'agent') { + // Treat undefined as collapsed (true) to match hasAnyExpandedBlocks semantics + const currentCollapsed = message.metadata?.isCollapsed ?? true + if (currentCollapsed !== collapsed) { + messageChanged = true + updatedMessage = { + ...updatedMessage, + metadata: { + ...updatedMessage.metadata, + isCollapsed: collapsed, + userOpened: !collapsed ? true : updatedMessage.metadata?.userOpened, + }, + } + } + } + + // Handle blocks within messages (applies to all message variants) + if (message.blocks) { + const { blocks: updatedBlocks, changed } = updateBlocksRecursively( + message.blocks, + collapsed, + ) + if (changed) { + messageChanged = true + updatedMessage = { + ...updatedMessage, + blocks: updatedBlocks, + } + } + } + + return messageChanged ? updatedMessage : message + }) +} diff --git a/cli/src/utils/constants.ts b/cli/src/utils/constants.ts index 8d9310f88a..bc1d2e59ab 100644 --- a/cli/src/utils/constants.ts +++ b/cli/src/utils/constants.ts @@ -1,6 +1,33 @@ +import type { ToolName } from '@codebuff/sdk' + +import { getCliEnv } from './env' + +/** + * Freebuff build-time flag. When true, the CLI is built as Freebuff (free-only variant). + * Injected via --define at compile time; enables dead-code elimination by the bundler. + */ +export const IS_FREEBUFF = getCliEnv().FREEBUFF_MODE === 'true' + +/** Message shown when the user ends a freebuff session early. */ +export const END_SESSION_MESSAGE = + 'Ending session and returning to the model picker…' + // Agent IDs that should not be rendered in the CLI UI export const HIDDEN_AGENT_IDS = ['codebuff/context-pruner'] as const +// Tool names that should be collapsed by default when rendered +// Uses ToolName type to ensure only valid tool names are added +export const COLLAPSED_BY_DEFAULT_TOOL_NAMES: readonly ToolName[] = [ + 'set_output', +] as const + +/** + * Check if a tool should be collapsed by default + */ +export const shouldCollapseToolByDefault = (toolName: string): boolean => { + return COLLAPSED_BY_DEFAULT_TOOL_NAMES.includes(toolName as ToolName) +} + /** * Check if an agent ID should be hidden from rendering */ @@ -14,8 +41,7 @@ export const COLLAPSED_BY_DEFAULT_AGENT_IDS = [ 'code-reviewer-selector', 'thinker-selector', 'best-of-n-selector', - 'commander', - 'commander-lite', + 'basher', 'code-searcher', 'directory-lister', 'glob-matcher', @@ -32,10 +58,45 @@ export const shouldCollapseByDefault = (agentType: string): boolean => { ) } +/** + * Rules for collapsing child agents when spawned by specific parent agents. + * Key: parent agent type pattern, Value: array of child agent type patterns to collapse + */ +export const PARENT_CHILD_COLLAPSE_RULES: Record = { + 'code-reviewer-multi-prompt': ['code-reviewer'], +} + +/** + * Check if a child agent should be collapsed when spawned by a specific parent + */ +export const shouldCollapseForParent = ( + childAgentType: string, + parentAgentType: string | undefined, +): boolean => { + if (!parentAgentType) { + return false + } + + for (const [parentPattern, childPatterns] of Object.entries( + PARENT_CHILD_COLLAPSE_RULES, + )) { + if (parentAgentType.includes(parentPattern)) { + for (const childPattern of childPatterns) { + if (childAgentType.includes(childPattern)) { + return true + } + } + } + } + + return false +} + // Agent IDs that should render as simple text instead of full agent boxes export const SIMPLE_TEXT_AGENT_IDS = [ 'best-of-n-selector', 'best-of-n-selector-gemini', + 'best-of-n-selector2', ] as const /** @@ -47,6 +108,16 @@ export const shouldRenderAsSimpleText = (agentType: string): boolean => { ) } +// Agent IDs that show progress-focused previews (multi-prompt editors) +export const MULTI_PROMPT_EDITOR_IDS = ['editor-multi-prompt'] as const + +/** + * Check if an agent should show progress-focused preview when collapsed + */ +export const isMultiPromptEditor = (agentType: string): boolean => { + return MULTI_PROMPT_EDITOR_IDS.some((id) => agentType.includes(id)) +} + /** * The parent agent ID for all root-level agents */ @@ -55,13 +126,34 @@ export const MAIN_AGENT_ID = 'main-agent' /** * Mapping from agent mode to agent ID. * Single source of truth for all agent modes (order = cycling order). + * + * Freebuff resolves LITE through the selected freebuff model at send time; + * this fallback stays on base2-free for non-runtime callers. Regular + * Codebuff maps LITE to base2-lite which charges credits normally. */ export const AGENT_MODE_TO_ID = { DEFAULT: 'base2', - LITE: 'base2-lite', + LITE: IS_FREEBUFF ? 'base2-free' : 'base2-lite', MAX: 'base2-max', PLAN: 'base2-plan', } as const export type AgentMode = keyof typeof AGENT_MODE_TO_ID export const AGENT_MODES = Object.keys(AGENT_MODE_TO_ID) as AgentMode[] + +/** + * Maps CLI agent mode to cost mode for billing. + * + * Freebuff's LITE maps to 'free' cost mode (waiting room, rate limits, 0 credits + * for allowlisted agent+model combos). Regular Codebuff's LITE maps to 'lite' — + * a normal paid mode (charges credits, no waiting room, no country restrictions). + */ +export const AGENT_MODE_TO_COST_MODE = { + DEFAULT: 'normal', + LITE: IS_FREEBUFF ? 'free' : 'lite', + MAX: 'max', + PLAN: 'normal', +} as const satisfies Record< + AgentMode, + 'free' | 'lite' | 'normal' | 'max' | 'experimental' | 'ask' +> diff --git a/cli/src/utils/create-event-handler-state.ts b/cli/src/utils/create-event-handler-state.ts index c09b8d2101..07d866f455 100644 --- a/cli/src/utils/create-event-handler-state.ts +++ b/cli/src/utils/create-event-handler-state.ts @@ -1,10 +1,10 @@ import type { AgentMode } from './constants' +import type { MessageUpdater } from './message-updater' import type { EventHandlerState, SetStreamingAgentsFn, SetStreamStatusFn, } from './sdk-event-handlers' -import type { MessageUpdater } from './message-updater' import type { StreamController } from '../hooks/stream-state' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { MutableRefObject } from 'react' diff --git a/cli/src/utils/create-run-config.ts b/cli/src/utils/create-run-config.ts index 5a734af35b..e37d86d7de 100644 --- a/cli/src/utils/create-run-config.ts +++ b/cli/src/utils/create-run-config.ts @@ -1,5 +1,7 @@ import path from 'path' +import { MAX_AGENT_STEPS_DEFAULT } from '@codebuff/common/constants/agents' + import { createEventHandler, createStreamChunkHandler, @@ -23,6 +25,8 @@ export type CreateRunConfigParams = { agentDefinitions: AgentDefinition[] eventHandlerState: EventHandlerState signal: AbortSignal + costMode?: 'free' | 'lite' | 'normal' | 'max' | 'experimental' | 'ask' + extraCodebuffMetadata?: Record } const SENSITIVE_EXTENSIONS = new Set([ @@ -98,6 +102,8 @@ export const createRunConfig = (params: CreateRunConfigParams) => { previousRunState, agentDefinitions, eventHandlerState, + costMode, + extraCodebuffMetadata, } = params return { @@ -107,10 +113,12 @@ export const createRunConfig = (params: CreateRunConfigParams) => { content, previousRun: previousRunState ?? undefined, agentDefinitions, - maxAgentSteps: 100, + maxAgentSteps: MAX_AGENT_STEPS_DEFAULT, handleStreamChunk: createStreamChunkHandler(eventHandlerState), handleEvent: createEventHandler(eventHandlerState), signal: params.signal, + costMode, + extraCodebuffMetadata, fileFilter: ((filePath: string) => { if (isSensitiveFile(filePath)) return { status: 'blocked' } if (isEnvTemplateFile(filePath)) return { status: 'allow-example' } diff --git a/cli/src/utils/detect-shell.ts b/cli/src/utils/detect-shell.ts index f86d0a407e..dfb14e0e5d 100644 --- a/cli/src/utils/detect-shell.ts +++ b/cli/src/utils/detect-shell.ts @@ -1,8 +1,9 @@ import { execSync } from 'child_process' -import type { CliEnv } from '../types/env' import { getCliEnv } from './env' +import type { CliEnv } from '../types/env' + type KnownShell = | 'bash' | 'zsh' diff --git a/cli/src/utils/env.ts b/cli/src/utils/env.ts index 47965d5c29..e7a0148bdc 100644 --- a/cli/src/utils/env.ts +++ b/cli/src/utils/env.ts @@ -16,6 +16,20 @@ import type { CliEnv } from '../types/env' export const getCliEnv = (): CliEnv => ({ ...getBaseEnv(), + // Display server detection (Linux headless check) + DISPLAY: process.env.DISPLAY, + WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY, + + // Terminal detection (for tmux/screen passthrough) + TERM: process.env.TERM, + TMUX: process.env.TMUX, + STY: process.env.STY, + + // SSH/remote session detection + SSH_CLIENT: process.env.SSH_CLIENT, + SSH_TTY: process.env.SSH_TTY, + SSH_CONNECTION: process.env.SSH_CONNECTION, + // Terminal detection KITTY_WINDOW_ID: process.env.KITTY_WINDOW_ID, SIXEL_SUPPORT: process.env.SIXEL_SUPPORT, @@ -59,6 +73,7 @@ export const getCliEnv = (): CliEnv => ({ CODEBUFF_RG_PATH: process.env.CODEBUFF_RG_PATH, CODEBUFF_SCROLL_MULTIPLIER: process.env.CODEBUFF_SCROLL_MULTIPLIER, CODEBUFF_PERF_TEST: process.env.CODEBUFF_PERF_TEST, + FREEBUFF_MODE: process.env.FREEBUFF_MODE, }) /** diff --git a/cli/src/utils/error-handling.ts b/cli/src/utils/error-handling.ts index a7b19dfe83..0eb9a682cf 100644 --- a/cli/src/utils/error-handling.ts +++ b/cli/src/utils/error-handling.ts @@ -1,6 +1,14 @@ import { env } from '@codebuff/common/env' +import { extractApiErrorDetails } from '@codebuff/common/util/error' +import { formatFreebuffHardBlockedPrivacySignals } from '@codebuff/common/util/freebuff-privacy' import type { ChatMessage } from '../types/chat' +import type { + FreebuffCountryBlockReason, + FreebuffIpPrivacySignal, +} from '@codebuff/common/types/freebuff-session' + +import { IS_FREEBUFF } from './constants' const defaultAppUrl = env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'https://codebuff.com' @@ -37,8 +45,173 @@ export const isOutOfCreditsError = (error: unknown): boolean => { return false } +/** + * Check if an error indicates free mode is not available in the user's country. + * Standardized on statusCode === 403 + error === 'free_mode_unavailable'. + */ +export const isFreeModeUnavailableError = (error: unknown): boolean => { + const details = getCliApiErrorDetails(error) + return ( + details.statusCode === 403 && + details.errorCode === 'free_mode_unavailable' + ) +} + +const getTopLevelApiErrorDetails = ( + error: unknown, +): { + statusCode?: number + errorCode?: string + message?: string + countryCode?: string + countryBlockReason?: string + ipPrivacySignals?: string[] +} => { + if (!error || typeof error !== 'object') return {} + const statusCode = (error as { statusCode?: unknown }).statusCode + const status = (error as { status?: unknown }).status + const errorCode = (error as { error?: unknown }).error + const message = (error as { message?: unknown }).message + const countryCode = (error as { countryCode?: unknown }).countryCode + const countryBlockReason = (error as { countryBlockReason?: unknown }) + .countryBlockReason + const ipPrivacySignals = (error as { ipPrivacySignals?: unknown }) + .ipPrivacySignals + const resolvedStatusCode = + typeof statusCode === 'number' + ? statusCode + : typeof status === 'number' + ? status + : undefined + + return { + ...(resolvedStatusCode !== undefined && { statusCode: resolvedStatusCode }), + ...(typeof errorCode === 'string' && { errorCode }), + ...(typeof message === 'string' && message.length > 0 && { message }), + ...(typeof countryCode === 'string' && + countryCode.length > 0 && { countryCode }), + ...(typeof countryBlockReason === 'string' && { countryBlockReason }), + ...(Array.isArray(ipPrivacySignals) && { + ipPrivacySignals: ipPrivacySignals.filter( + (signal): signal is string => typeof signal === 'string', + ), + }), + } +} + +const getCliApiErrorDetails = (error: unknown) => { + const parsed = extractApiErrorDetails(error) + const topLevel = getTopLevelApiErrorDetails(error) + + return { + statusCode: topLevel.statusCode ?? parsed.statusCode, + errorCode: topLevel.errorCode ?? parsed.errorCode, + // Prefer responseBody messages over top-level HTTP status text. + message: parsed.message ?? topLevel.message, + countryCode: topLevel.countryCode ?? parsed.countryCode, + countryBlockReason: + topLevel.countryBlockReason ?? parsed.countryBlockReason, + ipPrivacySignals: topLevel.ipPrivacySignals ?? parsed.ipPrivacySignals, + } +} + +export const getFreebuffRateLimitErrorMessage = ( + error: unknown, +): string | null => { + const details = getCliApiErrorDetails(error) + if (details.statusCode !== 429) return null + if (details.errorCode === 'free_mode_rate_limited') { + return details.message ?? FREEBUFF_RATE_LIMIT_MESSAGE + } + return FREEBUFF_RATE_LIMIT_MESSAGE +} + +export const getCountryBlockFromFreeModeError = ( + error: unknown, +): { + countryCode: string + countryBlockReason?: FreebuffCountryBlockReason + ipPrivacySignals?: FreebuffIpPrivacySignal[] +} | null => { + if (!isFreeModeUnavailableError(error)) return null + const errorDetails = getCliApiErrorDetails(error) + const countryCode = + typeof errorDetails.countryCode === 'string' && + errorDetails.countryCode.length > 0 + ? errorDetails.countryCode + : 'UNKNOWN' + + return { + countryCode, + countryBlockReason: + typeof errorDetails.countryBlockReason === 'string' + ? (errorDetails.countryBlockReason as FreebuffCountryBlockReason) + : undefined, + ipPrivacySignals: errorDetails.ipPrivacySignals as + | FreebuffIpPrivacySignal[] + | undefined, + } +} + +export const getFreeModeUnavailableErrorMessage = ( + error: unknown, +): string => { + const details = getCliApiErrorDetails(error) + const block = getCountryBlockFromFreeModeError(error) + if (block?.countryBlockReason === 'anonymous_network') { + return `${IS_FREEBUFF ? 'Freebuff' : 'Free mode'} cannot be used from ${formatFreebuffHardBlockedPrivacySignals( + block.ipPrivacySignals, + )} traffic. Please disable it and try again.` + } + return details.message ?? FREE_MODE_UNAVAILABLE_MESSAGE +} + +/** + * Freebuff waiting-room gate errors returned by /api/v1/chat/completions. + * + * Contract (see docs/freebuff-waiting-room.md): + * - 428 `waiting_room_required` — no session row exists; POST /session to join. + * - 429 `waiting_room_queued` — row exists but still queued. + * - 409 `session_superseded` — another CLI rotated our instance id. + * - 409 `session_model_mismatch` — session tier/model no longer matches. + * - 410 `session_expired` — active session's expires_at has passed. + */ +export type FreebuffGateErrorKind = + | 'waiting_room_required' + | 'waiting_room_queued' + | 'session_superseded' + | 'session_model_mismatch' + | 'session_expired' + +const FREEBUFF_GATE_STATUS: Record = { + waiting_room_required: 428, + waiting_room_queued: 429, + session_superseded: 409, + session_model_mismatch: 409, + session_expired: 410, +} + +export const getFreebuffGateErrorKind = ( + error: unknown, +): FreebuffGateErrorKind | null => { + if (!error || typeof error !== 'object') return null + const errorCode = (error as { error?: unknown }).error + const statusCode = (error as { statusCode?: unknown }).statusCode + if (typeof errorCode !== 'string') return null + const expected = FREEBUFF_GATE_STATUS[errorCode as FreebuffGateErrorKind] + if (expected === undefined || statusCode !== expected) return null + return errorCode as FreebuffGateErrorKind +} + export const OUT_OF_CREDITS_MESSAGE = `Out of credits. Please add credits at ${defaultAppUrl}/usage` +export const FREEBUFF_RATE_LIMIT_MESSAGE = + 'Freebuff is temporarily busy. Please try again in a moment.' + +export const FREE_MODE_UNAVAILABLE_MESSAGE = IS_FREEBUFF + ? 'Freebuff is not available in your country.' + : 'Free mode is not available in your country. You can use another mode to continue.' + export const createErrorMessage = ( error: unknown, aiMessageId: string, diff --git a/cli/src/utils/feedback-helpers.ts b/cli/src/utils/feedback-helpers.ts new file mode 100644 index 0000000000..6a5e5aa34b --- /dev/null +++ b/cli/src/utils/feedback-helpers.ts @@ -0,0 +1,103 @@ +import { + MAX_ERROR_ID_LENGTH, + MAX_ERROR_MESSAGE_LENGTH, + MAX_ERRORS, + MAX_RECENT_MESSAGES, +} from '@codebuff/common/constants/feedback' + +import type { ChatMessage } from '../types/chat' +import type { FeedbackCategory } from '@codebuff/common/constants/feedback' + +import type { FeedbackRequest } from '@codebuff/common/schemas/feedback' + +export type RecentMessageSummary = NonNullable< + FeedbackRequest['recentMessages'] +>[number] + +function toRecentMessageSummary(m: ChatMessage): RecentMessageSummary { + return { + type: m.variant, + id: m.id, + ...(m.completionTime != null && { completionTime: m.completionTime }), + ...(m.credits != null && { credits: m.credits }), + } +} + +export function buildMessageContext( + messages: ChatMessage[], + targetMessageId: string | null, +): { + target: ChatMessage | null + recentMessages: RecentMessageSummary[] +} { + if (!targetMessageId) { + const startIndex = Math.max(0, messages.length - MAX_RECENT_MESSAGES) + return { target: null, recentMessages: messages.slice(startIndex).map(toRecentMessageSummary) } + } + + const target = messages.find((m: ChatMessage) => m.id === targetMessageId) ?? null + + if (!target) { + return { target: null, recentMessages: [] } + } + + const targetIndex = messages.indexOf(target) + const startIndex = Math.max(0, targetIndex - (MAX_RECENT_MESSAGES - 1)) + return { target, recentMessages: messages.slice(startIndex, targetIndex + 1).map(toRecentMessageSummary) } +} + +export interface BuildFeedbackPayloadParams { + text: string + feedbackCategory: FeedbackCategory + feedbackMessageId: string | null + target: ChatMessage | null + recentMessages: RecentMessageSummary[] + agentMode: string | null + sessionCreditsUsed: number | null + errors: Array<{ id: string; message: string }> | null + clientFeedbackId: string +} + +export function buildFeedbackPayload( + params: BuildFeedbackPayloadParams, +): FeedbackRequest { + const { + text, + feedbackCategory, + feedbackMessageId, + target, + recentMessages, + agentMode, + sessionCreditsUsed, + errors, + clientFeedbackId, + } = params + + const hasMessageId = feedbackMessageId != null && feedbackMessageId !== '' + const feedbackType: 'message' | 'general' = hasMessageId ? 'message' : 'general' + + const truncatedErrors = errors + ? errors.slice(0, MAX_ERRORS).map((e) => ({ + id: e.id.slice(0, MAX_ERROR_ID_LENGTH), + message: e.message.slice(0, MAX_ERROR_MESSAGE_LENGTH), + })) + : null + + return { + text, + category: feedbackCategory, + type: feedbackType, + clientFeedbackId, + source: 'cli', + ...(hasMessageId && { messageId: feedbackMessageId }), + ...(target?.variant != null && { messageVariant: target.variant }), + ...(target?.completionTime != null && target.completionTime !== '' && { + completionTime: target.completionTime, + }), + ...(target?.credits != null && { credits: target.credits }), + ...(agentMode != null && agentMode !== '' && { agentMode }), + ...(sessionCreditsUsed != null && { sessionCreditsUsed }), + ...(recentMessages.length > 0 && { recentMessages }), + ...(truncatedErrors && truncatedErrors.length > 0 && { errors: truncatedErrors }), + } +} diff --git a/cli/src/utils/feedback-submission.ts b/cli/src/utils/feedback-submission.ts new file mode 100644 index 0000000000..8ab5131fda --- /dev/null +++ b/cli/src/utils/feedback-submission.ts @@ -0,0 +1,22 @@ +export interface FeedbackSubmissionResolution { + isCurrentSubmission: boolean + shouldSettleSubmission: boolean +} + +/** + * Decide whether an async feedback result should update local state. + * + * - current submission id => settle and apply full success path + * - null active id => feedback was closed while request was in-flight; still settle + * - different active id => a newer feedback session exists; ignore stale result + */ +export function resolveFeedbackSubmission( + activeClientFeedbackId: string | null, + submittedClientFeedbackId: string, +): FeedbackSubmissionResolution { + const isCurrentSubmission = activeClientFeedbackId === submittedClientFeedbackId + return { + isCurrentSubmission, + shouldSettleSubmission: isCurrentSubmission || activeClientFeedbackId === null, + } +} diff --git a/cli/src/utils/fetch-usage.ts b/cli/src/utils/fetch-usage.ts index 8102cf85b5..0706876302 100644 --- a/cli/src/utils/fetch-usage.ts +++ b/cli/src/utils/fetch-usage.ts @@ -1,5 +1,5 @@ import { getAuthToken } from './auth' -import { getApiClient, setApiClientAuthToken } from './codebuff-api' +import { getApiClient } from './codebuff-api' import { logger } from './logger' import { useChatStore } from '../state/chat-store' @@ -42,11 +42,7 @@ export async function fetchAndUpdateUsage( } const apiClient = - providedApiClient ?? - (() => { - setApiClientAuthToken(authToken) - return getApiClient() - })() + providedApiClient ?? getApiClient() try { const response = await apiClient.usage() diff --git a/cli/src/utils/fingerprint.ts b/cli/src/utils/fingerprint.ts new file mode 100644 index 0000000000..22e974fdda --- /dev/null +++ b/cli/src/utils/fingerprint.ts @@ -0,0 +1,240 @@ +/** + * Enhanced fingerprinting for CLI authentication. + * + * Uses hardware-based identifiers to create deterministic fingerprints, + * making it harder for users to game the system by creating multiple accounts. + * + * Falls back to legacy random fingerprints if enhanced fingerprinting fails. + */ + +import { createHash, randomBytes } from 'node:crypto' +import { cpus, networkInterfaces } from 'node:os' + +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' + +import { trackEvent } from './analytics' +import { detectShell } from './detect-shell' +import { logger } from './logger' + +// Lazy imports for optional dependencies +let machineIdModule: typeof import('node-machine-id') | null = null +let systeminformationModule: typeof import('systeminformation') | null = null + +async function getMachineId(): Promise { + if (!machineIdModule) { + machineIdModule = await import('node-machine-id') + } + const id = await machineIdModule.machineId() + // Validate that we got a real machine ID, not an empty or placeholder value. + // Throwing here triggers the legacy fallback in calculateFingerprint(). + if (!id || id === 'unknown' || id.length < 8) { + throw new Error('Invalid machine ID returned') + } + return id +} + +async function getSystemInfo(): Promise<{ + system: { manufacturer: string; model: string; serial: string; uuid: string } + cpu: { manufacturer: string; brand: string; cores: number; physicalCores: number } + os: { platform: string; distro: string; arch: string; hostname: string } +}> { + try { + if (!systeminformationModule) { + systeminformationModule = await import('systeminformation') + } + const [systemInfo, cpuInfo, osInfo] = await Promise.all([ + systeminformationModule.system(), + systeminformationModule.cpu(), + systeminformationModule.osInfo(), + ]) + return { + system: { + manufacturer: systemInfo.manufacturer, + model: systemInfo.model, + serial: systemInfo.serial, + uuid: systemInfo.uuid, + }, + cpu: { + manufacturer: cpuInfo.manufacturer, + brand: cpuInfo.brand, + cores: cpuInfo.cores, + physicalCores: cpuInfo.physicalCores, + }, + os: { + platform: osInfo.platform, + distro: osInfo.distro, + arch: osInfo.arch, + hostname: osInfo.hostname, + }, + } + } catch { + return { + system: { manufacturer: '', model: '', serial: '', uuid: '' }, + cpu: { manufacturer: '', brand: '', cores: 0, physicalCores: 0 }, + os: { platform: process.platform, distro: '', arch: process.arch, hostname: '' }, + } + } +} + +/** + * Generates an enhanced CLI fingerprint using hardware identifiers. + * This is deterministic - the same machine will always produce the same fingerprint. + * Throws if machine ID cannot be obtained (to trigger legacy fallback). + */ +async function calculateEnhancedFingerprint(): Promise { + // getMachineId will throw if it can't get a valid machine ID + const machineIdValue = await getMachineId() + + const [sysInfo, shell, networkInfo] = await Promise.all([ + getSystemInfo(), + Promise.resolve(detectShell()), + Promise.resolve(networkInterfaces()), + ]) + + // Extract MAC addresses for additional uniqueness + const macAddresses = Object.values(networkInfo) + .flat() + .filter( + (iface) => + iface && !iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00', + ) + .map((iface) => iface!.mac) + .sort() + + const fingerprintInfo = { + system: sysInfo.system, + cpu: sysInfo.cpu, + os: sysInfo.os, + runtime: { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + shell, + cpuCount: cpus().length, + }, + network: { + macAddresses, + interfaceCount: Object.keys(networkInfo).length, + }, + machineId: machineIdValue, + fingerprintVersion: '2.0', + } + + const fingerprintString = JSON.stringify(fingerprintInfo) + const fingerprintHash = createHash('sha256') + .update(fingerprintString) + .digest('base64url') + + return `enhanced-${fingerprintHash}` +} + +/** + * Generates a legacy fingerprint with a random suffix. + * Used as a fallback when enhanced fingerprinting fails. + */ +function calculateLegacyFingerprint(): string { + const randomSuffix = randomBytes(6).toString('base64url').substring(0, 8) + return `codebuff-cli-${randomSuffix}` +} + +/** + * Cached fingerprint promise. Populated on first call and reused for the + * process lifetime so every auth step in a session ships the same fingerprint + * to the server. + */ +let cachedFingerprintPromise: Promise | null = null + +/** + * Returns the process-wide CLI fingerprint, computing it on first call. + * Safe to call from multiple places — the first caller wins and the rest + * await the same promise. + */ +export function getFingerprintId(): Promise { + if (!cachedFingerprintPromise) { + cachedFingerprintPromise = calculateFingerprint() + } + return cachedFingerprintPromise +} + +/** + * Main fingerprint function. + * Tries enhanced fingerprinting first, falls back to legacy if it fails. + */ +export async function calculateFingerprint(): Promise { + try { + const fingerprint = await calculateEnhancedFingerprint() + logger.debug( + { + fingerprintType: 'enhanced_cli', + fingerprintId: fingerprint.substring(0, 20) + '...', + }, + 'Enhanced CLI fingerprint generated successfully', + ) + trackEvent(AnalyticsEvent.FINGERPRINT_GENERATED, { + fingerprintType: 'enhanced_cli', + success: true, + }) + return fingerprint + } catch (enhancedError) { + logger.info( + { + errorMessage: + enhancedError instanceof Error ? enhancedError.message : String(enhancedError), + fingerprintType: 'enhanced_failed_fallback', + }, + 'Enhanced CLI fingerprinting failed, using legacy fallback', + ) + + try { + const fingerprint = calculateLegacyFingerprint() + logger.debug( + { + fingerprintType: 'legacy_fallback', + fingerprintId: fingerprint, + }, + 'Legacy fingerprint generated successfully as fallback', + ) + trackEvent(AnalyticsEvent.FINGERPRINT_GENERATED, { + fingerprintType: 'legacy', + success: true, + fallbackReason: + enhancedError instanceof Error ? enhancedError.message : 'unknown', + }) + return fingerprint + } catch (legacyError) { + logger.error( + { + errorMessage: + legacyError instanceof Error ? legacyError.message : String(legacyError), + fingerprintType: 'failed', + }, + 'Both enhanced and legacy fingerprint generation failed', + ) + throw new Error('Fingerprint generation failed') + } + } +} + +/** + * Synchronous fingerprint generation (legacy only). + * Use this only when async is not possible (e.g., initial state). + * @deprecated Prefer calculateFingerprint() for hardware-based fingerprinting + */ +export function generateFingerprintIdSync(): string { + return calculateLegacyFingerprint() +} + +/** + * Detects the fingerprint type from a fingerprint ID. + */ +export function getFingerprintType( + fingerprintId: string, +): 'enhanced_cli' | 'legacy' | 'unknown' { + if (fingerprintId.startsWith('enhanced-')) { + return 'enhanced_cli' + } + if (fingerprintId.startsWith('codebuff-cli-') || fingerprintId.startsWith('legacy-')) { + return 'legacy' + } + return 'unknown' +} diff --git a/cli/src/utils/format-session-units.ts b/cli/src/utils/format-session-units.ts new file mode 100644 index 0000000000..75532df80c --- /dev/null +++ b/cli/src/utils/format-session-units.ts @@ -0,0 +1,6 @@ +/** Premium-session counts come back from the server as `recentCount` units + * that may be fractional (a long agent run can consume 1.3 sessions). Render + * integers without a trailing `.0`, fractionals at one decimal — matches the + * `limit` field which is always integer. */ +export const formatSessionUnits = (units: number): string => + Number.isInteger(units) ? String(units) : units.toFixed(1) diff --git a/cli/src/utils/format-timeout.ts b/cli/src/utils/format-timeout.ts new file mode 100644 index 0000000000..73f9cd454f --- /dev/null +++ b/cli/src/utils/format-timeout.ts @@ -0,0 +1,28 @@ +/** + * Formats a timeout value for display. + * - Returns "no timeout" for non-finite values (NaN, Infinity, -Infinity) + * - Returns "no timeout" for negative values (including -1) + * - Returns hours (e.g., "1h timeout") for values >= 3600 that are evenly divisible by 3600 + * - Returns minutes (e.g., "2m timeout") for values >= 60 that are evenly divisible by 60 + * - Returns seconds (e.g., "90s timeout") otherwise + * - Rounds floating point values to nearest integer + */ +export function formatTimeout(timeoutSeconds: number): string { + // Handle NaN, Infinity, -Infinity + if (!Number.isFinite(timeoutSeconds)) { + return 'no timeout' + } + // Handle all negative values (including -1) + if (timeoutSeconds < 0) { + return 'no timeout' + } + // Round floating point values + const rounded = Math.round(timeoutSeconds) + if (rounded >= 3600 && rounded % 3600 === 0) { + return `${rounded / 3600}h timeout` + } + if (rounded >= 60 && rounded % 60 === 0) { + return `${rounded / 60}m timeout` + } + return `${rounded}s timeout` +} diff --git a/cli/src/utils/freebuff-agent-selection.ts b/cli/src/utils/freebuff-agent-selection.ts new file mode 100644 index 0000000000..094f0de0f1 --- /dev/null +++ b/cli/src/utils/freebuff-agent-selection.ts @@ -0,0 +1,12 @@ +import { getFreebuffRootAgentIdForModel } from '@codebuff/common/constants/free-agents' + +import { getSelectedFreebuffModel } from '../state/freebuff-model-store' +import { AGENT_MODE_TO_ID, IS_FREEBUFF, type AgentMode } from './constants' + +export function getAgentIdForMode(agentMode: AgentMode): string { + if (IS_FREEBUFF && agentMode === 'LITE') { + return getFreebuffRootAgentIdForModel(getSelectedFreebuffModel()) + } + + return AGENT_MODE_TO_ID[agentMode] +} diff --git a/cli/src/utils/freebuff-exit.ts b/cli/src/utils/freebuff-exit.ts new file mode 100644 index 0000000000..5104e85fcb --- /dev/null +++ b/cli/src/utils/freebuff-exit.ts @@ -0,0 +1,21 @@ +import { endFreebuffSessionBestEffort } from '../hooks/use-freebuff-session' + +import { flushAnalytics } from './analytics' +import { withTimeout } from './terminal-color-detection' + +/** Cap on exit cleanup so a slow network doesn't block process exit. */ +const EXIT_CLEANUP_TIMEOUT_MS = 1_000 + +/** + * Flush analytics + release the freebuff seat (best-effort), then exit 0. + * Shared by every freebuff-specific screen's Ctrl+C / X handler so they all + * run the same cleanup. + */ +export async function exitFreebuffCleanly(): Promise { + await withTimeout( + Promise.allSettled([flushAnalytics(), endFreebuffSessionBestEffort()]), + EXIT_CLEANUP_TIMEOUT_MS, + undefined, + ) + process.exit(0) +} diff --git a/cli/src/utils/freebuff-instance-owner.ts b/cli/src/utils/freebuff-instance-owner.ts new file mode 100644 index 0000000000..a15881e54f --- /dev/null +++ b/cli/src/utils/freebuff-instance-owner.ts @@ -0,0 +1,66 @@ +import fs from 'fs' +import path from 'path' + +import { getConfigDir } from './auth' +import { logger } from './logger' + +interface FreebuffInstanceOwner { + instanceId: string + pid: number +} + +const OWNER_FILE = 'freebuff-instance-owner.json' + +const getOwnerPath = (): string => path.join(getConfigDir(), OWNER_FILE) + +function readOwner(): FreebuffInstanceOwner | null { + try { + const raw = fs.readFileSync(getOwnerPath(), 'utf8') + const parsed = JSON.parse(raw) as Partial + if ( + typeof parsed.instanceId !== 'string' || + typeof parsed.pid !== 'number' + ) { + return null + } + return { + instanceId: parsed.instanceId, + pid: parsed.pid, + } + } catch { + return null + } +} + +function isProcessRunning(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM' + } +} + +export function recordFreebuffInstanceOwner(instanceId: string): void { + try { + fs.mkdirSync(getConfigDir(), { recursive: true }) + fs.writeFileSync( + getOwnerPath(), + JSON.stringify({ instanceId, pid: process.pid }, null, 2), + ) + } catch (error) { + logger.debug( + { error: error instanceof Error ? error.message : String(error) }, + '[freebuff-session] Failed to record local owner', + ) + } +} + +export function isFreebuffInstanceOwnedByDeadLocalProcess( + instanceId: string, +): boolean { + const owner = readOwner() + if (!owner || owner.instanceId !== instanceId) return false + return !isProcessRunning(owner.pid) +} diff --git a/cli/src/utils/freebuff-model-navigation.ts b/cli/src/utils/freebuff-model-navigation.ts new file mode 100644 index 0000000000..a866ae16af --- /dev/null +++ b/cli/src/utils/freebuff-model-navigation.ts @@ -0,0 +1,50 @@ +export type FreebuffModelNavigationDirection = 'forward' | 'backward' + +const FORWARD_KEY_NAMES = new Set(['right', 'down']) +const BACKWARD_KEY_NAMES = new Set(['left', 'up']) +const FORWARD_TAB_SEQUENCES = new Set(['\t', '\x1b[9u']) +const BACKWARD_TAB_SEQUENCES = new Set([ + '\x1b[Z', + '\x1b[9;2u', + '\x1b[27;2;9~', +]) + +export function nextFreebuffModelId(params: { + modelIds: readonly string[] + focusedId: string + direction: FreebuffModelNavigationDirection +}): string | null { + const { modelIds, focusedId, direction } = params + if (modelIds.length === 0) return null + + const currentIdx = modelIds.indexOf(focusedId) + if (currentIdx === -1) return modelIds[0] ?? null + + const step = direction === 'forward' ? 1 : -1 + return modelIds[(currentIdx + step + modelIds.length) % modelIds.length] +} + +export function freebuffModelNavigationDirectionForKey(key: { + name?: string + shift?: boolean + sequence?: string + raw?: string +}): FreebuffModelNavigationDirection | null { + const name = (key.name ?? '').toLowerCase() + const sequence = key.sequence ?? key.raw ?? '' + + if (FORWARD_KEY_NAMES.has(name)) return 'forward' + if (BACKWARD_KEY_NAMES.has(name)) return 'backward' + + if ( + (name === 'tab' && Boolean(key.shift)) || + BACKWARD_TAB_SEQUENCES.has(sequence) + ) { + return 'backward' + } + if (name === 'tab' || FORWARD_TAB_SEQUENCES.has(sequence)) { + return 'forward' + } + + return null +} diff --git a/cli/src/utils/freebuff-premium-reset.ts b/cli/src/utils/freebuff-premium-reset.ts new file mode 100644 index 0000000000..efbcb2ec15 --- /dev/null +++ b/cli/src/utils/freebuff-premium-reset.ts @@ -0,0 +1,42 @@ +import { FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE } from '@codebuff/common/constants/freebuff-models' +import { getZonedDayBounds } from '@codebuff/common/util/zoned-time' + +import type { FreebuffSessionRateLimitByModel } from '@codebuff/common/types/freebuff-session' + +export function getFreebuffPremiumResetAt(params: { + rateLimitsByModel?: FreebuffSessionRateLimitByModel + nowMs: number +}): Date { + const { rateLimitsByModel, nowMs } = params + const serverResetAt = rateLimitsByModel + ? Object.values(rateLimitsByModel)[0]?.resetAt + : undefined + const parsedServerResetAt = serverResetAt ? new Date(serverResetAt) : null + + if ( + parsedServerResetAt && + Number.isFinite(parsedServerResetAt.getTime()) + ) { + return parsedServerResetAt + } + + return getZonedDayBounds( + new Date(nowMs), + FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE, + ).resetsAt +} + +export function formatFreebuffPremiumResetCountdown( + resetAt: Date, + nowMs: number, +): string { + const diffMs = resetAt.getTime() - nowMs + if (!Number.isFinite(diffMs) || diffMs <= 0) return 'now' + + const totalMinutes = Math.max(1, Math.floor(diffMs / 60_000)) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + + if (hours === 0) return `${minutes}m` + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m` +} diff --git a/cli/src/utils/image-processor.ts b/cli/src/utils/image-processor.ts index d274a89edb..5a237d0ec4 100644 --- a/cli/src/utils/image-processor.ts +++ b/cli/src/utils/image-processor.ts @@ -1,7 +1,7 @@ import { extractImagePaths, processImageFile } from './image-handler' import { logger } from './logger' -import type { PendingImageAttachment } from '../state/chat-store' +import type { PendingImageAttachment } from '../types/store' import type { MessageContent } from '@codebuff/sdk' // Converts pending images + inline references into SDK-ready message content. @@ -34,13 +34,6 @@ export const processImagesForMessage = async (params: { log = logger, } = params - const detectedImagePaths = extractImagePaths(content) - const allImagePaths = [ - ...pendingImages.map((img) => img.path), - ...detectedImagePaths, - ] - const uniqueImagePaths = [...new Set(allImagePaths)] - const attachments = pendingImages.map((img) => ({ path: img.path, filename: img.filename, @@ -48,7 +41,62 @@ export const processImagesForMessage = async (params: { })) const validImageParts: ProcessedImagePart[] = [] - for (const imagePath of uniqueImagePaths) { + + // First, use pre-processed data from pendingImages (already processed when attached) + // This avoids re-reading from disk, which can fail if the path is relative to a different cwd + const pendingImagePaths = new Set() + for (const img of pendingImages) { + pendingImagePaths.add(img.path) + + if (img.processedImage) { + // Use the already-processed image data + validImageParts.push({ + type: 'image', + image: img.processedImage.base64, + mediaType: img.processedImage.mediaType, + filename: img.filename, + size: img.size, + width: img.width, + height: img.height, + path: img.path, + }) + } else if (img.status === 'ready') { + // Backwards compatibility: if processedImage is missing but status is ready, + // try to process from disk (shouldn't happen in normal flow) + log.warn( + { imagePath: img.path }, + 'Pending image marked ready but missing processedImage data, re-processing from disk', + ) + const result = await processor(img.path, projectRoot) + if (result.success && result.imagePart) { + validImageParts.push({ + type: 'image', + image: result.imagePart.image, + mediaType: result.imagePart.mediaType, + filename: result.imagePart.filename, + size: result.imagePart.size, + width: result.imagePart.width, + height: result.imagePart.height, + path: img.path, + }) + } else if (!result.success) { + log.warn( + { imagePath: img.path, error: result.error }, + 'Failed to process pending image from disk', + ) + } + } + // Skip images with status 'processing' or 'error' - they shouldn't be sent + } + + // Then, process any inline image paths from the message content that aren't already in pendingImages + const detectedImagePaths = extractImagePaths(content) + for (const imagePath of detectedImagePaths) { + // Skip if this path is already handled by pendingImages + if (pendingImagePaths.has(imagePath)) { + continue + } + const result = await processor(imagePath, projectRoot) if (result.success && result.imagePart) { validImageParts.push({ @@ -64,7 +112,7 @@ export const processImagesForMessage = async (params: { } else if (!result.success) { log.warn( { imagePath, error: result.error }, - 'Failed to process image for SDK', + 'Failed to process inline image path for SDK', ) } } diff --git a/cli/src/utils/image-thumbnail.ts b/cli/src/utils/image-thumbnail.ts index 8abf5677c9..899b62890b 100644 --- a/cli/src/utils/image-thumbnail.ts +++ b/cli/src/utils/image-thumbnail.ts @@ -27,12 +27,12 @@ export interface ThumbnailData { * @returns Promise resolving to thumbnail data with pixel colors */ export async function extractThumbnailColors( - imagePath: string, + source: string | Buffer, targetWidth: number, targetHeight: number, ): Promise { try { - const image = await Jimp.read(imagePath) + const image = await Jimp.read(source) // Resize to target dimensions (height * 2 because we use half-blocks) // Use bilinear interpolation for smoother downscaling (sharper than nearest-neighbor) @@ -61,7 +61,7 @@ export async function extractThumbnailColors( } catch (error) { logger.warn( { - imagePath, + source: typeof source === 'string' ? source : `Buffer(len=${source.length})`, error: error instanceof Error ? error.message : String(error), }, 'Failed to extract thumbnail colors from image', diff --git a/cli/src/utils/implementor-helpers.ts b/cli/src/utils/implementor-helpers.ts index cc031f3596..ccb92c5c14 100644 --- a/cli/src/utils/implementor-helpers.ts +++ b/cli/src/utils/implementor-helpers.ts @@ -11,8 +11,13 @@ export const IMPLEMENTOR_AGENT_IDS = [ 'editor-implementor-gpt-5', ] as const -// Edit tool names that count as edits (proposed versions too) -const PROPOSED_EDIT_TOOL_NAMES = ['propose_str_replace', 'propose_write_file'] as const +/** All edit tool names (both direct and proposed variants) */ +const ALL_EDIT_TOOL_NAMES = [ + 'str_replace', + 'write_file', + 'propose_str_replace', + 'propose_write_file', +] as const const isProposedToolName = (toolName: ToolContentBlock['toolName']): boolean => typeof toolName === 'string' && toolName.startsWith('propose_') @@ -20,6 +25,18 @@ const isProposedToolName = (toolName: ToolContentBlock['toolName']): boolean => const getBaseToolName = (toolName: ToolContentBlock['toolName']): string => isProposedToolName(toolName) ? toolName.slice('propose_'.length) : toolName +const SUCCESSFUL_EDIT_MESSAGES = [ + 'String replace applied successfully', + 'Created file successfully', + 'Created new file', + 'Overwrote file successfully', + 'Wrote file successfully', + 'Updated file', + 'Proposed new file', + 'Proposed changes', + 'Proposed string replacement', +] as const + const hasProposedTools = (blocks?: ContentBlock[]): boolean => { if (!blocks || blocks.length === 0) return false @@ -29,8 +46,8 @@ const hasProposedTools = (blocks?: ContentBlock[]): boolean => { } /** - * Check if an agent is an implementor agent - * These agents are rendered differently (as simple status lines instead of full agent blocks) + * Check if an agent is an implementor agent. + * These agents are rendered differently (as simple status lines instead of full agent blocks). */ export const isImplementorAgent = ( agentBlock: Pick, @@ -43,7 +60,7 @@ export const isImplementorAgent = ( } /** - * Get the display name for an implementor agent + * Get the display name for an implementor agent. */ export const getImplementorDisplayName = ( agentType: string, @@ -67,8 +84,8 @@ export const getImplementorDisplayName = ( } /** - * Get the index of an implementor agent among its siblings - * Returns the 0-based index among all implementor agents of the same type + * Get the index of an implementor agent among its siblings. + * Returns the 0-based index among all implementor agents of the same type. */ export const getImplementorIndex = ( currentAgent: AgentContentBlock, @@ -96,19 +113,20 @@ export const getImplementorIndex = ( } /** - * Group consecutive implementor agents from a blocks array - * Returns the group of implementors and the next index to process + * Group consecutive blocks from a blocks array that match the predicate. + * Returns the group and the next index to process. */ -export function groupConsecutiveImplementors( +export function groupConsecutiveBlocks( blocks: ContentBlock[], startIndex: number, -): { group: AgentContentBlock[]; nextIndex: number } { - const group: AgentContentBlock[] = [] + predicate: (block: ContentBlock) => block is T, +): { group: T[]; nextIndex: number } { + const group: T[] = [] let i = startIndex while (i < blocks.length) { const block = blocks[i] - if (block.type !== 'agent' || !isImplementorAgent(block)) { + if (!predicate(block)) { break } group.push(block) @@ -118,15 +136,48 @@ export function groupConsecutiveImplementors( return { group, nextIndex: i } } -// Edit tool names that count as edits -const EDIT_TOOL_NAMES = ['str_replace', 'write_file'] as const +/** + * Group consecutive implementor agents from a blocks array. + * Returns the group of implementors and the next index to process. + */ +export function groupConsecutiveImplementors( + blocks: ContentBlock[], + startIndex: number, +): { group: AgentContentBlock[]; nextIndex: number } { + return groupConsecutiveBlocks( + blocks, + startIndex, + (block): block is AgentContentBlock => + block.type === 'agent' && isImplementorAgent(block), + ) +} -// All edit tool names (executed and proposed) -const ALL_EDIT_TOOL_NAMES = [...EDIT_TOOL_NAMES, ...PROPOSED_EDIT_TOOL_NAMES] as const +export function groupConsecutiveNonImplementorAgents( + blocks: ContentBlock[], + startIndex: number, +): { group: AgentContentBlock[]; nextIndex: number } { + return groupConsecutiveBlocks( + blocks, + startIndex, + (block): block is AgentContentBlock => + block.type === 'agent' && !isImplementorAgent(block), + ) +} + +export function groupConsecutiveToolBlocks( + blocks: ContentBlock[], + startIndex: number, +): { group: ToolContentBlock[]; nextIndex: number } { + return groupConsecutiveBlocks( + blocks, + startIndex, + (block): block is ToolContentBlock => block.type === 'tool', + ) +} /** - * Extract a value for a key from tool output (key: value format) - * Supports multi-line values with pipe delimiter + * Extract a value for a key from tool output (key: value format). + * Supports multi-line values with pipe delimiter. */ export function extractValueForKey(output: string, key: string): string | null { if (!output) return null @@ -163,7 +214,7 @@ export function extractValueForKey(output: string, key: string): string | null { } /** - * Extract file path from tool block + * Extract file path from tool block. */ export function extractFilePath(toolBlock: ToolContentBlock): string | null { const outputStr = typeof toolBlock.output === 'string' ? toolBlock.output : '' @@ -177,43 +228,66 @@ export function extractFilePath(toolBlock: ToolContentBlock): string | null { } /** - * Extract unified diff from tool output, or construct from input - * For executed tools: use outputRaw/output with unifiedDiff - * For proposed tools (implementors): construct diff from input replacements + * Extract unified diff from tool output, or construct from input. + * For executed tools: use outputRaw/output with unifiedDiff. + * For proposed tools (implementors): construct diff from input replacements. */ export function extractDiff(toolBlock: ToolContentBlock): string | null { + let hasSuccessfulOutput = false + // First try to get from outputRaw (for executed tool results) // outputRaw is typically an array like [{type: "json", value: {unifiedDiff: "..."}}] const outputRaw = toolBlock.outputRaw as unknown if (Array.isArray(outputRaw) && outputRaw[0]?.value) { const value = outputRaw[0].value as Record + if (hasErrorMessage(value)) return null + if (isSuccessfulEditMessage(value.message)) hasSuccessfulOutput = true if (value.unifiedDiff) return value.unifiedDiff as string if (value.patch) return value.patch as string } // Also check direct properties (in case format differs) if (typeof outputRaw === 'object' && outputRaw !== null) { const rawObj = outputRaw as Record + if (hasErrorMessage(rawObj)) return null + if (isSuccessfulEditMessage(rawObj.message)) hasSuccessfulOutput = true if (rawObj.unifiedDiff) return rawObj.unifiedDiff as string if (rawObj.patch) return rawObj.patch as string } // Try to get from output string (key: value format) const outputStr = typeof toolBlock.output === 'string' ? toolBlock.output : '' + const message = extractValueForKey(outputStr, 'message') const diffFromOutput = extractValueForKey(outputStr, 'unifiedDiff') || extractValueForKey(outputStr, 'patch') + if (hasFailedEditOutput({ outputStr, message, diffFromOutput })) { + return null + } + if (isSuccessfulEditMessage(message)) { + hasSuccessfulOutput = true + } + if (diffFromOutput) { return diffFromOutput } - // For proposed edits (no output yet): construct diff from input + // For proposed/pending edits, or confirmed successful executions, construct + // the preview from input when the result omits a diff. + const canUseInputFallback = + isProposedToolName(toolBlock.toolName) || + outputStr === '' || + hasSuccessfulOutput + if (!canUseInputFallback) { + return null + } + const input = toolBlock.input as Record const baseToolName = getBaseToolName(toolBlock.toolName) // Handle str_replace: construct diff from replacements if (baseToolName === 'str_replace' && Array.isArray(input?.replacements)) { - const replacements = input.replacements as { old: string; new: string }[] + const replacements = input.replacements as ReplacementInput[] if (replacements.length > 0) { return constructDiffFromReplacements(replacements) } @@ -232,22 +306,96 @@ export function extractDiff(toolBlock: ToolContentBlock): string | null { return null } +function hasErrorMessage(value: Record): boolean { + return Boolean(value.errorMessage || (value.value as any)?.errorMessage) +} + +function hasFailedEditOutput(params: { + outputStr: string + message: string | null + diffFromOutput: string | null +}): boolean { + const { outputStr, message, diffFromOutput } = params + const trimmedOutput = outputStr.trim() + if (!trimmedOutput) { + return false + } + if ( + extractValueForKey(outputStr, 'errorMessage') || + isErrorOutput(outputStr) + ) { + return true + } + if (diffFromOutput || isSuccessfulEditMessage(message)) { + return false + } + return !isSuccessfulEditMessage(trimmedOutput) +} + +function isFailedEditToolBlock(toolBlock: ToolContentBlock): boolean { + const outputRaw = toolBlock.outputRaw as unknown + if (Array.isArray(outputRaw) && outputRaw[0]?.value) { + const value = outputRaw[0].value as Record + if (hasErrorMessage(value)) return true + } + if (typeof outputRaw === 'object' && outputRaw !== null) { + const rawObj = outputRaw as Record + if (hasErrorMessage(rawObj)) return true + } + + const outputStr = typeof toolBlock.output === 'string' ? toolBlock.output : '' + const message = extractValueForKey(outputStr, 'message') + const diffFromOutput = + extractValueForKey(outputStr, 'unifiedDiff') || + extractValueForKey(outputStr, 'patch') + return hasFailedEditOutput({ outputStr, message, diffFromOutput }) +} + +function isSuccessfulEditMessage(message: unknown): boolean { + if (typeof message !== 'string') { + return false + } + + return message + .split('\n') + .some((line) => + SUCCESSFUL_EDIT_MESSAGES.some((successMessage) => + line.trim().startsWith(successMessage), + ), + ) +} + +function isErrorOutput(output: string): boolean { + const trimmedOutput = output.trim() + return trimmedOutput.startsWith('Error:') || trimmedOutput.startsWith('Failed ') +} + /** - * Construct a simple diff view from str_replace replacements + * Construct a simple diff view from str_replace replacements. */ +type ReplacementInput = { + oldString?: string + newString?: string + old?: string + new?: string +} + function constructDiffFromReplacements( - replacements: { old: string; new: string }[], + replacements: ReplacementInput[], ): string { const lines: string[] = [] for (const replacement of replacements) { + const oldString = replacement.oldString ?? replacement.old ?? '' + const newString = replacement.newString ?? replacement.new ?? '' + // Add old lines as removals - const oldLines = replacement.old.split('\n') + const oldLines = oldString.split('\n') for (const line of oldLines) { lines.push(`- ${line}`) } // Add new lines as additions - const newLines = replacement.new.split('\n') + const newLines = newString.split('\n') for (const line of newLines) { lines.push(`+ ${line}`) } @@ -261,7 +409,7 @@ function constructDiffFromReplacements( } /** - * Construct a diff view from write_file content + * Construct a diff view from write_file content. */ function constructDiffFromWriteFile(content: string): string { const lines = content.split('\n') @@ -269,18 +417,46 @@ function constructDiffFromWriteFile(content: string): string { } /** - * Check if a tool is a "create new file" operation + * Check if a tool is a "create new file" operation. */ export function isCreateFile(toolBlock: ToolContentBlock): boolean { const outputStr = typeof toolBlock.output === 'string' ? toolBlock.output : '' const message = extractValueForKey(outputStr, 'message') return ( typeof message === 'string' && - (message.startsWith('Created new file') || + (message.startsWith('Created file successfully') || + message.startsWith('Created new file') || message.startsWith('Proposed new file')) ) } +function hasToolResultOutput(toolBlock: ToolContentBlock): boolean { + const outputStr = typeof toolBlock.output === 'string' ? toolBlock.output : '' + return outputStr.length > 0 || toolBlock.outputRaw !== undefined +} + +/** + * Decide whether the direct edit tool renderer should show a diff preview. + * + * Real edit tool calls render immediately with input only, then receive output + * once the edit completes. Wait for that result before showing diffs so create + * operations never briefly flash an input-derived full-file diff. + */ +export function shouldShowEditDiff(toolBlock: ToolContentBlock): boolean { + if (!extractDiff(toolBlock) || isCreateFile(toolBlock)) { + return false + } + + if ( + !isProposedToolName(toolBlock.toolName) && + !hasToolResultOutput(toolBlock) + ) { + return false + } + + return true +} + export interface TimelineItem { type: 'commentary' | 'edit' content: string // For commentary: the text. For edits: file path @@ -304,7 +480,7 @@ export interface FileStats { } /** - * Parse diff text and extract statistics + * Parse diff text and extract statistics. */ export function parseDiffStats(diff: string | undefined): DiffStats { if (!diff) return { linesAdded: 0, linesRemoved: 0, hunks: 0 } @@ -338,7 +514,7 @@ export function parseDiffStats(diff: string | undefined): DiffStats { } /** - * Determine file change type based on tool and context + * Determine file change type based on tool and context. */ export function getFileChangeType(toolBlock: ToolContentBlock): FileChangeType { const baseToolName = getBaseToolName(toolBlock.toolName) @@ -358,10 +534,12 @@ export function getFileChangeType(toolBlock: ToolContentBlock): FileChangeType { } /** - * Get aggregated file stats from all edit blocks - * Groups by file path and sums up the stats + * Get aggregated file stats from all edit blocks. + * Groups by file path and sums up the stats. */ -export function getFileStatsFromBlocks(blocks: ContentBlock[] | undefined): FileStats[] { +export function getFileStatsFromBlocks( + blocks: ContentBlock[] | undefined, +): FileStats[] { if (!blocks || blocks.length === 0) return [] const fileMap = new Map() @@ -369,8 +547,12 @@ export function getFileStatsFromBlocks(blocks: ContentBlock[] | undefined): File for (const block of blocks) { if ( block.type === 'tool' && - ALL_EDIT_TOOL_NAMES.includes(block.toolName as (typeof ALL_EDIT_TOOL_NAMES)[number]) + ALL_EDIT_TOOL_NAMES.includes( + block.toolName as (typeof ALL_EDIT_TOOL_NAMES)[number], + ) ) { + if (isFailedEditToolBlock(block)) continue + const filePath = extractFilePath(block) if (!filePath) continue @@ -398,9 +580,9 @@ export function getFileStatsFromBlocks(blocks: ContentBlock[] | undefined): File } /** - * Build an activity timeline from agent blocks - * Interleaves commentary (text blocks) and edits (tool calls) - * Includes both executed tools (str_replace, write_file) and proposed tools + * Build an activity timeline from agent blocks. + * Interleaves commentary (text blocks) and edits (tool calls). + * Includes both executed tools (str_replace, write_file) and proposed tools. */ export function buildActivityTimeline( blocks: ContentBlock[] | undefined, @@ -417,8 +599,12 @@ export function buildActivityTimeline( } } else if ( block.type === 'tool' && - ALL_EDIT_TOOL_NAMES.includes(block.toolName as (typeof ALL_EDIT_TOOL_NAMES)[number]) + ALL_EDIT_TOOL_NAMES.includes( + block.toolName as (typeof ALL_EDIT_TOOL_NAMES)[number], + ) ) { + if (isFailedEditToolBlock(block)) continue + const filePath = extractFilePath(block) const diff = extractDiff(block) const isCreate = isCreateFile(block) @@ -436,10 +622,166 @@ export function buildActivityTimeline( } /** - * Truncate text to fit within maxWidth, adding ellipsis if needed + * Truncate text to fit within maxWidth, adding ellipsis if needed. */ export function truncateWithEllipsis(text: string, maxWidth: number): string { if (text.length <= maxWidth) return text if (maxWidth <= 3) return text.slice(0, maxWidth) return text.slice(0, maxWidth - 3) + '...' } + +export interface MultiPromptProgress { + /** Total number of implementor agents */ + total: number + /** Number of successfully completed implementors */ + completed: number + /** Number of failed/errored implementors */ + failed: number + /** Whether selector is active (all implementors done, selecting best) */ + isSelecting: boolean + /** Whether selector has completed (used to detect applying phase) */ + isSelectorComplete: boolean +} + +/** + * Analyze progress of a multi-prompt editor agent. + * Returns counts of implementor agents and current phase. + */ +export function getMultiPromptProgress( + blocks: ContentBlock[] | undefined, +): MultiPromptProgress | null { + if (!blocks || blocks.length === 0) return null + + const implementors = blocks.filter( + (block): block is AgentContentBlock => + block.type === 'agent' && isImplementorAgent(block), + ) + + if (implementors.length === 0) return null + + const completed = implementors.filter((a) => a.status === 'complete').length + const failed = implementors.filter( + (a) => a.status === 'failed' || a.status === 'cancelled', + ).length + + const selectorAgent = blocks.find( + (block): block is AgentContentBlock => + block.type === 'agent' && block.agentType.includes('best-of-n-selector'), + ) + const isSelecting = selectorAgent?.status === 'running' + + return { + total: implementors.length, + completed, + failed, + isSelecting, + isSelectorComplete: selectorAgent?.status === 'complete', + } +} + +/** Expected shape of the set_output data from editor-multi-prompt */ +interface MultiPromptSetOutputData { + implementationId?: string + chosenStrategy?: string + reason?: string + suggestedImprovements?: string + toolResults?: unknown[] + error?: string +} + +/** Expected shape of the set_output input (data is wrapped in a 'data' property) */ +interface SetOutputInput { + data?: MultiPromptSetOutputData +} + +/** Type guard for set_output input with data property */ +function hasSetOutputData(input: unknown): input is SetOutputInput { + return ( + typeof input === 'object' && + input !== null && + 'data' in input && + typeof (input as SetOutputInput).data === 'object' + ) +} + +/** + * Extract the selection reason from multi-prompt agent's set_output block. + * set_output wraps data in a 'data' property, so we need to access input.data.reason + */ +function extractSelectionReason( + blocks: ContentBlock[] | undefined, +): string | null { + if (!blocks || blocks.length === 0) return null + + const setOutputBlock = blocks.find( + (block): block is ToolContentBlock => + block.type === 'tool' && + block.toolName === 'set_output' && + hasSetOutputData(block.input) && + typeof block.input.data?.reason === 'string', + ) + + if (!setOutputBlock || !hasSetOutputData(setOutputBlock.input)) { + return null + } + + return setOutputBlock.input.data?.reason ?? null +} + +/** + * Generate a progress-focused preview string for multi-prompt editor. + * @param blocks - The nested content blocks of the agent + * @param isAgentComplete - Whether the parent agent has finished (status === 'complete') + */ +export function getMultiPromptPreview( + blocks: ContentBlock[] | undefined, + isAgentComplete?: boolean, +): string | null { + const progress = getMultiPromptProgress(blocks) + if (!progress) return null + + const { total, completed, failed, isSelecting, isSelectorComplete } = progress + const finished = completed + failed + + // Agent is fully complete - show final state with selection info + // Use multi-line format: line 1 = count, lines 2-3 = reason (truncated to fit) + if (isAgentComplete) { + const reason = extractSelectionReason(blocks) + if (reason) { + // Capitalize first letter and truncate to 2 lines (line 1 is the count) + const formattedReason = reason.charAt(0).toUpperCase() + reason.slice(1) + const lines = formattedReason.split('\n') + const truncatedReason = + lines.length > 2 + ? lines.slice(0, 2).join('\n').trimEnd() + '...' + : formattedReason + return `${total} proposals evaluated\n${truncatedReason}` + } + return `${total} proposals evaluated` + } + + // Selector completed but agent still running = applying phase + if (isSelectorComplete) { + return 'Applying selected changes...' + } + + if (isSelecting) { + return `${total} proposals complete • Selecting best...` + } + + if (finished === total && total > 0) { + if (failed > 0) { + return `${completed}/${total} proposals complete (${failed} failed)` + } + return `${total} proposals complete` + } + + if (finished > 0) { + if (failed > 0) { + return `${completed}/${total} complete, ${failed} failed...` + } + return `${completed}/${total} proposals complete...` + } + + return `Generating ${total} proposals...` +} diff --git a/cli/src/utils/input-modes.ts b/cli/src/utils/input-modes.ts index be2196223b..d9441cdea5 100644 --- a/cli/src/utils/input-modes.ts +++ b/cli/src/utils/input-modes.ts @@ -1,3 +1,5 @@ +import { IS_FREEBUFF } from './constants' + // Input mode types and configurations // To add a new mode: // 1. Add it to the InputMode type @@ -7,12 +9,15 @@ export type InputMode = | 'default' | 'bash' | 'homeDir' - | 'referral' + | 'plan' + | 'review' + | 'interview' | 'usage' | 'image' | 'help' - | 'connect:claude' + | 'connect:chatgpt' | 'outOfCredits' + | 'subscriptionLimit' // Theme color keys that are valid color values (must match ChatTheme keys) export type ThemeColorKey = @@ -29,6 +34,8 @@ export type ThemeColorKey = export type InputModeConfig = { /** Prefix icon shown before input (e.g., "!" for bash) */ icon: string | null + /** Colored label shown before input (e.g., "Plan") */ + label: string | null /** Theme color key for icon and border */ color: ThemeColorKey /** Input placeholder text */ @@ -39,81 +46,138 @@ export type InputModeConfig = { showAgentModeToggle: boolean /** Whether to disable slash command suggestions */ disableSlashSuggestions: boolean + /** Whether keyboard shortcuts (Escape, Backspace) can exit this mode */ + blockKeyboardExit: boolean } export const INPUT_MODE_CONFIGS: Record = { default: { icon: null, + label: null, color: 'foreground', placeholder: 'enter a coding task or / for commands', widthAdjustment: 0, showAgentModeToggle: true, disableSlashSuggestions: false, + blockKeyboardExit: false, }, bash: { - icon: '!', - color: 'success', + icon: null, + label: '!', + color: 'info', placeholder: 'enter bash command...', - widthAdjustment: 2, // 1 char + 1 padding + widthAdjustment: 4, // ` ! ` (3 chars) + 1 padding showAgentModeToggle: false, disableSlashSuggestions: true, + blockKeyboardExit: false, }, homeDir: { icon: null, + label: null, color: 'warning', placeholder: 'enter a coding task or / for commands', widthAdjustment: 0, showAgentModeToggle: true, disableSlashSuggestions: false, + blockKeyboardExit: false, }, - referral: { - icon: '◎', - color: 'warning', - placeholder: 'have a code? enter it here', - widthAdjustment: 2, // 1 char + 1 padding + interview: { + icon: null, + label: 'Interview', + color: 'info', + placeholder: 'describe a feature/bug or other request to be fleshed out...', + widthAdjustment: 12, + showAgentModeToggle: false, + disableSlashSuggestions: true, + blockKeyboardExit: false, + }, + plan: { + icon: null, + label: 'Plan', + color: 'info', + placeholder: 'describe what you want to plan...', + widthAdjustment: 7, + showAgentModeToggle: false, + disableSlashSuggestions: true, + blockKeyboardExit: false, + }, + review: { + icon: null, + label: 'Review', + color: 'info', + placeholder: 'describe what to review...', + widthAdjustment: 9, showAgentModeToggle: false, disableSlashSuggestions: true, + blockKeyboardExit: false, }, usage: { icon: null, + label: null, color: 'foreground', placeholder: 'enter a coding task or / for commands', widthAdjustment: 0, showAgentModeToggle: true, disableSlashSuggestions: false, + blockKeyboardExit: false, }, image: { icon: '📎', + label: null, color: 'imageCardBorder', placeholder: 'enter image path or Ctrl+V to paste', widthAdjustment: 3, // emoji width + padding showAgentModeToggle: false, disableSlashSuggestions: true, + blockKeyboardExit: false, }, help: { icon: null, + label: null, color: 'info', placeholder: 'enter a coding task or / for commands', widthAdjustment: 0, showAgentModeToggle: true, disableSlashSuggestions: false, + blockKeyboardExit: false, }, - 'connect:claude': { - icon: '🔗', + 'connect:chatgpt': { + icon: '🔐', + label: null, color: 'info', - placeholder: 'paste authorization code here...', - widthAdjustment: 3, // emoji width + padding + placeholder: 'authorizing in browser... press Escape to cancel', + widthAdjustment: 3, showAgentModeToggle: false, disableSlashSuggestions: true, + blockKeyboardExit: false, }, outOfCredits: { icon: null, + label: null, color: 'warning', placeholder: '', widthAdjustment: 0, showAgentModeToggle: false, disableSlashSuggestions: true, + blockKeyboardExit: false, }, + subscriptionLimit: { + icon: null, + label: null, + color: 'warning', + placeholder: '', + widthAdjustment: 0, + showAgentModeToggle: false, + disableSlashSuggestions: true, + blockKeyboardExit: true, // User must click "Continue with credits" or wait for reset + }, +} + +// In Freebuff, never show the agent mode toggle +if (IS_FREEBUFF) { + for (const key of Object.keys(INPUT_MODE_CONFIGS) as InputMode[]) { + INPUT_MODE_CONFIGS[key].showAgentModeToggle = false + } } export function getInputModeConfig(mode: InputMode): InputModeConfig { diff --git a/cli/src/utils/keyboard-actions.ts b/cli/src/utils/keyboard-actions.ts index 52f9869836..8a11ba782c 100644 --- a/cli/src/utils/keyboard-actions.ts +++ b/cli/src/utils/keyboard-actions.ts @@ -1,6 +1,8 @@ -import type { InputMode } from './input-modes' +import { getInputModeConfig, type InputMode } from './input-modes' +import { isLinefeedActingAsEnter } from './terminal-enter-detection' import type { KeyEvent } from '@opentui/core' + /** * State needed to determine keyboard actions in chat input contexts. * This is a focused subset of app state relevant to keyboard handling. @@ -82,6 +84,9 @@ export type ChatKeyboardAction = | { type: 'toggle-agent-mode' } | { type: 'unfocus-agent' } + // Toggle all collapsed/expanded + | { type: 'toggle-all' } + // Queue actions | { type: 'clear-queue' } @@ -127,7 +132,8 @@ export function resolveChatKeyboardAction( const isShiftTab = key.name === 'tab' && key.shift && !key.ctrl && !key.meta && !key.option const isEnter = - (key.name === 'return' || key.name === 'enter') && + (key.name === 'return' || key.name === 'enter' || + (key.name === 'linefeed' && isLinefeedActingAsEnter())) && !key.shift && !hasModifier(key) const isPageUp = key.name === 'pageup' && !hasModifier(key) @@ -146,7 +152,7 @@ export function resolveChatKeyboardAction( return { type: 'none' } } - // Priority 1: Feedback mode handlers + // Priority 1: Feedback mode - block global keys except Escape/Ctrl-C/Ctrl-V if (state.feedbackMode) { if (isEscape) { return { type: 'exit-feedback-mode' } @@ -156,11 +162,17 @@ export function resolveChatKeyboardAction( ? { type: 'exit-feedback-mode' } : { type: 'clear-feedback-input' } } + if (isCtrlV) { + return { type: 'paste' } + } + return { type: 'none' } } // Priority 2: Non-default input mode escape // Escape should exit the current mode BEFORE interrupting streams - if (isEscape && state.inputMode !== 'default') { + // Exception: modes with blockKeyboardExit cannot be escaped + const modeConfig = getInputModeConfig(state.inputMode) + if (isEscape && state.inputMode !== 'default' && !modeConfig.blockKeyboardExit) { return { type: 'exit-input-mode' } } @@ -178,10 +190,12 @@ export function resolveChatKeyboardAction( } // Priority 5: Backspace at position 0 exits non-default mode + // Exception: modes with blockKeyboardExit cannot be exited via keyboard if ( isBackspace && state.cursorPosition === 0 && state.inputMode !== 'default' && + !modeConfig.blockKeyboardExit && state.inputValue.length === 0 ) { return { type: 'backspace-exit-mode' } @@ -304,7 +318,14 @@ export function resolveChatKeyboardAction( return { type: 'history-down' } } - // Priority 11: Agent mode toggle (tab or shift-tab when not in menus) + // Priority 11: Toggle all collapsed/expanded (Ctrl+T) + const isCtrlT = key.ctrl && key.name === 't' && !key.meta && !key.option + + if (isCtrlT) { + return { type: 'toggle-all' } + } + + // Priority 12: Agent mode toggle (tab or shift-tab when not in menus) if ( (isShiftTab || isTab) && !state.slashMenuActive && @@ -313,12 +334,12 @@ export function resolveChatKeyboardAction( return { type: 'toggle-agent-mode' } } - // Priority 12: Unfocus agent + // Priority 13: Unfocus agent if (isEscape && state.focusedAgentId !== null) { return { type: 'unfocus-agent' } } - // Priority 13: Scroll with PageUp/PageDown + // Priority 14: Scroll with PageUp/PageDown if (isPageUp) { return { type: 'scroll-up' } } @@ -326,12 +347,12 @@ export function resolveChatKeyboardAction( return { type: 'scroll-down' } } - // Priority 14: Paste (ctrl-v) + // Priority 15: Paste (ctrl-v) if (isCtrlV) { return { type: 'paste' } } - // Priority 15: Exit app (ctrl-c double-tap) + // Priority 16: Exit app (ctrl-c double-tap) if (isCtrlC) { if (state.nextCtrlCWillExit) { return { type: 'exit-app' } diff --git a/cli/src/utils/layout-helpers.ts b/cli/src/utils/layout-helpers.ts index 70b37fa8b2..7f6fd58785 100644 --- a/cli/src/utils/layout-helpers.ts +++ b/cli/src/utils/layout-helpers.ts @@ -1,6 +1,15 @@ +/** Minimum width (in characters) for a grid column */ +export const MIN_COLUMN_WIDTH = 10 + +/** Maximum nesting depth for agent blocks */ +export const MAX_AGENT_DEPTH = 10 + +/** Horizontal padding (in characters) inside agent content boxes */ +export const AGENT_CONTENT_HORIZONTAL_PADDING = 12 + /** - * Compute the ideal number of columns for a grid layout - * Tries to create a balanced grid (e.g. 2x2 instead of 3x1 + 1) while respecting max columns + * Compute the ideal number of columns for a grid layout. + * Tries to create a balanced grid (e.g. 2x2 instead of 3x1 + 1) while respecting max columns. */ export function computeSmartColumns(itemCount: number, maxColumns: number): number { if (itemCount === 0) return 1 diff --git a/cli/src/utils/local-agent-registry.ts b/cli/src/utils/local-agent-registry.ts index bd3693ed9c..1781e50db3 100644 --- a/cli/src/utils/local-agent-registry.ts +++ b/cli/src/utils/local-agent-registry.ts @@ -1,12 +1,21 @@ import fs from 'fs' +import os from 'os' import path from 'path' import { pluralize } from '@codebuff/common/util/string' -import { loadLocalAgents as sdkLoadLocalAgents } from '@codebuff/sdk' +import { + loadLocalAgents as sdkLoadLocalAgents, + loadMCPConfigSync, +} from '@codebuff/sdk' +import type { MCPConfig } from '@codebuff/common/types/mcp' + +import { getSelectedFreebuffModel } from '../state/freebuff-model-store' import { getProjectRoot } from '../project-files' -import { AGENT_MODE_TO_ID, type AgentMode } from './constants' +import { IS_FREEBUFF, type AgentMode } from './constants' +import { getAgentIdForMode } from './freebuff-agent-selection' import { logger } from './logger' +import * as bundledAgentsModule from '../agents/bundled-agents.generated' import type { AgentDefinition } from '@codebuff/common/templates/initial-agents-dir/types/agent-definition' @@ -31,35 +40,75 @@ export interface LocalAgentInfo { let userAgentsCache: Record = {} // Map from agent ID to source file path (for UI "Open file" links) let userAgentFilePaths: Map = new Map() +// Cache for MCP servers loaded from mcp.json in .agents directories +let mcpServersCache: Record = {} /** * Initialize the agent registry by loading user agents via the SDK. * This must be called at CLI startup before any sync agent loading functions. + * + * Agents are loaded from: + * - {cwd}/.agents (project) + * - {cwd}/../.agents (parent, e.g. monorepo root) + * - ~/.agents (global, user's home directory) + * + * Later directories take precedence, so project agents override global ones. */ export async function initializeAgentRegistry(): Promise { - const agentsDir = findAgentsDirectory() - if (agentsDir) { - try { - userAgentsCache = await sdkLoadLocalAgents({ agentsPath: agentsDir }) - // Build ID-to-filepath map by scanning agent files - userAgentFilePaths = buildAgentFilePathMap(agentsDir) - } catch (error) { - // Fall back to empty cache if SDK loading fails, but log a warning - logger.warn({ error, agentsDir }, 'Failed to load user agents from .agents directory') - userAgentsCache = {} - userAgentFilePaths = new Map() + try { + // Let SDK load from all default directories (cwd, parent, home) + userAgentsCache = await sdkLoadLocalAgents({ verbose: false }) + // Build ID-to-filepath map by scanning all agent directories + userAgentFilePaths = buildAgentFilePathMap(getDefaultAgentDirs()) + } catch (error) { + // Fall back to empty cache if SDK loading fails, but log a warning + logger.warn( + { error }, + 'Failed to load user agents from .agents directories', + ) + userAgentsCache = {} + userAgentFilePaths = new Map() + } + + // Load MCP config from mcp.json files in .agents directories + try { + const mcpConfig = loadMCPConfigSync({ verbose: false }) + mcpServersCache = mcpConfig.mcpServers + if (Object.keys(mcpServersCache).length > 0) { + logger.debug( + { + mcpServers: Object.keys(mcpServersCache), + source: mcpConfig._sourceFilePath, + }, + '[agents] Loaded MCP servers from mcp.json', + ) } + } catch (error) { + logger.warn({ error }, 'Failed to load MCP config from .agents directories') + mcpServersCache = {} } } /** - * Scan agent directory and build a map from agent ID to source file path. + * Get default agent directories to scan. + * Matches the SDK's getDefaultAgentDirs() to ensure consistency. + */ +const getDefaultAgentDirs = (): string[] => { + const cwdAgents = path.join(process.cwd(), AGENTS_DIR_NAME) + const parentAgents = path.join(process.cwd(), '..', AGENTS_DIR_NAME) + const homeAgents = path.join(os.homedir(), AGENTS_DIR_NAME) + return [cwdAgents, parentAgents, homeAgents] +} + +/** + * Scan agent directories and build a map from agent ID to source file path. * Uses regex to extract IDs from files without requiring module loading. + * Later directories in the list take precedence (can override earlier ones). */ -const buildAgentFilePathMap = (agentsDir: string): Map => { +const buildAgentFilePathMap = (agentsDirs: string[]): Map => { const idToPath = new Map() const idRegex = /id\s*:\s*['"`]([^'"`]+)['"`]/i - + const scanDirectory = (dir: string): void => { try { const entries = fs.readdirSync(dir, { withFileTypes: true }) @@ -69,7 +118,12 @@ const buildAgentFilePathMap = (agentsDir: string): Map => { scanDirectory(fullPath) continue } - if (!entry.isFile() || !entry.name.endsWith('.ts') || entry.name.endsWith('.d.ts') || entry.name.endsWith('.test.ts')) { + if ( + !entry.isFile() || + !entry.name.endsWith('.ts') || + entry.name.endsWith('.d.ts') || + entry.name.endsWith('.test.ts') + ) { continue } try { @@ -86,8 +140,11 @@ const buildAgentFilePathMap = (agentsDir: string): Map => { // Skip directories that can't be read } } - - scanDirectory(agentsDir) + + // Scan all directories - later directories override earlier ones + for (const agentsDir of agentsDirs) { + scanDirectory(agentsDir) + } return idToPath } @@ -113,26 +170,12 @@ const getUserAgentDefinitions = (): AgentDefinition[] => { // Bundled agents loading (generated at build time by prebuild-agents.ts) // ============================================================================ -interface BundledAgentsModule { - bundledAgents: Record - getBundledAgentsAsLocalInfo: () => LocalAgentInfo[] -} - -// NOTE: Inline require() with try/catch is used because this file is generated at -// build time by prebuild-agents.ts and may not exist during development -let bundledAgentsModule: BundledAgentsModule | null = null -try { - bundledAgentsModule = require('../agents/bundled-agents.generated') -} catch { - // File not generated yet - running in development without prebuild -} - const getBundledAgents = (): Record => { - return bundledAgentsModule?.bundledAgents ?? {} + return bundledAgentsModule.bundledAgents ?? {} } const getBundledAgentsAsLocalInfo = (): LocalAgentInfo[] => { - return bundledAgentsModule?.getBundledAgentsAsLocalInfo?.() ?? [] + return bundledAgentsModule.getBundledAgentsAsLocalInfo?.() ?? [] } // ============================================================================ @@ -193,13 +236,18 @@ const cachedAgentsByMode: Map = new Map() /** * Load local agents for display in the '@' menu. - * + * * @param currentAgentMode - If provided, filters bundled agents to only include * subagents of the current mode's agent (e.g., base2's spawnableAgents for DEFAULT mode). * User's local agents from .agents/ are always included regardless of mode. */ -export const loadLocalAgents = (currentAgentMode?: AgentMode): LocalAgentInfo[] => { - const cacheKey = currentAgentMode ?? 'all' +export const loadLocalAgents = ( + currentAgentMode?: AgentMode, +): LocalAgentInfo[] => { + const selectedFreebuffModel = IS_FREEBUFF ? getSelectedFreebuffModel() : null + const cacheKey = selectedFreebuffModel + ? `${currentAgentMode ?? 'all'}:${selectedFreebuffModel}` + : (currentAgentMode ?? 'all') const cached = cachedAgentsByMode.get(cacheKey) if (cached) { return cached @@ -209,35 +257,37 @@ export const loadLocalAgents = (currentAgentMode?: AgentMode): LocalAgentInfo[] // compiled into the CLI binary at build time const bundledAgentsInfo = getBundledAgentsAsLocalInfo() const bundledAgents = getBundledAgents() - + // Filter bundled agents to only include subagents of the current mode's agent let filteredBundledAgents: LocalAgentInfo[] if (currentAgentMode) { - const currentAgentId = AGENT_MODE_TO_ID[currentAgentMode] + const currentAgentId = getAgentIdForMode(currentAgentMode) const currentAgentDef = bundledAgents[currentAgentId] + ? bundledAgents[currentAgentId] + : undefined const spawnableAgentIds = new Set(currentAgentDef?.spawnableAgents ?? []) - + // Only include bundled agents that are in the spawnableAgents list - filteredBundledAgents = bundledAgentsInfo.filter(agent => - spawnableAgentIds.has(agent.id) + filteredBundledAgents = bundledAgentsInfo.filter((agent) => + spawnableAgentIds.has(agent.id), ) } else { filteredBundledAgents = bundledAgentsInfo } - + const results: LocalAgentInfo[] = [...filteredBundledAgents] - const includedIds = new Set(filteredBundledAgents.map(a => a.id)) + const includedIds = new Set(filteredBundledAgents.map((a) => a.id)) // Get user agents from the SDK-loaded cache // User agents are always included (not filtered by mode) and can override bundled agents const userAgents = getUserAgentsAsLocalInfo() - + // Merge user agents - they override bundled agents with same ID // and are always included regardless of mode filtering for (const userAgent of userAgents) { if (includedIds.has(userAgent.id)) { // Replace bundled agent with user's version - const idx = results.findIndex(a => a.id === userAgent.id) + const idx = results.findIndex((a) => a.id === userAgent.id) if (idx !== -1) { results[idx] = userAgent } @@ -250,7 +300,7 @@ export const loadLocalAgents = (currentAgentMode?: AgentMode): LocalAgentInfo[] const sorted = results.sort((a, b) => a.displayName.localeCompare(b.displayName, 'en'), ) - + cachedAgentsByMode.set(cacheKey, sorted) return sorted } @@ -264,7 +314,7 @@ export const loadLocalAgents = (currentAgentMode?: AgentMode): LocalAgentInfo[] * Bundled agents are compiled into the CLI binary at build time. * User agents from .agents/ are loaded via SDK at startup and cached. * User agents can override bundled agents with the same ID. - * + * * Additionally, all user agent IDs are automatically added to the spawnableAgents * of any base agent (agents with IDs starting with 'base'), so users can spawn * their custom agents without needing to modify the base agent definition. @@ -272,17 +322,19 @@ export const loadLocalAgents = (currentAgentMode?: AgentMode): LocalAgentInfo[] export const loadAgentDefinitions = (): AgentDefinition[] => { // Start with bundled agents - these are the default Codebuff agents const bundledAgents = getBundledAgents() - const definitions: AgentDefinition[] = Object.values(bundledAgents).map(def => ({ ...def })) + const definitions: AgentDefinition[] = Object.values(bundledAgents).map( + (def) => ({ ...def }), + ) const bundledIds = new Set(Object.keys(bundledAgents)) // Get user agents from the SDK-loaded cache const userAgentDefs = getUserAgentDefinitions() - const userAgentIds = userAgentDefs.map(def => def.id) + const userAgentIds = userAgentDefs.map((def) => def.id) for (const agentDef of userAgentDefs) { // User agents override bundled agents with the same ID if (bundledIds.has(agentDef.id)) { - const idx = definitions.findIndex(d => d.id === agentDef.id) + const idx = definitions.findIndex((d) => d.id === agentDef.id) if (idx !== -1) { definitions[idx] = { ...agentDef } } @@ -308,6 +360,25 @@ export const loadAgentDefinitions = (): AgentDefinition[] => { } } + // Merge MCP servers from mcp.json into base agents + // This allows users to configure MCP tools that are available to the main agent + if (Object.keys(mcpServersCache).length > 0) { + for (const def of definitions) { + // Consider any agent with an ID starting with 'base' as a base agent + if (def.id.startsWith('base')) { + // Initialize mcpServers if not present + if (!def.mcpServers) { + def.mcpServers = {} + } + // Merge MCP servers (user config can override existing servers) + def.mcpServers = { + ...def.mcpServers, + ...mcpServersCache, + } + } + } + } + return definitions } @@ -391,4 +462,13 @@ export const __resetLocalAgentRegistryForTests = (): void => { cachedAgentsDir = null userAgentsCache = {} userAgentFilePaths = new Map() + mcpServersCache = {} +} + +/** + * Get the currently loaded MCP servers from mcp.json. + * Useful for debugging and displaying loaded MCP configuration. + */ +export const getLoadedMCPServers = (): Record => { + return { ...mcpServersCache } } diff --git a/cli/src/utils/logger.ts b/cli/src/utils/logger.ts index 8a7144f873..98a5410420 100644 --- a/cli/src/utils/logger.ts +++ b/cli/src/utils/logger.ts @@ -2,8 +2,15 @@ import { appendFileSync, existsSync, mkdirSync, unlinkSync } from 'fs' import path, { dirname } from 'path' import { format as stringFormat } from 'util' + +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { env, IS_DEV, IS_TEST, IS_CI } from '@codebuff/common/env' import { createAnalyticsDispatcher } from '@codebuff/common/util/analytics-dispatcher' +import { getAnalyticsEventId } from '@codebuff/common/util/analytics-log' +import { + isFullTelemetryEnabled, + summarizeAnalyticsValue, +} from '@codebuff/common/util/analytics-sampling' import { pino } from 'pino' import { @@ -35,6 +42,23 @@ const analyticsDispatcher = createAnalyticsDispatcher({ bufferWhenNoUser: true, }) +/** + * Safely stringify an object, handling circular references. + * Replaces circular references with '[Circular]' placeholder. + */ +function safeStringify(obj: unknown): string { + const seen = new WeakSet() + return JSON.stringify(obj, (_key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]' + } + seen.add(value) + } + return value + }) +} + function isEmptyObject(value: any): boolean { return ( value != null && @@ -145,10 +169,35 @@ function sendAnalyticsAndLog( }) } + // Send all log events to PostHog in production for better observability + // Skip if the log already has an eventId (to avoid duplicate tracking) + const hasEventId = includeData && getAnalyticsEventId(normalizedData) !== null + if (!IS_DEV && !IS_TEST && !IS_CI && !hasEventId) { + const fullTelemetry = isFullTelemetryEnabled({ + distinctId: loggerContext.userId, + properties: loggerContext, + }) + const includeRawData = + fullTelemetry || level === 'error' || level === 'fatal' + const dataProperties = + includeData && includeRawData + ? { data: normalizedData } + : includeData + ? { dataSummary: summarizeAnalyticsValue(normalizedData) } + : {} + + trackEvent(AnalyticsEvent.CLI_LOG, { + level, + msg: stringFormat(normalizedMsg ?? '', ...args), + ...dataProperties, + ...loggerContext, + }) + } + // In dev mode, use appendFileSync for real-time logging (Bun has issues with pino sync) // In prod mode, use pino for better performance if (IS_DEV && logPath) { - const logEntry = JSON.stringify({ + const logEntry = safeStringify({ level: level.toUpperCase(), timestamp: new Date().toISOString(), ...loggerContext, diff --git a/cli/src/utils/markdown-renderer.tsx b/cli/src/utils/markdown-renderer.tsx index 0363ed8f28..662602cc25 100644 --- a/cli/src/utils/markdown-renderer.tsx +++ b/cli/src/utils/markdown-renderer.tsx @@ -644,28 +644,55 @@ const renderLink = (link: Link, state: RenderState): ReactNode[] => { } /** - * Truncates text to fit within a specified width, adding ellipsis if needed. + * Wraps text to fit within a specified width, returning an array of lines. * Uses stringWidth to properly measure Unicode and wide characters. + * Performs word-wrapping where possible, falling back to character-level + * breaking for words that exceed the column width. */ -const truncateText = (text: string, maxWidth: number): string => { - if (maxWidth < 1) return '' +const wrapText = (text: string, maxWidth: number): string[] => { + if (maxWidth < 1) return [''] + if (!text) return [''] const textWidth = stringWidth(text) - if (textWidth <= maxWidth) { - return text - } - - // Need to truncate - leave room for ellipsis - if (maxWidth === 1) return '…' - - let truncated = '' - let width = 0 - for (const char of text) { - const charWidth = stringWidth(char) - if (width + charWidth + 1 > maxWidth) break // +1 for ellipsis - truncated += char - width += charWidth + if (textWidth <= maxWidth) return [text] + + const lines: string[] = [] + let currentLine = '' + let currentWidth = 0 + const tokens = text.split(/(\s+)/) + + for (const token of tokens) { + if (!token) continue + const tokenWidth = stringWidth(token) + const isWhitespace = /^\s+$/.test(token) + + // Skip leading whitespace on new lines + if (isWhitespace && currentWidth === 0) continue + + if (tokenWidth > maxWidth && !isWhitespace) { + // Break long words character by character + for (const char of token) { + const charWidth = stringWidth(char) + if (currentWidth + charWidth > maxWidth) { + if (currentLine) lines.push(currentLine) + currentLine = char + currentWidth = charWidth + } else { + currentLine += char + currentWidth += charWidth + } + } + } else if (currentWidth + tokenWidth > maxWidth) { + if (currentLine) lines.push(currentLine.trimEnd()) + currentLine = isWhitespace ? '' : token + currentWidth = isWhitespace ? 0 : tokenWidth + } else { + currentLine += token + currentWidth += tokenWidth + } } - return truncated + '…' + + if (currentLine) lines.push(currentLine.trimEnd()) + return lines.length > 0 ? lines : [''] } /** @@ -756,53 +783,60 @@ const renderTable = (table: Table, state: RenderState): ReactNode[] => { nodes.push('\n') } + // Pre-wrap all cell contents so we know the height of each row + const wrappedRows: string[][][] = rows.map((row) => + Array.from({ length: numCols }, (_, i) => { + const cellText = row[i] || '' + return wrapText(cellText, columnWidths[i]) + }), + ) + // Render top border renderSeparator('┌', '┬', '┐') - // Render each row - table.children.forEach((row, rowIdx) => { + // Render each row with word-wrapped cells + wrappedRows.forEach((wrappedCells, rowIdx) => { const isHeader = rowIdx === 0 - const cells = (row as TableRow).children as TableCell[] + const rowHeight = Math.max(...wrappedCells.map((lines) => lines.length), 1) + + // Render each visual line in the row + for (let lineIdx = 0; lineIdx < rowHeight; lineIdx++) { + for (let cellIdx = 0; cellIdx < numCols; cellIdx++) { + const colWidth = columnWidths[cellIdx] + const lineText = wrappedCells[cellIdx][lineIdx] || '' + const displayText = padText(lineText, colWidth) + + // Left border for first cell + if (cellIdx === 0) { + nodes.push( + + │ + , + ) + } + + // Cell content with padding + nodes.push( + + {' '} + {displayText} + {' '} + , + ) - // Render row content - for (let cellIdx = 0; cellIdx < numCols; cellIdx++) { - const cell = cells[cellIdx] - const cellText = cell ? nodeToPlainText(cell).trim() : '' - const colWidth = columnWidths[cellIdx] - - // Truncate and pad the cell content - const displayText = padText(truncateText(cellText, colWidth), colWidth) - - // Left border for first cell - if (cellIdx === 0) { + // Separator or right border nodes.push( , ) } - - // Cell content with padding - nodes.push( - - {' '} - {displayText} - {' '} - , - ) - - // Separator or right border - nodes.push( - - │ - , - ) + nodes.push('\n') } - nodes.push('\n') // Add separator line after header if (isHeader) { diff --git a/cli/src/utils/message-block-helpers.ts b/cli/src/utils/message-block-helpers.ts index c1b8cde174..2d0eb29fed 100644 --- a/cli/src/utils/message-block-helpers.ts +++ b/cli/src/utils/message-block-helpers.ts @@ -1,7 +1,7 @@ import { isEqual } from 'lodash' import { formatToolOutput } from './codebuff-client' -import { shouldCollapseByDefault } from './constants' +import { shouldCollapseByDefault, shouldCollapseForParent } from './constants' import type { ContentBlock, @@ -16,10 +16,11 @@ import type { * getAgentBaseName('codebuff/file-picker@0.0.2') // 'file-picker' * getAgentBaseName('file-picker@1.0.0') // 'file-picker' * getAgentBaseName('file-picker') // 'file-picker' + * getAgentBaseName('file_picker') // 'file-picker' */ export const getAgentBaseName = (type: string): string => { const segment = type.split('/').pop() ?? type - return segment.split('@')[0] + return segment.split('@')[0].replace(/_/g, '-') } /** @@ -79,7 +80,7 @@ export const autoCollapseBlocks = (blocks: ContentBlock[]): ContentBlock[] => { return blocks.map((block) => { // Handle thinking blocks (grouped text blocks) if (block.type === 'text' && block.thinkingId) { - return block.userOpened ? block : { ...block, isCollapsed: true } + return block.userOpened ? block : { ...block, thinkingCollapseState: 'hidden' as const } } // Handle agent blocks @@ -250,6 +251,30 @@ export const appendInterruptionNotice = ( return [...blocks, interruptionNotice] } +/** + * Recursively finds an agent block by ID and returns its agent type. + * Returns undefined if not found. + */ +export const findAgentTypeById = ( + blocks: ContentBlock[], + agentId: string, +): string | undefined => { + for (const block of blocks) { + if (block.type === 'agent') { + if (block.agentId === agentId) { + return block.agentType + } + if (block.blocks) { + const found = findAgentTypeById(block.blocks, agentId) + if (found) { + return found + } + } + } + } + return undefined +} + /** * Options for creating an agent content block. */ @@ -262,6 +287,8 @@ export interface CreateAgentBlockOptions { spawnToolCallId?: string /** The index within the spawn_agents call */ spawnIndex?: number + /** The agent type of the parent agent that spawned this one */ + parentAgentType?: string } /** @@ -270,7 +297,10 @@ export interface CreateAgentBlockOptions { export const createAgentBlock = ( options: CreateAgentBlockOptions, ): AgentContentBlock => { - const { agentId, agentType, prompt, params, spawnToolCallId, spawnIndex } = options + const { agentId, agentType, prompt, params, spawnToolCallId, spawnIndex, parentAgentType } = options + const shouldCollapse = + shouldCollapseByDefault(agentType || '') || + shouldCollapseForParent(agentType || '', parentAgentType) return { type: 'agent', agentId, @@ -283,7 +313,7 @@ export const createAgentBlock = ( ...(params && { params }), ...(spawnToolCallId && { spawnToolCallId }), ...(spawnIndex !== undefined && { spawnIndex }), - ...(shouldCollapseByDefault(agentType || '') && { isCollapsed: true }), + ...(shouldCollapse && { isCollapsed: true }), } } @@ -437,6 +467,7 @@ export const moveSpawnAgentBlock = ( parentId?: string, params?: Record, prompt?: string, + realAgentType?: string, ): ContentBlock[] => { const updateAgentBlock = (block: ContentBlock): ContentBlock => { if (block.type !== 'agent') { @@ -455,6 +486,11 @@ export const moveSpawnAgentBlock = ( updatedBlock.initialPrompt = prompt } + if (realAgentType) { + updatedBlock.agentType = realAgentType + updatedBlock.agentName = realAgentType + } + return updatedBlock } 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/message-updater.ts b/cli/src/utils/message-updater.ts index b827009687..2fba21cde3 100644 --- a/cli/src/utils/message-updater.ts +++ b/cli/src/utils/message-updater.ts @@ -1,4 +1,4 @@ -import type { ChatMessage, ContentBlock } from '../types/chat' +import type { ChatMessage, ContentBlock, TextContentBlock } from '../types/chat' // Small wrapper to avoid repeating the ai-message map/update pattern. export type SetMessagesFn = ( @@ -12,6 +12,8 @@ export type MessageUpdater = { ) => void markComplete: (metadata?: Partial) => void setError: (message: string) => void + /** Clears the userError field (e.g., when a new message is sent successfully) */ + clearUserError: () => void addBlock: (block: ContentBlock) => void } @@ -55,9 +57,25 @@ export const createMessageUpdater = ( const markComplete = (metadata?: Partial) => { updateAiMessage((msg) => { const { metadata: messageMetadata, ...rest } = metadata ?? {} + + // Mark native reasoning blocks as complete by setting thinkingOpen = false + // This ensures thinking blocks auto-collapse when the message finishes + // Check for thinkingOpen !== false to handle both true (native) and undefined (legacy) + const updatedBlocks = msg.blocks?.map((block) => { + if ( + block.type === 'text' && + (block as TextContentBlock).textType === 'reasoning' && + (block as TextContentBlock).thinkingOpen !== false + ) { + return { ...block, thinkingOpen: false } as ContentBlock + } + return block + }) + const nextMessage: ChatMessage = { ...msg, isComplete: true, + ...(updatedBlocks && { blocks: updatedBlocks }), ...rest, } @@ -73,13 +91,22 @@ export const createMessageUpdater = ( } const setError = (message: string) => { + updateAiMessage((msg) => ({ + ...msg, + userError: message, + isComplete: true, + })) + } + + /** + * Clears the userError field from the message. + * Call this when starting a new successful interaction to dismiss any previous error banners. + */ + const clearUserError = () => { updateAiMessage((msg) => { - const nextMessage: ChatMessage = { - ...msg, - content: message, - isComplete: true, - } - return nextMessage + if (!msg.userError) return msg + const { userError: _, ...rest } = msg + return rest as ChatMessage }) } @@ -88,6 +115,7 @@ export const createMessageUpdater = ( updateAiMessageBlocks, markComplete, setError, + clearUserError, addBlock, } } @@ -122,6 +150,8 @@ export const createBatchedMessageUpdater = ( const dispose = () => { if (isDisposed) return + // Flush any pending updates before disposing to prevent data loss + flush() isDisposed = true if (intervalId !== null) { clearInterval(intervalId) @@ -170,9 +200,25 @@ export const createBatchedMessageUpdater = ( prev.map((msg) => { if (msg.id !== aiMessageId) return msg const { metadata: messageMetadata, ...rest } = metadata ?? {} + + // Mark native reasoning blocks as complete by setting thinkingOpen = false + // This ensures thinking blocks auto-collapse when the message finishes + // Check for thinkingOpen !== false to handle both true (native) and undefined (legacy) + const updatedBlocks = msg.blocks?.map((block) => { + if ( + block.type === 'text' && + (block as TextContentBlock).textType === 'reasoning' && + (block as TextContentBlock).thinkingOpen !== false + ) { + return { ...block, thinkingOpen: false } as ContentBlock + } + return block + }) + const nextMessage: ChatMessage = { ...msg, isComplete: true, + ...(updatedBlocks && { blocks: updatedBlocks }), ...rest, } if (messageMetadata) { @@ -187,28 +233,45 @@ export const createBatchedMessageUpdater = ( } const setError = (message: string) => { - // Clear pending updates (they'll be overwritten anyway) and stop the interval - pendingUpdaters.length = 0 + // Flush any pending updates first so we don't lose streamed content + flush() + // Stop the interval dispose() - // Apply error immediately, preserving blocks for debugging context + // Apply error immediately while preserving existing content and blocks setMessages((prev) => prev.map((msg) => { if (msg.id !== aiMessageId) return msg return { ...msg, - content: message, + userError: message, isComplete: true, } }), ) } + /** + * Clears the userError field from the message immediately (bypasses batch queue). + * Call this when starting a new successful interaction to dismiss any previous error banners. + */ + const clearUserError = () => { + // Apply immediately (bypass batch queue) so error banners are dismissed instantly + setMessages((prev) => + prev.map((msg) => { + if (msg.id !== aiMessageId || !msg.userError) return msg + const { userError: _, ...rest } = msg + return rest as ChatMessage + }), + ) + } + return { updateAiMessage, updateAiMessageBlocks, markComplete, setError, + clearUserError, addBlock, flush, dispose, diff --git a/cli/src/utils/open-file.ts b/cli/src/utils/open-file.ts index b4f3c0a0d4..c565a8d1b2 100644 --- a/cli/src/utils/open-file.ts +++ b/cli/src/utils/open-file.ts @@ -1,10 +1,11 @@ import { spawn } from 'child_process' import os from 'os' -import type { CliEnv } from '../types/env' import { getCliEnv } from './env' import { logger } from './logger' +import type { CliEnv } from '../types/env' + const isWindows = os.platform() === 'win32' const isMac = os.platform() === 'darwin' diff --git a/cli/src/utils/open-url.ts b/cli/src/utils/open-url.ts new file mode 100644 index 0000000000..1dffeaac06 --- /dev/null +++ b/cli/src/utils/open-url.ts @@ -0,0 +1,37 @@ +import os from 'os' + +import open from 'open' + +import { getCliEnv } from './env' +import { logger } from './logger' + +/** + * Safely open a URL in the user's default browser. + * + * On headless Linux (no DISPLAY or WAYLAND_DISPLAY), calling `open()` spawns + * `xdg-open` which can crash the entire process — even inside a try/catch — + * because the child process may trigger fatal signals. This wrapper detects + * headless environments and skips the call entirely. + * + * @returns `true` if the browser was (likely) opened, `false` if skipped. + */ +export async function safeOpen(url: string): Promise { + if (os.platform() === 'linux') { + const env = getCliEnv() + const hasDisplay = Boolean(env.DISPLAY || env.WAYLAND_DISPLAY) + if (!hasDisplay) { + logger.warn( + 'No display server detected (DISPLAY / WAYLAND_DISPLAY unset). Skipping browser open.', + ) + return false + } + } + + try { + await open(url) + return true + } catch (err) { + logger.error(err, 'Failed to open browser') + return false + } +} diff --git a/cli/src/utils/path-helpers.ts b/cli/src/utils/path-helpers.ts index 7481b114fb..7ce6c37ace 100644 --- a/cli/src/utils/path-helpers.ts +++ b/cli/src/utils/path-helpers.ts @@ -1,10 +1,11 @@ import os from 'os' import path from 'path' -import type { CliEnv } from '../types/env' import { getCliEnv } from './env' import { getProjectRoot } from '../project-files' +import type { CliEnv } from '../types/env' + /** * Format a path for display, replacing home directory with ~ * @param cwd - The path to format diff --git a/cli/src/utils/pending-attachments.ts b/cli/src/utils/pending-attachments.ts index a769a2abb4..595bda3b94 100644 --- a/cli/src/utils/pending-attachments.ts +++ b/cli/src/utils/pending-attachments.ts @@ -1,13 +1,9 @@ -import { existsSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import path from 'node:path' -import { showClipboardMessage } from './clipboard' import { processImageFile, resolveFilePath, isImageFile } from './image-handler' -import { - useChatStore, - type PendingAttachment, - type PendingImageAttachment, -} from '../state/chat-store' +import { useChatStore } from '../state/chat-store' +import type { PendingAttachment } from '../types/store' /** * Exit image input mode if currently active. @@ -82,10 +78,9 @@ export async function addPendingImageFromFile( }), })) - // Exit image mode and show status message after successfully adding an image + // Exit image mode after successfully processing an image if (result.success) { exitImageModeIfActive() - showClipboardMessage(`🖼️ Attached ${filename}`, { durationMs: 5000 }) } } @@ -119,6 +114,10 @@ const AUTO_REMOVE_ERROR_DELAY_MS = 3000 // Counter for generating unique placeholder IDs let clipboardPlaceholderCounter = 0 +// Map to store cleanup timers for error images, keyed by image path +// This allows clearing the timer if the image is removed before the delay expires +const errorImageTimers = new Map>() + /** * Add a placeholder for a clipboard image immediately and return its path. * Use with addPendingImageFromFile's replacePlaceholder parameter. @@ -137,6 +136,8 @@ export function addClipboardPlaceholder(): string { * Add a pending image with an error note (e.g., unsupported format, not found). * Used when we want to show the image in the banner with an error state. * Error images are automatically removed after a short delay. + * + * Error images are automatically removed after AUTO_REMOVE_ERROR_DELAY_MS. */ export function addPendingImageWithError( imagePath: string, @@ -150,10 +151,31 @@ export function addPendingImageWithError( note, }) + // Clear any existing timer for this path (shouldn't happen, but be safe) + const existingTimer = errorImageTimers.get(imagePath) + if (existingTimer) { + clearTimeout(existingTimer) + } + // Auto-remove error images after a delay - setTimeout(() => { + const timer = setTimeout(() => { + errorImageTimers.delete(imagePath) useChatStore.getState().removePendingImage(imagePath) }, AUTO_REMOVE_ERROR_DELAY_MS) + + errorImageTimers.set(imagePath, timer) +} + +/** + * Clear the auto-remove timer for an error image. + * Call this when manually removing an image to prevent memory leaks. + */ +export function clearErrorImageTimer(imagePath: string): void { + const timer = errorImageTimers.get(imagePath) + if (timer) { + clearTimeout(timer) + errorImageTimers.delete(imagePath) + } } /** @@ -170,7 +192,7 @@ export async function validateAndAddImage( // Check if file exists if (!existsSync(resolvedPath)) { const error = 'file not found' - addPendingImageWithError(imagePath, `❌ ${error}`) + addPendingImageWithError(resolvedPath, `❌ ${error}`) return { success: false, error } } @@ -187,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. */ @@ -196,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/renderer-cleanup.ts b/cli/src/utils/renderer-cleanup.ts index cbb3ad01b1..58d21367d6 100644 --- a/cli/src/utils/renderer-cleanup.ts +++ b/cli/src/utils/renderer-cleanup.ts @@ -1,5 +1,8 @@ +import { resetTerminalTitle } from './terminal-title' + import type { CliRenderer } from '@opentui/core' + let renderer: CliRenderer | null = null let handlersInstalled = false let terminalStateReset = false @@ -9,6 +12,7 @@ let terminalStateReset = false * These are written directly to stdout to ensure they're sent even if the renderer is in a bad state. * * Sequences: + * - \x1b[?1049l: Exit alternate screen buffer (restores main screen) * - \x1b[?1000l: Disable X10 mouse mode * - \x1b[?1002l: Disable button event mouse mode * - \x1b[?1003l: Disable any-event mouse mode (all motion tracking) @@ -17,7 +21,8 @@ let terminalStateReset = false * - \x1b[?2004l: Disable bracketed paste mode * - \x1b[?25h: Show cursor (safety measure) */ -const TERMINAL_RESET_SEQUENCES = +export const TERMINAL_RESET_SEQUENCES = + '\x1b[?1049l' + // Exit alternate screen buffer '\x1b[?1000l' + // Disable X10 mouse mode '\x1b[?1002l' + // Disable button event mouse mode '\x1b[?1003l' + // Disable any-event mouse mode (all motion) @@ -39,9 +44,20 @@ function resetTerminalState(): void { terminalStateReset = true try { + if (process.stdin.isTTY && process.stdin.setRawMode) { + process.stdin.setRawMode(false) + } + } catch { + // Ignore errors - stdin may already be closed + } + try { + // Reset terminal title to default + resetTerminalTitle() // Write directly to stdout - this is synchronous and will complete // before the process exits, ensuring the terminal is reset - process.stdout.write(TERMINAL_RESET_SEQUENCES) + if (process.stdout.isTTY) { + process.stdout.write(TERMINAL_RESET_SEQUENCES) + } } catch { // Ignore errors - stdout may already be closed } @@ -117,21 +133,23 @@ export function installProcessCleanupHandlers(cliRenderer: CliRenderer): void { // uncaughtException - Safety net for unhandled errors process.on('uncaughtException', (error) => { + cleanup() // Exit alt screen FIRST so error output is visible on the main screen try { console.error('Uncaught exception:', error) } catch { // Ignore logging errors } - cleanupAndExit(1) + process.exit(1) }) // unhandledRejection - Safety net for unhandled promise rejections process.on('unhandledRejection', (reason) => { + cleanup() // Exit alt screen FIRST so error output is visible on the main screen try { console.error('Unhandled rejection:', reason) } catch { // Ignore logging errors } - cleanupAndExit(1) + process.exit(1) }) } diff --git a/cli/src/utils/run-state-storage.ts b/cli/src/utils/run-state-storage.ts index a2993238fd..8ca9168127 100644 --- a/cli/src/utils/run-state-storage.ts +++ b/cli/src/utils/run-state-storage.ts @@ -1,7 +1,12 @@ import * as fs from 'fs' import path from 'path' +import { randomUUID } from 'node:crypto' -import { getCurrentChatDir, getMostRecentChatDir, getProjectDataDir } from '../project-files' +import { + getCurrentChatDir, + getMostRecentChatDir, + getProjectDataDir, +} from '../project-files' import { logger } from './logger' import type { ChatMessage, ContentBlock } from '../types/chat' @@ -21,9 +26,9 @@ type SavedChatState = { */ function extractToggleIds(blocks: ContentBlock[] | undefined): string[] { if (!blocks) return [] - + const ids: string[] = [] - + for (const block of blocks) { if (block.type === 'agent') { ids.push(block.agentId) @@ -33,7 +38,7 @@ function extractToggleIds(blocks: ContentBlock[] | undefined): string[] { ids.push(block.toolCallId) } } - + return ids } @@ -42,11 +47,11 @@ function extractToggleIds(blocks: ContentBlock[] | undefined): string[] { */ export function getAllToggleIdsFromMessages(messages: ChatMessage[]): string[] { const ids: string[] = [] - + for (const message of messages) { ids.push(...extractToggleIds(message.blocks)) } - + return ids } @@ -69,18 +74,16 @@ export function getChatMessagesPath(): string { /** * Save both the RunState and ChatMessage[] to disk */ -export function saveChatState(runState: RunState, messages: ChatMessage[]): void { +export function saveChatState( + runState: RunState, + messages: ChatMessage[], +): void { try { const runStatePath = getRunStatePath() const messagesPath = getChatMessagesPath() - + fs.writeFileSync(runStatePath, JSON.stringify(runState, null, 2)) fs.writeFileSync(messagesPath, JSON.stringify(messages, null, 2)) - - logger.debug( - { runStatePath, messagesPath, messageCount: messages.length }, - 'Saved chat state to disk' - ) } catch (error) { logger.error( { @@ -97,14 +100,19 @@ export function saveChatState(runState: RunState, messages: ChatMessage[]): void * recently modified chat directory is used. * Returns null if no previous chat exists or files can't be parsed. */ -export function loadMostRecentChatState(chatId?: string): SavedChatState | null { +export function loadMostRecentChatState( + chatId?: string, +): SavedChatState | null { try { let chatDir: string | null = null if (chatId && chatId.trim().length > 0) { const baseDir = path.join(getProjectDataDir(), 'chats') const candidateDir = path.join(baseDir, chatId.trim()) - if (fs.existsSync(candidateDir) && fs.statSync(candidateDir).isDirectory()) { + if ( + fs.existsSync(candidateDir) && + fs.statSync(candidateDir).isDirectory() + ) { chatDir = candidateDir } else { logger.debug( @@ -138,12 +146,18 @@ export function loadMostRecentChatState(chatId?: string): SavedChatState | null const messagesContent = fs.readFileSync(messagesPath, 'utf8') const runState = JSON.parse(runStateContent) as RunState + runState.traceSessionId ??= randomUUID() const messages = JSON.parse(messagesContent) as ChatMessage[] const resolvedChatId = path.basename(chatDir) logger.info( - { runStatePath, messagesPath, messageCount: messages.length, chatId: resolvedChatId }, + { + runStatePath, + messagesPath, + messageCount: messages.length, + chatId: resolvedChatId, + }, 'Loaded chat state from chat directory', ) @@ -166,18 +180,15 @@ export function clearChatState(): void { try { const runStatePath = getRunStatePath() const messagesPath = getChatMessagesPath() - + if (fs.existsSync(runStatePath)) { fs.unlinkSync(runStatePath) } if (fs.existsSync(messagesPath)) { fs.unlinkSync(messagesPath) } - - logger.debug( - { runStatePath, messagesPath }, - 'Cleared chat state files' - ) + + logger.debug({ runStatePath, messagesPath }, 'Cleared chat state files') } catch (error) { logger.error( { diff --git a/cli/src/utils/sdk-event-handlers.ts b/cli/src/utils/sdk-event-handlers.ts index b7443d089e..4cfdf5df0a 100644 --- a/cli/src/utils/sdk-event-handlers.ts +++ b/cli/src/utils/sdk-event-handlers.ts @@ -3,6 +3,8 @@ import { match } from 'ts-pattern' import { appendTextToRootStream, appendToolToAgentBlock, + closeNativeReasoningBlock, + closeNativeReasoningInAgent, markAgentComplete, } from './block-operations' import { shouldHideAgent } from './constants' @@ -10,6 +12,7 @@ import { createAgentBlock, extractPlanFromBuffer, extractSpawnAgentResultContent, + findAgentTypeById, insertPlanBlock, nestBlockUnderParent, transformAskUserBlocks, @@ -35,7 +38,6 @@ import type { PrintModeFinish, PrintModeSubagentFinish, PrintModeSubagentStart, - PrintModeText, PrintModeToolCall, PrintModeToolResult, } from '@codebuff/common/types/print-mode' @@ -176,21 +178,12 @@ const handleSubagentStart = ( ) if (spawnAgentMatch) { - state.logger.info( - { - tempId: spawnAgentMatch.tempId, - realAgentId: event.agentId, - agentType: event.agentType, - hasParentAgentId: !!event.parentAgentId, - }, - 'Matching spawn_agents block found', - ) - state.message.updater.updateAiMessageBlocks((blocks) => resolveSpawnAgentToReal({ blocks, match: spawnAgentMatch, realAgentId: event.agentId, + realAgentType: event.agentType, parentAgentId: event.parentAgentId, params: event.params, prompt: event.prompt, @@ -216,14 +209,20 @@ const handleSubagentStart = ( 'Creating new agent block (no spawn_agents match)', ) - const newAgentBlock = createAgentBlock({ - agentId: event.agentId, - agentType: event.agentType || '', - prompt: event.prompt, - params: event.params, - }) - state.message.updater.updateAiMessageBlocks((blocks) => { + // Look up the parent agent's type if there's a parent agent ID + const parentAgentType = event.parentAgentId + ? findAgentTypeById(blocks, event.parentAgentId) + : undefined + + const newAgentBlock = createAgentBlock({ + agentId: event.agentId, + agentType: event.agentType || '', + prompt: event.prompt, + params: event.params, + parentAgentType, + }) + if (event.parentAgentId) { const { blocks: nestedBlocks, parentFound } = nestBlockUnderParent( blocks, @@ -273,6 +272,11 @@ const handleSpawnAgentsToolCall = ( }) state.message.updater.updateAiMessageBlocks((blocks) => { + // Look up the parent agent's type if there's a parent agent ID + const parentAgentType = event.agentId + ? findAgentTypeById(blocks, event.agentId) + : undefined + const newAgentBlocks: ContentBlock[] = agents .map((agent: any, originalIndex: number) => ({ agent, originalIndex })) .filter(({ agent }) => !shouldHideAgent(agent.agent_type || '')) @@ -281,8 +285,10 @@ const handleSpawnAgentsToolCall = ( agentId: `${event.toolCallId}-${originalIndex}`, agentType: agent.agent_type || '', prompt: agent.prompt, + params: agent.params, spawnToolCallId: event.toolCallId, spawnIndex: originalIndex, + parentAgentType, }), ) @@ -323,6 +329,19 @@ const handleRegularToolCall = ( } const handleToolCall = (state: EventHandlerState, event: PrintModeToolCall) => { + // Close any open native reasoning blocks when a tool call happens + // (agent may go directly from thinking to tool calls without emitting text) + // This must happen BEFORE any early returns (spawn_agents, hidden tools) + if (event.parentAgentId && event.agentId) { + // For agent tool calls, close reasoning in that specific agent + state.message.updater.updateAiMessageBlocks((blocks) => + closeNativeReasoningInAgent(blocks, event.agentId as string), + ) + } else if (!event.parentAgentId) { + // For root tool calls, close reasoning at root level + state.message.updater.updateAiMessageBlocks(closeNativeReasoningBlock) + } + if (event.toolName === 'spawn_agents' && event.input?.agents) { handleSpawnAgentsToolCall(state, event) return @@ -339,43 +358,79 @@ const handleToolCall = (state: EventHandlerState, event: PrintModeToolCall) => { /** * Recursively finds and updates agent blocks that match a spawn_agents tool call. */ -const updateSpawnAgentBlocks = ( - blocks: ContentBlock[], +const updateSpawnAgentBlock = ( + block: ContentBlock, toolCallId: string, results: any[], -): ContentBlock[] => { - return blocks.map((block) => { - if (block.type !== 'agent') { - return block - } +): ContentBlock | null => { + if (block.type !== 'agent') { + return block + } - if (block.spawnToolCallId === toolCallId && block.spawnIndex !== undefined && block.blocks) { - const result = results[block.spawnIndex] - - if (result?.value) { - const { content, hasError } = extractSpawnAgentResultContent(result.value) - // Preserve streamed content (agents like commander stream their output) - const hasStreamedContent = block.blocks.length > 0 - if (hasError || content || hasStreamedContent) { - return { - ...block, - blocks: hasStreamedContent ? block.blocks : [{ type: 'text', content } as ContentBlock], - status: hasError ? ('failed' as const) : ('complete' as const), - } + const spawnIndex = block.spawnIndex + const childBlocks = block.blocks + const isSpawnResultTarget = + block.spawnToolCallId === toolCallId && + spawnIndex !== undefined && + childBlocks + + if (isSpawnResultTarget) { + const result = results[spawnIndex] + if (result?.value) { + const { content, hasError } = extractSpawnAgentResultContent(result.value) + + if (hasError) { + if (childBlocks.length === 0) { + return null + } + + return { + ...block, + blocks: content + ? [...childBlocks, { type: 'text', content } as ContentBlock] + : childBlocks, + status: 'complete' as const, } } - } - // Recursively process nested agent blocks - if (block.blocks?.length) { - const updatedNestedBlocks = updateSpawnAgentBlocks(block.blocks, toolCallId, results) - if (updatedNestedBlocks !== block.blocks) { - return { ...block, blocks: updatedNestedBlocks } + // Agents like thinker return all output at the end via lastMessage, + // while agents like basher may have already streamed their text. + const hasStreamedTextContent = childBlocks.some( + (b) => b.type === 'text' && b.textType === 'text', + ) + const finalBlocks = + content && !hasStreamedTextContent + ? [...childBlocks, { type: 'text', content } as ContentBlock] + : childBlocks + + if (finalBlocks.length > 0) { + return { + ...block, + blocks: finalBlocks, + status: 'complete' as const, + } } } + } + if (!childBlocks?.length) { return block - }) + } + + return { + ...block, + blocks: updateSpawnAgentBlocks(childBlocks, toolCallId, results), + } +} + +const updateSpawnAgentBlocks = ( + blocks: ContentBlock[], + toolCallId: string, + results: any[], +): ContentBlock[] => { + return blocks + .map((block) => updateSpawnAgentBlock(block, toolCallId, results)) + .filter((block): block is ContentBlock => block !== null) } const handleSpawnAgentsResult = ( @@ -407,7 +462,8 @@ const handleToolResult = ( ) const firstOutput = event.output?.[0] - const firstOutputValue = firstOutput && 'value' in firstOutput ? firstOutput.value : undefined + const firstOutputValue = + firstOutput && 'value' in firstOutput ? firstOutput.value : undefined const isSpawnAgentsResult = Array.isArray(firstOutputValue) && firstOutputValue.some((v: any) => v?.agentName || v?.agentType) diff --git a/cli/src/utils/settings.ts b/cli/src/utils/settings.ts index 903a955009..5dc901e69d 100644 --- a/cli/src/utils/settings.ts +++ b/cli/src/utils/settings.ts @@ -1,6 +1,8 @@ import fs from 'fs' import path from 'path' +import { isFreebuffModelId } from '@codebuff/common/constants/freebuff-models' + import { getConfigDir } from './auth' import { AGENT_MODES } from './constants' import { logger } from './logger' @@ -12,12 +14,22 @@ const DEFAULT_SETTINGS: Settings = { adsEnabled: true, } +// Note: The old FREE mode has been renamed back to LITE; migrate on load. + /** * Settings schema - add new settings here as the product evolves */ export interface Settings { mode?: AgentMode adsEnabled?: boolean + /** Last model the user picked in the freebuff model selector. Restored on + * next freebuff launch so users land in the queue for their preferred + * model without re-picking. Persisted as the canonical model id. */ + freebuffModel?: string + /** @deprecated Use server-side fallbackToALaCarte setting instead */ + alwaysUseALaCarte?: boolean + /** @deprecated Use server-side fallbackToALaCarte setting instead */ + fallbackToALaCarte?: boolean } /** @@ -77,12 +89,12 @@ const validateSettings = (parsed: unknown): Settings => { const settings: Settings = {} const obj = parsed as Record - // Validate mode - if ( - typeof obj.mode === 'string' && - AGENT_MODES.includes(obj.mode as AgentMode) - ) { - settings.mode = obj.mode as AgentMode + // Validate mode; migrate the previously-saved 'FREE' value to 'LITE'. + if (typeof obj.mode === 'string') { + const normalized = obj.mode === 'FREE' ? 'LITE' : obj.mode + if (AGENT_MODES.includes(normalized as AgentMode)) { + settings.mode = normalized as AgentMode + } } // Validate adsEnabled @@ -90,6 +102,22 @@ const validateSettings = (parsed: unknown): Settings => { settings.adsEnabled = obj.adsEnabled } + // Validate freebuffModel — drop unknown ids so a removed model doesn't + // strand the user on a non-existent queue. + if (typeof obj.freebuffModel === 'string' && isFreebuffModelId(obj.freebuffModel)) { + settings.freebuffModel = obj.freebuffModel + } + + // Validate alwaysUseALaCarte (legacy) + if (typeof obj.alwaysUseALaCarte === 'boolean') { + settings.alwaysUseALaCarte = obj.alwaysUseALaCarte + } + + // Validate fallbackToALaCarte (legacy) + if (typeof obj.fallbackToALaCarte === 'boolean') { + settings.fallbackToALaCarte = obj.fallbackToALaCarte + } + return settings } @@ -132,3 +160,20 @@ export const loadModePreference = (): AgentMode => { export const saveModePreference = (mode: AgentMode): void => { saveSettings({ mode }) } + +/** + * Load the saved freebuff model preference. Returns undefined if none is + * saved yet — callers should fall back to DEFAULT_FREEBUFF_MODEL_ID. + */ +export const loadFreebuffModelPreference = (): string | undefined => { + return loadSettings().freebuffModel +} + +/** + * Save the freebuff model preference. Called whenever the user picks a model + * in the waiting room so the next launch defaults to it. + */ +export const saveFreebuffModelPreference = (model: string): void => { + saveSettings({ freebuffModel: model }) +} + diff --git a/cli/src/utils/skill-registry.ts b/cli/src/utils/skill-registry.ts new file mode 100644 index 0000000000..8cc8e8480e --- /dev/null +++ b/cli/src/utils/skill-registry.ts @@ -0,0 +1,94 @@ +import { loadSkills as sdkLoadSkills } from '@codebuff/sdk' + +import { getProjectRoot } from '../project-files' +import { logger } from './logger' + +import type { SkillDefinition, SkillsMap } from '@codebuff/common/types/skill' + +// ============================================================================ +// Skills cache (loaded via SDK at startup) +// ============================================================================ + +let skillsCache: SkillsMap = {} + +/** + * Initialize the skill registry by loading skills via the SDK. + * This must be called at CLI startup. + * + * Skills are loaded from: + * - ~/.agents/skills/ (global) + * - {projectRoot}/.agents/skills/ (project, overrides global) + */ +export async function initializeSkillRegistry(): Promise { + const cwd = getProjectRoot() || process.cwd() + + try { + // Load skills from both global (~/.agents/skills) and project directories + // The SDK handles merging, with project skills overriding global ones + skillsCache = await sdkLoadSkills({ + cwd, + verbose: false, + }) + } catch (error) { + logger.warn({ error }, 'Failed to load skills') + skillsCache = {} + } +} + +// ============================================================================ +// Skills access +// ============================================================================ + +/** + * Get all loaded skills. + */ +export function getLoadedSkills(): SkillsMap { + return skillsCache +} + +/** + * Get a skill by name. + */ +export function getSkillByName(name: string): SkillDefinition | undefined { + return skillsCache[name] +} + +/** + * Get the number of loaded skills. + */ +export function getSkillCount(): number { + return Object.keys(skillsCache).length +} + +// ============================================================================ +// UI/Display utilities +// ============================================================================ + +/** + * Get a message describing loaded skills for display. + */ +export function getLoadedSkillsMessage(): string | null { + const skills = Object.values(skillsCache) + + if (skills.length === 0) { + return null + } + + const header = `Loaded ${skills.length} skill${skills.length === 1 ? '' : 's'}` + const skillList = skills + .map((skill) => ` - ${skill.name}: ${skill.description.slice(0, 60)}${skill.description.length > 60 ? '...' : ''}`) + .join('\n') + + return `${header}\n${skillList}` +} + +// ============================================================================ +// Testing utilities +// ============================================================================ + +/** + * Clear cached skills. Intended for test scenarios. + */ +export function __resetSkillRegistryForTests(): void { + skillsCache = {} +} diff --git a/cli/src/utils/spawn-agent-matcher.ts b/cli/src/utils/spawn-agent-matcher.ts index c3eb5c0549..a87e493b1d 100644 --- a/cli/src/utils/spawn-agent-matcher.ts +++ b/cli/src/utils/spawn-agent-matcher.ts @@ -28,6 +28,7 @@ export const resolveSpawnAgentToReal = (options: { blocks: ContentBlock[] match: SpawnAgentMatch realAgentId: string + realAgentType?: string parentAgentId?: string params?: Record prompt?: string @@ -36,6 +37,7 @@ export const resolveSpawnAgentToReal = (options: { blocks, match, realAgentId, + realAgentType, parentAgentId, params: agentParams, prompt, @@ -48,5 +50,6 @@ export const resolveSpawnAgentToReal = (options: { parentAgentId, agentParams, prompt, + realAgentType, ) } diff --git a/cli/src/utils/stream-chunk-processor.ts b/cli/src/utils/stream-chunk-processor.ts index 1b106616e7..1d611e6ad1 100644 --- a/cli/src/utils/stream-chunk-processor.ts +++ b/cli/src/utils/stream-chunk-processor.ts @@ -5,13 +5,13 @@ import type { ContentBlock } from '../types/chat' export type ChunkDestination = | { type: 'root'; textType: 'text' | 'reasoning' } - | { type: 'agent'; agentId: string } + | { type: 'agent'; agentId: string; textType: 'text' | 'reasoning' } export const destinationFromTextEvent = ( event: { agentId?: string }, ): ChunkDestination => { if (event.agentId) { - return { type: 'agent', agentId: event.agentId } + return { type: 'agent', agentId: event.agentId, textType: 'text' } } return { type: 'root', textType: 'text' } } @@ -24,14 +24,14 @@ export const destinationFromChunkEvent = ( } if (event.type === 'subagent_chunk') { - return { type: 'agent', agentId: event.agentId } + return { type: 'agent', agentId: event.agentId, textType: 'text' } } if (event.type === 'reasoning_chunk') { if (event.ancestorRunIds.length === 0) { return { type: 'root', textType: 'reasoning' } } - return { type: 'agent', agentId: event.agentId } + return { type: 'agent', agentId: event.agentId, textType: 'reasoning' } } return null @@ -47,7 +47,7 @@ export const processTextChunk = ( } if (destination.type === 'agent') { - return appendTextToAgentBlock(blocks, destination.agentId, text) + return appendTextToAgentBlock(blocks, destination.agentId, text, destination.textType) } return appendTextToRootStream(blocks, { diff --git a/cli/src/utils/strings.ts b/cli/src/utils/strings.ts index 6e56f74db4..e761e5646c 100644 --- a/cli/src/utils/strings.ts +++ b/cli/src/utils/strings.ts @@ -1,13 +1,37 @@ import path from 'path' +/** Max number of lines to show in collapsed previews */ +export const MAX_COLLAPSED_LINES = 3 + +/** + * Truncate text to a maximum number of lines, adding '...' if truncated. + * Returns the input unchanged if it's null/undefined/empty. + */ +export function truncateToLines( + text: string | null | undefined, + maxLines: number, +): string | null | undefined { + if (!text) return text + const lines = text.split('\n') + if (lines.length <= maxLines) { + return text + } + 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' -import type { InputValue } from '../state/chat-store' + +import type { InputValue } from '../types/store' export function getSubsequenceIndices( str: string, @@ -37,7 +61,7 @@ export const BULLET_CHAR = '• ' // Threshold for treating pasted text as an attachment instead of inline insertion // Text longer than this value (not equal) becomes an attachment -export const LONG_TEXT_THRESHOLD = 1000 +export const LONG_TEXT_THRESHOLD = 2000 /** * Insert text at cursor position and return the new text and cursor position. @@ -63,9 +87,11 @@ export function createTextPasteHandler( text: string, cursorPosition: number, onChange: (value: InputValue) => void, -): (fallbackText?: string) => void { - return (fallbackText) => { - const pasteText = readClipboardText() ?? fallbackText +): (eventText?: string) => void { + return (eventText) => { + const rawPaste = eventText || readClipboardText() + if (!rawPaste) return + const pasteText = Bun.stripANSI(rawPaste) if (!pasteText) return const { newText, newCursor } = insertTextAtCursor( text, @@ -83,12 +109,12 @@ export function createTextPasteHandler( /** * Creates a paste handler that supports both image and text paste. * - * When fallbackText is provided (from drag-drop or native paste event), - * it takes FULL priority over the clipboard. This is because: + * When eventText is provided (from drag-drop or native paste event), + * it takes priority over the clipboard. This is because: * - Drag operations provide file paths directly without updating the clipboard * - The clipboard might contain stale data from a previous copy operation * - * Only when NO fallbackText is provided do we read from the clipboard. + * Only when NO eventText is provided do we read from the clipboard. */ export function createPasteHandler(options: { text: string @@ -96,28 +122,36 @@ 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 -}): (fallbackText?: string) => void { +}): (eventText?: string) => void { const { text, cursorPosition, onChange, onPasteImage, onPasteImagePath, + onPasteFilePath, onPasteLongText, cwd, } = options - return (fallbackText) => { + 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 (fallbackText && onPasteImagePath) { + if (eventText && onPasteImagePath) { // The terminal often only passes the filename when pasting a file copied from Finder. // Check if this looks like just a filename (no path separators) that's an image. const looksLikeImageFilename = - isImageFile(fallbackText) && - !fallbackText.includes('/') && - !fallbackText.includes('\\') + isImageFile(eventText) && + !eventText.includes('/') && + !eventText.includes('\\') if (looksLikeImageFilename) { // Try to get the full path from the clipboard's file URL @@ -125,7 +159,7 @@ export function createPasteHandler(options: { // Verify the clipboard path's basename matches exactly (not just endsWith) if ( clipboardFilePath && - path.basename(clipboardFilePath) === fallbackText + path.basename(clipboardFilePath) === eventText ) { // The clipboard has the full path to the same file - use it! onPasteImagePath(clipboardFilePath) @@ -133,9 +167,9 @@ export function createPasteHandler(options: { } } - // Check if fallbackText is a full path to an image file + // Check if eventText is a full path to an image file if (cwd) { - const imagePath = getImageFilePathFromText(fallbackText, cwd) + const imagePath = getImageFilePathFromText(eventText, cwd) if (imagePath) { onPasteImagePath(imagePath) return @@ -143,11 +177,20 @@ export function createPasteHandler(options: { } } - // fallbackText provided but not an image - check if it's long text - if (fallbackText) { + // 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 - if (onPasteLongText && fallbackText.length > LONG_TEXT_THRESHOLD) { - onPasteLongText(fallbackText) + if (onPasteLongText && eventText.length > LONG_TEXT_THRESHOLD) { + onPasteLongText(eventText) return } @@ -155,7 +198,7 @@ export function createPasteHandler(options: { const { newText, newCursor } = insertTextAtCursor( text, cursorPosition, - fallbackText, + eventText, ) onChange({ text: newText, @@ -167,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/cli/src/utils/subscription.ts b/cli/src/utils/subscription.ts new file mode 100644 index 0000000000..5bbdc5ae9f --- /dev/null +++ b/cli/src/utils/subscription.ts @@ -0,0 +1,31 @@ +import type { SubscriptionResponse } from '../hooks/use-subscription-query' + +/** + * Calculates the percentage of subscription block credits remaining. + * Returns null if the subscription data is incomplete. + */ +export function getBlockPercentRemaining( + subscriptionData: SubscriptionResponse | null | undefined, +): number | null { + if (!subscriptionData?.hasSubscription) return null + const rateLimit = subscriptionData.rateLimit + if (!rateLimit?.blockLimit || rateLimit.blockUsed == null) return null + return Math.round( + ((rateLimit.blockLimit - rateLimit.blockUsed) / rateLimit.blockLimit) * 100, + ) +} + +/** + * Determines if a request is covered by subscription based on subscription data. + * Returns true if the user has an active subscription that's not rate-limited + * and has remaining block credits. + */ +export function isCoveredBySubscription( + subscriptionData: SubscriptionResponse | null | undefined, +): boolean { + if (!subscriptionData?.hasSubscription) return false + const rateLimit = subscriptionData.rateLimit + if (rateLimit?.limited) return false + const blockPercentRemaining = getBlockPercentRemaining(subscriptionData) + return blockPercentRemaining != null && blockPercentRemaining > 0 +} diff --git a/cli/src/utils/terminal-color-detection.ts b/cli/src/utils/terminal-color-detection.ts index 4702377920..5a5091fff9 100644 --- a/cli/src/utils/terminal-color-detection.ts +++ b/cli/src/utils/terminal-color-detection.ts @@ -11,9 +11,10 @@ import { openSync, closeSync, writeSync, constants } from 'fs' -import type { CliEnv } from '../types/env' import { getCliEnv } from './env' +import type { CliEnv } from '../types/env' + // Timeout constants const OSC_QUERY_TIMEOUT_MS = 500 // Timeout for individual OSC query const GLOBAL_OSC_TIMEOUT_MS = 2000 // Global timeout for entire detection process diff --git a/cli/src/utils/terminal-enter-detection.ts b/cli/src/utils/terminal-enter-detection.ts new file mode 100644 index 0000000000..d2f7d0a7aa --- /dev/null +++ b/cli/src/utils/terminal-enter-detection.ts @@ -0,0 +1,17 @@ +/** + * Most terminals send \r for Enter and \n for Ctrl+J. A few niche Linux + * terminal emulators send \n for Enter instead, making the two + * indistinguishable. We detect this at runtime by tracking whether we've + * ever seen a \r ("return") key event. On macOS, Enter always sends \r. + */ + +let hasSeenReturnKey = process.platform === 'darwin' + +export function markReturnKeySeen(): void { + hasSeenReturnKey = true +} + +/** True when a "linefeed" (\n) key event should be treated as Enter. */ +export function isLinefeedActingAsEnter(): boolean { + return !hasSeenReturnKey +} diff --git a/cli/src/utils/terminal-images.ts b/cli/src/utils/terminal-images.ts index 8617d7b1cd..cb6dc37492 100644 --- a/cli/src/utils/terminal-images.ts +++ b/cli/src/utils/terminal-images.ts @@ -3,9 +3,10 @@ * Supports iTerm2 inline images protocol and Kitty graphics protocol */ -import type { CliEnv } from '../types/env' import { getCliEnv } from './env' +import type { CliEnv } from '../types/env' + export type TerminalImageProtocol = 'iterm2' | 'kitty' | 'sixel' | 'none' let cachedProtocol: TerminalImageProtocol | null = null diff --git a/cli/src/utils/terminal-title.ts b/cli/src/utils/terminal-title.ts new file mode 100644 index 0000000000..f77943f2e0 --- /dev/null +++ b/cli/src/utils/terminal-title.ts @@ -0,0 +1,110 @@ +/** + * Terminal title utilities using OSC (Operating System Command) escape sequences. + * + * OSC sequence format for setting title: + * - `\x1b]0;${title}\x07` - Sets both window title and icon name + * - `\x1b` is ESC, `]0;` starts the title command, `\x07` (BEL) ends it + * + * We write directly to /dev/tty to bypass OpenTUI's stdout capture, + * similar to how clipboard.ts handles OSC52 sequences. + */ + +import { closeSync, constants, openSync, writeSync } from 'fs' + +import { IS_FREEBUFF } from './constants' +import { getCliEnv } from './env' + +const MAX_TITLE_LENGTH = 60 +const TITLE_PREFIX = IS_FREEBUFF ? 'Freebuff: ' : 'Codebuff: ' +const OSC_TERMINATOR = '\x07' // BEL + +function isInTmux(env: ReturnType): boolean { + return Boolean(env.TMUX) +} + +function isInScreen(env: ReturnType): boolean { + if (env.STY) return true + const term = env.TERM ?? '' + return term.startsWith('screen') && !isInTmux(env) +} + +/** + * Build the OSC title sequence with tmux/screen passthrough if needed + */ +function buildTitleSequence(title: string, env: ReturnType): string { + const osc = `\x1b]0;${title}${OSC_TERMINATOR}` + + // tmux passthrough: wrap in DCS and double ESC characters + if (isInTmux(env)) { + const escaped = osc.replace(/\x1b/g, '\x1b\x1b') + return `\x1bPtmux;${escaped}\x1b\\` + } + + // GNU screen passthrough: wrap in DCS + if (isInScreen(env)) { + return `\x1bP${osc}\x1b\\` + } + + return osc +} + +/** + * Write an escape sequence directly to the controlling terminal. + * This bypasses OpenTUI's stdout capture by writing to /dev/tty directly. + */ +function writeToTty(sequence: string): boolean { + const ttyPath = process.platform === 'win32' ? 'CON' : '/dev/tty' + + let fd: number | null = null + try { + fd = openSync(ttyPath, constants.O_WRONLY) + writeSync(fd, sequence) + return true + } catch { + return false + } finally { + if (fd !== null) { + try { + closeSync(fd) + } catch { + // Ignore close errors + } + } + } +} + +/** + * Set the terminal window title. + * Works on most modern terminal emulators, including through tmux and screen. + * + * @param title - The title to set (will be truncated if too long) + */ +export function setTerminalTitle(title: string): void { + // Sanitize: remove control characters and newlines + const sanitized = title.replace(/[\x00-\x1f\x7f]/g, ' ').trim() + if (!sanitized) return + + // Truncate to reasonable length + const maxInputLength = MAX_TITLE_LENGTH - TITLE_PREFIX.length + const truncated = + sanitized.length > maxInputLength + ? sanitized.slice(0, maxInputLength - 1) + '…' + : sanitized + + const fullTitle = `${TITLE_PREFIX}${truncated}` + const env = getCliEnv() + const sequence = buildTitleSequence(fullTitle, env) + + writeToTty(sequence) +} + +/** + * Reset the terminal title to the default. + * Call this when the CLI exits to restore the terminal to a clean state. + */ +export function resetTerminalTitle(): void { + // Empty title resets to terminal's default behavior + const env = getCliEnv() + const sequence = buildTitleSequence('', env) + writeToTty(sequence) +} diff --git a/cli/src/utils/theme-system.ts b/cli/src/utils/theme-system.ts index 01090b5990..79bd92d3dd 100644 --- a/cli/src/utils/theme-system.ts +++ b/cli/src/utils/theme-system.ts @@ -2,9 +2,10 @@ import { existsSync, readFileSync, readdirSync, statSync, watch } from 'fs' import { homedir } from 'os' import { dirname, join } from 'path' -import type { CliEnv } from '../types/env' import { getCliEnv } from './env' + import type { MarkdownPalette } from './markdown-renderer' +import type { CliEnv } from '../types/env' import type { ChatTheme, MarkdownHeadingLevel, @@ -148,16 +149,6 @@ const IDE_THEME_INFERENCE = { ], } as const -const VS_CODE_FAMILY_ENV_KEYS = [ - 'VSCODE_PID', - 'VSCODE_CWD', - 'VSCODE_IPC_HOOK_CLI', - 'VSCODE_LOG_NATIVE', - 'VSCODE_NLS_CONFIG', - 'CURSOR_SESSION_ID', - 'CURSOR', -] as const - const VS_CODE_PRODUCT_DIRS = [ 'Code', 'Code - Insiders', @@ -167,15 +158,6 @@ const VS_CODE_PRODUCT_DIRS = [ 'Cursor', ] as const -const JETBRAINS_ENV_KEYS = [ - 'JB_PRODUCT_CODE', - 'JB_SYSTEM_PATH', - 'JB_INSTALLATION_HOME', - 'IDEA_INITIAL_DIRECTORY', - 'IDE_CONFIG_DIR', - 'JB_IDE_CONFIG_DIR', -] as const - const normalizeThemeName = (themeName: string): string => themeName.trim().toLowerCase() diff --git a/cli/src/utils/time-format.ts b/cli/src/utils/time-format.ts index af178fde8c..e7b4723602 100644 --- a/cli/src/utils/time-format.ts +++ b/cli/src/utils/time-format.ts @@ -1,20 +1,21 @@ +import { formatTimeUntil } from '@codebuff/common/util/dates' + /** - * Format time until reset in human-readable form + * Format time until reset in human-readable form. * @param resetDate - The date when the quota/resource resets * @returns Human-readable string like "2h 30m" or "45m" */ export const formatResetTime = (resetDate: Date | null): string => { if (!resetDate) return '' - const now = new Date() - const diffMs = resetDate.getTime() - now.getTime() - if (diffMs <= 0) return 'now' - - const diffMins = Math.floor(diffMs / (1000 * 60)) - const diffHours = Math.floor(diffMins / 60) - const remainingMins = diffMins % 60 + return formatTimeUntil(resetDate, { fallback: 'now' }) +} - if (diffHours > 0) { - return `${diffHours}h ${remainingMins}m` - } - return `${diffMins}m` +/** + * Format time until reset in human-readable form, including days. + * @param resetDate - The date when the quota/resource resets + * @returns Human-readable string like "4d 7h" or "2h 30m" + */ +export const formatResetTimeLong = (resetDate: Date | string | null): string => { + if (!resetDate) return '' + return formatTimeUntil(resetDate, { fallback: 'now' }) } diff --git a/cli/src/utils/yield-to-event-loop.ts b/cli/src/utils/yield-to-event-loop.ts new file mode 100644 index 0000000000..8b13f4d460 --- /dev/null +++ b/cli/src/utils/yield-to-event-loop.ts @@ -0,0 +1,9 @@ +/** + * Yield to the event loop so pending React state updates and microtasks can flush + * before continuing. Useful after enqueuing UI changes that should render + * before the next step of an async flow. + */ +export const yieldToEventLoop = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, 0) + }) diff --git a/cli/tsconfig.json b/cli/tsconfig.json index d4b7a92834..127c0f0f1c 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -12,6 +12,7 @@ "esModuleInterop": true, "skipLibCheck": true, "preserveSymlinks": false, + "baseUrl": ".", "paths": { "@codebuff/sdk": ["../sdk/src/index.ts"] } diff --git a/common/package.json b/common/package.json index 90767118aa..723dbe2954 100644 --- a/common/package.json +++ b/common/package.json @@ -18,7 +18,7 @@ }, "sideEffects": false, "engines": { - "bun": "^1.3.5" + "bun": "1.3.11" }, "dependencies": { "@auth/drizzle-adapter": "^1.8.0", @@ -26,9 +26,9 @@ "@types/pg": "^8.11.10", "@types/readable-stream": "^4.0.18", "@types/seedrandom": "^3.0.8", - "ai": "^5.0.0", + "ai": "^5.0.52", "ignore": "5.3.2", - "lodash": "4.17.21", + "lodash": "4.17.23", "next-auth": "^4.24.11", "partial-json": "^0.1.7", "pg": "^8.14.1", diff --git a/common/src/__tests__/agent-validation.test.ts b/common/src/__tests__/agent-validation.test.ts index 99c794de67..6700855ddb 100644 --- a/common/src/__tests__/agent-validation.test.ts +++ b/common/src/__tests__/agent-validation.test.ts @@ -164,7 +164,7 @@ describe('Agent Validation', () => { instructionsPrompt: 'Test user prompt', stepPrompt: 'Test step prompt', inputSchema: { - prompt: {} as any, // invalid prompt schema + prompt: {} as Record, // invalid prompt schema }, outputMode: 'last_message', includeMessageHistory: true, @@ -515,7 +515,7 @@ describe('Agent Validation', () => { instructionsPrompt: 'Test user prompt', stepPrompt: 'Test step prompt', inputSchema: { - prompt: 10 as any, // Invalid - number schema + prompt: 10 as unknown as Record, // Invalid - number schema }, outputMode: 'last_message', includeMessageHistory: true, diff --git a/common/src/__tests__/env-process.test.ts b/common/src/__tests__/env-process.test.ts index 50ce6a8648..13c409aa50 100644 --- a/common/src/__tests__/env-process.test.ts +++ b/common/src/__tests__/env-process.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { describe, test, expect, afterEach } from 'bun:test' import { getProcessEnv, processEnv } from '../env-process' import { createTestProcessEnv } from '../testing-env-process' diff --git a/common/src/__tests__/free-agents.test.ts b/common/src/__tests__/free-agents.test.ts new file mode 100644 index 0000000000..d45d612b70 --- /dev/null +++ b/common/src/__tests__/free-agents.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from 'bun:test' + +import { + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_GEMINI_PRO_MODEL_ID, + FREEBUFF_KIMI_MODEL_ID, + FREEBUFF_MINIMAX_MODEL_ID, +} from '../constants/freebuff-models' +import { FREEBUFF_GEMINI_THINKER_AGENT_ID } from '../constants/freebuff-gemini-thinker' +import { + getFreebuffRootAgentIdForModel, + isFreebuffGeminiThinkerAgent, + isFreeModeAllowedAgentModel, + shouldUseLocalTokenCountForFreebuffDeepseekFlash, +} from '../constants/free-agents' + +describe('free mode agent model allowlist', () => { + test('maps selectable freebuff models to concrete root agents', () => { + expect(getFreebuffRootAgentIdForModel(FREEBUFF_MINIMAX_MODEL_ID)).toBe( + 'base2-free', + ) + expect(getFreebuffRootAgentIdForModel(FREEBUFF_KIMI_MODEL_ID)).toBe( + 'base2-free-kimi', + ) + expect( + getFreebuffRootAgentIdForModel(FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID), + ).toBe('base2-free-deepseek') + expect( + getFreebuffRootAgentIdForModel(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID), + ).toBe('base2-free-deepseek-flash') + }) + + test('allows each freebuff root agent only with its configured model', () => { + expect( + isFreeModeAllowedAgentModel('base2-free', FREEBUFF_MINIMAX_MODEL_ID), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'base2-free', + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + ), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel('base2-free', FREEBUFF_KIMI_MODEL_ID), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel('base2-free-kimi', FREEBUFF_KIMI_MODEL_ID), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'base2-free-deepseek', + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + ), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'base2-free-deepseek-flash', + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ), + ).toBe(true) + }) + + test('allows each freebuff reviewer agent only with its configured model', () => { + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-minimax', + FREEBUFF_MINIMAX_MODEL_ID, + ), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-minimax', + FREEBUFF_KIMI_MODEL_ID, + ), + ).toBe(false) + expect( + isFreeModeAllowedAgentModel('code-reviewer-kimi', FREEBUFF_KIMI_MODEL_ID), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-deepseek', + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + ), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-deepseek-flash', + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ), + ).toBe(true) + }) + + test('allows legacy code-reviewer-lite with freebuff reviewer models', () => { + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-lite', + FREEBUFF_MINIMAX_MODEL_ID, + ), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel('code-reviewer-lite', FREEBUFF_KIMI_MODEL_ID), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-lite', + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + ), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'code-reviewer-lite', + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ), + ).toBe(true) + }) + + test('allows the browser-use subagent with its bundled model', () => { + expect( + isFreeModeAllowedAgentModel( + 'browser-use', + 'google/gemini-3.1-flash-lite-preview', + ), + ).toBe(true) + }) + + test('allows the tmux-cli subagent with its bundled model', () => { + expect( + isFreeModeAllowedAgentModel('tmux-cli', FREEBUFF_MINIMAX_MODEL_ID), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'codebuff/tmux-cli@0.0.1', + FREEBUFF_MINIMAX_MODEL_ID, + ), + ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'other/tmux-cli@0.0.1', + FREEBUFF_MINIMAX_MODEL_ID, + ), + ).toBe(false) + }) + + test('allows Gemini Pro for the thinker subagent but not the freebuff root', () => { + expect( + isFreeModeAllowedAgentModel('base2-free', FREEBUFF_GEMINI_PRO_MODEL_ID), + ).toBe(false) + expect( + isFreeModeAllowedAgentModel( + FREEBUFF_GEMINI_THINKER_AGENT_ID, + FREEBUFF_GEMINI_PRO_MODEL_ID, + ), + ).toBe(true) + }) + + test('recognizes the Gemini thinker agent in free mode', () => { + expect(isFreebuffGeminiThinkerAgent(FREEBUFF_GEMINI_THINKER_AGENT_ID)).toBe( + true, + ) + expect( + isFreebuffGeminiThinkerAgent( + `codebuff/${FREEBUFF_GEMINI_THINKER_AGENT_ID}@0.0.1`, + ), + ).toBe(true) + expect( + isFreebuffGeminiThinkerAgent( + `other/${FREEBUFF_GEMINI_THINKER_AGENT_ID}@0.0.1`, + ), + ).toBe(false) + }) + + test('uses local token count only for the DeepSeek Flash freebuff root', () => { + expect( + shouldUseLocalTokenCountForFreebuffDeepseekFlash({ + agentId: 'base2-free-deepseek-flash', + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + }), + ).toBe(true) + expect( + shouldUseLocalTokenCountForFreebuffDeepseekFlash({ + agentId: 'codebuff/base2-free-deepseek-flash@0.0.1', + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + }), + ).toBe(true) + expect( + shouldUseLocalTokenCountForFreebuffDeepseekFlash({ + agentId: 'base2-free-deepseek', + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + }), + ).toBe(false) + expect( + shouldUseLocalTokenCountForFreebuffDeepseekFlash({ + agentId: 'base2-free-deepseek-flash', + model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + }), + ).toBe(false) + expect( + shouldUseLocalTokenCountForFreebuffDeepseekFlash({ + agentId: 'other/base2-free-deepseek-flash@0.0.1', + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + }), + ).toBe(false) + }) +}) diff --git a/common/src/__tests__/freebuff-models.test.ts b/common/src/__tests__/freebuff-models.test.ts new file mode 100644 index 0000000000..ee39ed975b --- /dev/null +++ b/common/src/__tests__/freebuff-models.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from 'bun:test' + +import { + canFreebuffModelSpawnGeminiThinker, + DEFAULT_FREEBUFF_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_GLM_MODEL_ID, + FREEBUFF_KIMI_MODEL_ID, + LIMITED_FREEBUFF_MODEL_ID, + FREEBUFF_MINIMAX_MODEL_ID, + FREEBUFF_MODELS, + SUPPORTED_FREEBUFF_MODELS, + getFreebuffDeploymentAvailabilityLabel, + getFreebuffModelsForAccessTier, + isFreebuffDeploymentHours, + isFreebuffModelId, + isFreebuffModelAllowedForAccessTier, + isFreebuffPremiumModelId, + isSupportedFreebuffModelId, + resolveFreebuffModelForAccessTier, +} from '../constants/freebuff-models' + +describe('freebuff model availability', () => { + test('defaults to MiniMax M2.7 for base2-free', () => { + expect(DEFAULT_FREEBUFF_MODEL_ID).toBe(FREEBUFF_MINIMAX_MODEL_ID) + }) + + test('DeepSeek Pro carries the data-collection warning so users see it before picking', () => { + const deepseek = FREEBUFF_MODELS.find( + (m) => m.id === FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + ) + expect(deepseek?.warning).toBe('Collects data for training') + }) + + test('DeepSeek Flash carries the data-collection warning so users see it before picking', () => { + const deepseek = FREEBUFF_MODELS.find( + (m) => m.id === FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ) + expect(deepseek?.warning).toBe('Collects data for training') + }) + + test('DeepSeek V4 Flash is selectable and unlimited', () => { + expect(FREEBUFF_MODELS.map((model) => model.id)).toContain( + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ) + expect(isFreebuffModelId(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID)).toBe(true) + expect(isFreebuffPremiumModelId(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID)).toBe( + false, + ) + }) + + test('limited access exposes only DeepSeek V4 Flash', () => { + expect(LIMITED_FREEBUFF_MODEL_ID).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) + expect(getFreebuffModelsForAccessTier('limited').map((m) => m.id)).toEqual([ + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ]) + expect( + isFreebuffModelAllowedForAccessTier( + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + 'limited', + ), + ).toBe(true) + expect( + isFreebuffModelAllowedForAccessTier(FREEBUFF_MINIMAX_MODEL_ID, 'limited'), + ).toBe(false) + expect( + resolveFreebuffModelForAccessTier(FREEBUFF_MINIMAX_MODEL_ID, 'limited'), + ).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) + }) + + test('only smart freebuff models can spawn the gemini-thinker subagent', () => { + expect(canFreebuffModelSpawnGeminiThinker(FREEBUFF_KIMI_MODEL_ID)).toBe( + true, + ) + expect( + canFreebuffModelSpawnGeminiThinker(FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID), + ).toBe(true) + expect(canFreebuffModelSpawnGeminiThinker(FREEBUFF_MINIMAX_MODEL_ID)).toBe( + false, + ) + expect( + canFreebuffModelSpawnGeminiThinker(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID), + ).toBe(false) + }) + + test('supports GLM 5.1 as a legacy server-side model without selecting it for new clients', () => { + expect(FREEBUFF_MODELS.map((model) => model.id)).not.toContain( + FREEBUFF_GLM_MODEL_ID, + ) + expect(SUPPORTED_FREEBUFF_MODELS.map((model) => model.id)).toContain( + FREEBUFF_GLM_MODEL_ID, + ) + expect(isFreebuffModelId(FREEBUFF_GLM_MODEL_ID)).toBe(false) + expect(isSupportedFreebuffModelId(FREEBUFF_GLM_MODEL_ID)).toBe(true) + }) + + test('formats the close time in the user local timezone while deployment is open', () => { + expect( + getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-05T18:00:00Z'), { + locale: 'en-US', + timeZone: 'America/Los_Angeles', + }), + ).toBe('until 5:00 PM') + }) + + test('formats the next open time in the user local timezone while deployment is closed', () => { + expect( + getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-05T12:00:00Z'), { + locale: 'en-US', + timeZone: 'America/Los_Angeles', + }), + ).toBe('opens 6:00 AM') + }) + + test('includes the weekday when the next opening is on a later local day', () => { + expect( + getFreebuffDeploymentAvailabilityLabel(new Date('2026-01-11T03:00:00Z'), { + locale: 'en-US', + timeZone: 'America/Los_Angeles', + }), + ).toBe('opens Sun 6:00 AM') + }) + + test('tracks deployment hours correctly across the open and close boundaries', () => { + expect(isFreebuffDeploymentHours(new Date('2026-01-05T13:59:00Z'))).toBe( + false, + ) + expect(isFreebuffDeploymentHours(new Date('2026-01-05T14:00:00Z'))).toBe( + true, + ) + expect(isFreebuffDeploymentHours(new Date('2026-01-06T00:59:00Z'))).toBe( + true, + ) + expect(isFreebuffDeploymentHours(new Date('2026-01-06T01:00:00Z'))).toBe( + false, + ) + expect(isFreebuffDeploymentHours(new Date('2026-01-10T20:00:00Z'))).toBe( + true, + ) + }) +}) diff --git a/common/src/__tests__/handlesteps-parsing.test.ts b/common/src/__tests__/handlesteps-parsing.test.ts index e73896e3be..1edd4160af 100644 --- a/common/src/__tests__/handlesteps-parsing.test.ts +++ b/common/src/__tests__/handlesteps-parsing.test.ts @@ -43,6 +43,7 @@ describe('handleSteps Parsing Tests', () => { arch: 'test', homedir: '/test', cpus: 1, + chromeAvailable: false, }, tokenCallers: {}, } diff --git a/common/src/actions.ts b/common/src/actions.ts index 7644b2020d..eb5304fba9 100644 --- a/common/src/actions.ts +++ b/common/src/actions.ts @@ -1,6 +1,5 @@ import { z } from 'zod/v4' -import type { CostMode } from './old-constants' import type { GrantType } from './types/grant' import type { MCPConfig } from './types/mcp' import type { ToolMessage } from './types/messages/codebuff-message' @@ -30,7 +29,7 @@ type ClientActionPrompt = { promptParams?: Record // Additional json params. fingerprintId: string authToken?: string - costMode?: CostMode + costMode?: string sessionState: SessionState toolResults: ToolMessage[] model?: string @@ -70,7 +69,7 @@ type ClientActionMcpToolData = { tools: { name: string description?: string - inputSchema: { type: 'object'; [k: string]: unknown } + inputSchema: { type: 'object';[k: string]: unknown } }[] } diff --git a/common/src/analytics.ts b/common/src/analytics.ts index 75eec081a5..ea88cf7e59 100644 --- a/common/src/analytics.ts +++ b/common/src/analytics.ts @@ -1,7 +1,10 @@ +import { env, DEBUG_ANALYTICS } from '@codebuff/common/env' + import { createPostHogClient, type AnalyticsClient } from './analytics-core' import { AnalyticsEvent } from './constants/analytics-events' + +import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { Logger } from '@codebuff/common/types/contracts/logger' -import { env, DEBUG_ANALYTICS } from '@codebuff/common/env' let client: AnalyticsClient | undefined @@ -30,6 +33,18 @@ export async function flushAnalytics(logger?: Logger) { } } +export function withDefaultProperties( + trackEventFn: TrackEventFn, + defaultProperties: Record, +): TrackEventFn { + return (params) => { + trackEventFn({ + ...params, + properties: { ...defaultProperties, ...params.properties }, + }) + } +} + export function trackEvent({ event, userId, diff --git a/common/src/browser-actions.ts b/common/src/browser-actions.ts index 2a6ed28382..f195a62cd6 100644 --- a/common/src/browser-actions.ts +++ b/common/src/browser-actions.ts @@ -193,7 +193,7 @@ export const BrowserNavigateActionSchema = OptionalNavigateConfigSchema, ) -const RangeSchema = z.object({ +const _RangeSchema = z.object({ min: z.number(), max: z.number(), }) diff --git a/common/src/constants/agents.ts b/common/src/constants/agents.ts index 01b92e37d4..5737b77614 100644 --- a/common/src/constants/agents.ts +++ b/common/src/constants/agents.ts @@ -92,4 +92,4 @@ export const AGENT_NAME_TO_TYPES = Object.entries(AGENT_NAMES).reduce( {} as Record, ) -export const MAX_AGENT_STEPS_DEFAULT = 100 +export const MAX_AGENT_STEPS_DEFAULT = 200 diff --git a/common/src/constants/analytics-events.ts b/common/src/constants/analytics-events.ts index c7b71d4419..b380cc1211 100644 --- a/common/src/constants/analytics-events.ts +++ b/common/src/constants/analytics-events.ts @@ -4,6 +4,7 @@ export enum AnalyticsEvent { // CLI APP_LAUNCHED = 'cli.app_launched', + FINGERPRINT_GENERATED = 'cli.fingerprint_generated', CHANGE_DIRECTORY = 'cli.change_directory', INVALID_COMMAND = 'cli.invalid_command', KNOWLEDGE_FILE_UPDATED = 'cli.knowledge_file_updated', @@ -13,7 +14,6 @@ export enum AnalyticsEvent { TERMINAL_COMMAND_COMPLETED = 'cli.terminal_command_completed', USER_INPUT_COMPLETE = 'cli.user_input_complete', UPDATE_CODEBUFF_FAILED = 'cli.update_codebuff_failed', - FEEDBACK_SUBMITTED = 'cli.feedback_submitted', FEEDBACK_BUTTON_HOVERED = 'cli.feedback_button_hovered', FOLLOWUP_CLICKED = 'cli.followup_clicked', @@ -26,12 +26,25 @@ export enum AnalyticsEvent { UNKNOWN_TOOL_CALL = 'backend.unknown_tool_call', USER_INPUT = 'backend.user_input', + // Backend - Database Operations + ADVISORY_LOCK_CONTENTION = 'backend.advisory_lock_contention', + TRANSACTION_RETRY_THRESHOLD_EXCEEDED = 'backend.transaction_retry_threshold_exceeded', + + // Backend - Subscription + SUBSCRIPTION_CREATED = 'backend.subscription_created', + SUBSCRIPTION_CANCELED = 'backend.subscription_canceled', + SUBSCRIPTION_PAYMENT_FAILED = 'backend.subscription_payment_failed', + SUBSCRIPTION_BLOCK_CREATED = 'backend.subscription_block_created', + SUBSCRIPTION_BLOCK_LIMIT_HIT = 'backend.subscription_block_limit_hit', + SUBSCRIPTION_WEEKLY_LIMIT_HIT = 'backend.subscription_weekly_limit_hit', + SUBSCRIPTION_CREDITS_MIGRATED = 'backend.subscription_credits_migrated', + SUBSCRIPTION_TIER_CHANGED = 'backend.subscription_tier_changed', + // Web SIGNUP = 'web.signup', // Web - Authentication AUTH_LOGIN_STARTED = 'auth.login_started', - AUTH_REFERRAL_GITHUB_LOGIN_STARTED = 'auth.referral_github_login_started', AUTH_LOGOUT_COMPLETED = 'auth.logout_completed', // Web - Cookie Consent @@ -49,6 +62,9 @@ export enum AnalyticsEvent { ONBOARD_PAGE_RUN_COMMAND_COPIED = 'onboard_page.run_command_copied', ONBOARD_PAGE_INSTALL_COMMAND_COPIED = 'onboard_page.install_command_copied', + // Web - Creator Attribution + CODEBUFF_REFERRER_ATTRIBUTED = 'codebuff.referrer_attributed', + // Web - Install Dialog INSTALL_DIALOG_CD_COMMAND_COPIED = 'install_dialog.cd_command_copied', INSTALL_DIALOG_RUN_COMMAND_COPIED = 'install_dialog.run_command_copied', @@ -73,7 +89,6 @@ export enum AnalyticsEvent { // Web - UI Components TOAST_SHOWN = 'toast.shown', - REFERRAL_BANNER_CLICKED = 'referral_banner.clicked', // Web - API AGENT_RUN_API_REQUEST = 'api.agent_run_request', @@ -109,8 +124,19 @@ export enum AnalyticsEvent { DOCS_SEARCH_INSUFFICIENT_CREDITS = 'api.docs_search_insufficient_credits', DOCS_SEARCH_ERROR = 'api.docs_search_error', + GRAVITY_INDEX_REQUEST = 'api.gravity_index_request', + GRAVITY_INDEX_AUTH_ERROR = 'api.gravity_index_auth_error', + GRAVITY_INDEX_VALIDATION_ERROR = 'api.gravity_index_validation_error', + GRAVITY_INDEX_ERROR = 'api.gravity_index_error', + + // Web - Feedback API + FEEDBACK_SUBMITTED = 'api.feedback_submitted', + FEEDBACK_AUTH_ERROR = 'api.feedback_auth_error', + FEEDBACK_VALIDATION_ERROR = 'api.feedback_validation_error', + // Web - Ads API ADS_API_AUTH_ERROR = 'api.ads_auth_error', + ADS_CLICKED = 'ads.clicked', // Web - Token Count API TOKEN_COUNT_REQUEST = 'api.token_count_request', @@ -118,11 +144,28 @@ export enum AnalyticsEvent { TOKEN_COUNT_VALIDATION_ERROR = 'api.token_count_validation_error', TOKEN_COUNT_ERROR = 'api.token_count_error', - // Claude OAuth - CLAUDE_OAUTH_REQUEST = 'sdk.claude_oauth_request', - CLAUDE_OAUTH_RATE_LIMITED = 'sdk.claude_oauth_rate_limited', - CLAUDE_OAUTH_AUTH_ERROR = 'sdk.claude_oauth_auth_error', + // ChatGPT OAuth + CHATGPT_OAUTH_REQUEST = 'sdk.chatgpt_oauth_request', + CHATGPT_OAUTH_RATE_LIMITED = 'sdk.chatgpt_oauth_rate_limited', + CHATGPT_OAUTH_AUTH_ERROR = 'sdk.chatgpt_oauth_auth_error', + + // Freebuff - Creator Attribution + FREEBUFF_REFERRER_ATTRIBUTED = 'freebuff.referrer_attributed', + + // Freebuff - Get Started Page + FREEBUFF_GET_STARTED_VIEWED = 'freebuff.get_started_viewed', + FREEBUFF_GET_STARTED_HELP_EXPANDED = 'freebuff.get_started_help_expanded', + FREEBUFF_GET_STARTED_EDITOR_CLICKED = 'freebuff.get_started_editor_clicked', + + // Freebuff - Home Page + FREEBUFF_HOME_INSTALL_COMMAND_COPIED = 'freebuff.home_install_command_copied', + FREEBUFF_HOME_GITHUB_CLICKED = 'freebuff.home_github_clicked', + FREEBUFF_HOME_INSTALL_GUIDE_EXPANDED = 'freebuff.home_install_guide_expanded', + FREEBUFF_HOME_FAQ_OPENED = 'freebuff.home_faq_opened', // Common FLUSH_FAILED = 'common.flush_failed', + + // Client Logging - for sending logger events to PostHog in production + CLI_LOG = 'cli.log', } diff --git a/common/src/constants/anthropic.ts b/common/src/constants/anthropic.ts new file mode 100644 index 0000000000..8ad7deb6bb --- /dev/null +++ b/common/src/constants/anthropic.ts @@ -0,0 +1,68 @@ +/** + * OpenRouter → Anthropic model ID mapping. Used by the token-count API to + * route Anthropic-family requests to Anthropic's native counting endpoint. + */ + +const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { + // Claude 3.x Haiku models + 'anthropic/claude-3.5-haiku-20241022': 'claude-3-5-haiku-20241022', + 'anthropic/claude-3.5-haiku': 'claude-3-5-haiku-20241022', + 'anthropic/claude-3-5-haiku': 'claude-3-5-haiku-20241022', + 'anthropic/claude-3-5-haiku-20241022': 'claude-3-5-haiku-20241022', + 'anthropic/claude-3-haiku': 'claude-3-haiku-20240307', + + // Claude 3.x Sonnet models + 'anthropic/claude-3.5-sonnet': 'claude-3-5-sonnet-20241022', + 'anthropic/claude-3-5-sonnet': 'claude-3-5-sonnet-20241022', + 'anthropic/claude-3-5-sonnet-20241022': 'claude-3-5-sonnet-20241022', + 'anthropic/claude-3-5-sonnet-20240620': 'claude-3-5-sonnet-20240620', + 'anthropic/claude-3-sonnet': 'claude-3-sonnet-20240229', + + // Claude 3.x Opus models + 'anthropic/claude-3-opus': 'claude-3-opus-20240229', + 'anthropic/claude-3-opus-20240229': 'claude-3-opus-20240229', + + // Claude 4.x Haiku models + 'anthropic/claude-haiku-4.5': 'claude-haiku-4-5-20251001', + 'anthropic/claude-haiku-4': 'claude-haiku-4-20250514', + + // Claude 4.x Sonnet models + 'anthropic/claude-sonnet-4.6': 'claude-sonnet-4-6', + 'anthropic/claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', + 'anthropic/claude-sonnet-4': 'claude-sonnet-4-20250514', + 'anthropic/claude-4-sonnet-20250522': 'claude-sonnet-4-20250514', + 'anthropic/claude-4-sonnet': 'claude-sonnet-4-20250514', + + // Claude 4.x Opus models + 'anthropic/claude-opus-4.7': 'claude-opus-4-7', + 'anthropic/claude-opus-4.6': 'claude-opus-4-6', + 'anthropic/claude-opus-4.5': 'claude-opus-4-5-20251101', + 'anthropic/claude-opus-4.1': 'claude-opus-4-1-20250805', + 'anthropic/claude-opus-4': 'claude-opus-4-1-20250805', +} + +export function isClaudeModel(model: string): boolean { + return model.startsWith('anthropic/') || model.startsWith('claude-') +} + +/** + * Convert an OpenRouter model ID to an Anthropic model ID. + * Throws if the model has a non-anthropic provider prefix. + */ +export function toAnthropicModelId(openrouterModel: string): string { + // Already an Anthropic model ID (no provider prefix) + if (!openrouterModel.includes('/')) { + return openrouterModel + } + + if (!openrouterModel.startsWith('anthropic/')) { + throw new Error( + `Cannot convert non-Anthropic model to Anthropic model ID: ${openrouterModel}`, + ) + } + + return ( + OPENROUTER_TO_ANTHROPIC_MODEL_MAP[openrouterModel] ?? + openrouterModel.replace('anthropic/', '') + ) +} diff --git a/common/src/constants/chatgpt-oauth.ts b/common/src/constants/chatgpt-oauth.ts new file mode 100644 index 0000000000..ded5ba48e0 --- /dev/null +++ b/common/src/constants/chatgpt-oauth.ts @@ -0,0 +1,82 @@ +/** + * ChatGPT subscription OAuth constants for experimental direct OpenAI routing. + */ + +/** + * Feature flag for ChatGPT OAuth (connect:chatgpt) functionality. + * Default OFF until validated. + */ +export const CHATGPT_OAUTH_ENABLED = true + +/** OAuth client id used by Codex-compatible OAuth ecosystems. */ +export const CHATGPT_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' + +/** OAuth endpoints */ +export const CHATGPT_OAUTH_AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize' +export const CHATGPT_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token' + +/** Pinned redirect URI for paste-based localhost callback flow. */ +export const CHATGPT_OAUTH_REDIRECT_URI = 'http://localhost:1455/auth/callback' + +/** Base URL for ChatGPT backend API (Codex endpoint). */ +export const CHATGPT_BACKEND_BASE_URL = 'https://chatgpt.com/backend-api' + +/** Environment variable for OAuth token override. */ +export const CHATGPT_OAUTH_TOKEN_ENV_VAR = 'CODEBUFF_CHATGPT_OAUTH_TOKEN' + +/** + * OpenRouter-style model IDs that are allowed for ChatGPT OAuth direct routing. + * This includes optimistic aliases requested by the user. + */ +export const OPENROUTER_TO_OPENAI_MODEL_MAP: Record = { + 'openai/gpt-5.4': 'gpt-5.4', + 'openai/gpt-5.4-codex': 'gpt-5.4-codex', + 'openai/gpt-5.3': 'gpt-5.3', + 'openai/gpt-5.3-codex': 'gpt-5.3-codex', + 'openai/gpt-5.2': 'gpt-5.2', + 'openai/gpt-5.2-codex': 'gpt-5.2-codex', + + // Nearby/optimistic aliases supported in current model config. + 'openai/gpt-5.1': 'gpt-5.1', + 'openai/gpt-5.1-chat': 'gpt-5.1-chat', + 'openai/gpt-4o-2024-11-20': 'gpt-4o-2024-11-20', + 'openai/gpt-4o-mini-2024-07-18': 'gpt-4o-mini-2024-07-18', +} + +export const CHATGPT_OAUTH_OPENAI_MODEL_ALLOWLIST = Object.keys( + OPENROUTER_TO_OPENAI_MODEL_MAP, +) as Array + +export function isOpenAIProviderModel(model: string): boolean { + return model.startsWith('openai/') +} + +/** + * Check if model is in the explicit ChatGPT OAuth allowlist. + */ +export function isChatGptOAuthModelAllowed(model: string): boolean { + return model in OPENROUTER_TO_OPENAI_MODEL_MAP +} + +/** + * Normalize OpenRouter-style model IDs to direct OpenAI model IDs. + * Example: "openai/gpt-5.3-codex" => "gpt-5.3-codex" + */ +export function toOpenAIModelId(model: string): string { + if (!model.includes('/')) { + return model + } + + if (!model.startsWith('openai/')) { + throw new Error( + `Cannot convert non-OpenAI model to OpenAI model ID: ${model}`, + ) + } + + const mapped = OPENROUTER_TO_OPENAI_MODEL_MAP[model] + if (mapped) { + return mapped + } + + throw new Error(`Model is not supported for ChatGPT OAuth direct routing: ${model}`) +} diff --git a/common/src/constants/claude-oauth.ts b/common/src/constants/claude-oauth.ts deleted file mode 100644 index f6e1cea454..0000000000 --- a/common/src/constants/claude-oauth.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Claude Code OAuth constants for connecting to user's Claude Pro/Max subscription. - * These are used by the CLI for the OAuth PKCE flow and by the SDK for direct Anthropic API calls. - */ - -// OAuth client ID used by Claude Code and third-party apps like opencode -export const CLAUDE_OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' - -// Anthropic OAuth endpoints -export const CLAUDE_OAUTH_AUTHORIZE_URL = 'https://console.anthropic.com/oauth/authorize' -export const CLAUDE_OAUTH_TOKEN_URL = 'https://console.anthropic.com/oauth/token' - -// Anthropic API endpoint for direct calls -export const ANTHROPIC_API_BASE_URL = 'https://api.anthropic.com' - -// Environment variable for OAuth token override -export const CLAUDE_OAUTH_TOKEN_ENV_VAR = 'CODEBUFF_CLAUDE_OAUTH_TOKEN' - -// Required Anthropic API version header -export const ANTHROPIC_API_VERSION = '2023-06-01' - -/** - * Beta headers required for Claude OAuth access to Claude 4+ models. - * These must be included in the anthropic-beta header when making requests. - */ -export const CLAUDE_OAUTH_BETA_HEADERS = [ - 'oauth-2025-04-20', - 'claude-code-20250219', - 'interleaved-thinking-2025-05-14', - 'fine-grained-tool-streaming-2025-05-14', -] as const - -/** - * System prompt prefix required by Anthropic to allow OAuth access to Claude 4+ models. - * This must be prepended to the system prompt when using Claude OAuth with Claude 4+ models. - * Without this prefix, requests will fail with "This credential is only authorized for use with Claude Code". - */ -export const CLAUDE_CODE_SYSTEM_PROMPT_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude." - -/** - * Model ID mapping from OpenRouter format to Anthropic format. - * OpenRouter uses prefixed IDs like "anthropic/claude-sonnet-4", - * while Anthropic uses versioned IDs like "claude-3-5-haiku-20241022". - */ -export const OPENROUTER_TO_ANTHROPIC_MODEL_MAP: Record = { - // Claude 3.x Haiku models - 'anthropic/claude-3.5-haiku-20241022': 'claude-3-5-haiku-20241022', - 'anthropic/claude-3.5-haiku': 'claude-3-5-haiku-20241022', - 'anthropic/claude-3-5-haiku': 'claude-3-5-haiku-20241022', - 'anthropic/claude-3-5-haiku-20241022': 'claude-3-5-haiku-20241022', - 'anthropic/claude-3-haiku': 'claude-3-haiku-20240307', - - // Claude 3.x Sonnet models - 'anthropic/claude-3.5-sonnet': 'claude-3-5-sonnet-20241022', - 'anthropic/claude-3-5-sonnet': 'claude-3-5-sonnet-20241022', - 'anthropic/claude-3-5-sonnet-20241022': 'claude-3-5-sonnet-20241022', - 'anthropic/claude-3-5-sonnet-20240620': 'claude-3-5-sonnet-20240620', - 'anthropic/claude-3-sonnet': 'claude-3-sonnet-20240229', - - // Claude 3.x Opus models - 'anthropic/claude-3-opus': 'claude-3-opus-20240229', - 'anthropic/claude-3-opus-20240229': 'claude-3-opus-20240229', - - // Claude 4.x Haiku models - 'anthropic/claude-haiku-4.5': 'claude-haiku-4-5-20251001', - 'anthropic/claude-haiku-4': 'claude-haiku-4-20250514', - - // Claude 4.x Sonnet models - 'anthropic/claude-sonnet-4.5': 'claude-sonnet-4-5-20250929', - 'anthropic/claude-sonnet-4': 'claude-sonnet-4-20250514', - 'anthropic/claude-4-sonnet-20250522': 'claude-sonnet-4-20250514', - 'anthropic/claude-4-sonnet': 'claude-sonnet-4-20250514', - - // Claude 4.x Opus models - 'anthropic/claude-opus-4.5': 'claude-opus-4-5-20251101', - 'anthropic/claude-opus-4.1': 'claude-opus-4-1-20250805', - 'anthropic/claude-opus-4': 'claude-opus-4-1-20250805', -} - -/** - * Check if a model is a Claude/Anthropic model that can use OAuth. - */ -export function isClaudeModel(model: string): boolean { - return model.startsWith('anthropic/') || model.startsWith('claude-') -} - -/** - * Convert an OpenRouter model ID to an Anthropic model ID. - * Throws an error if the model has a provider prefix but is not an Anthropic model. - */ -export function toAnthropicModelId(openrouterModel: string): string { - // If it's already an Anthropic model ID (no prefix), return as-is - if (!openrouterModel.includes('/')) { - return openrouterModel - } - - // Require anthropic/ prefix for OpenRouter model IDs - if (!openrouterModel.startsWith('anthropic/')) { - throw new Error( - `Cannot convert non-Anthropic model to Anthropic model ID: ${openrouterModel}`, - ) - } - - // Check the mapping table - const mapped = OPENROUTER_TO_ANTHROPIC_MODEL_MAP[openrouterModel] - if (mapped) { - return mapped - } - - // Fallback: strip the "anthropic/" prefix - return openrouterModel.replace('anthropic/', '') -} diff --git a/common/src/constants/feedback.ts b/common/src/constants/feedback.ts new file mode 100644 index 0000000000..5ea4ac48ec --- /dev/null +++ b/common/src/constants/feedback.ts @@ -0,0 +1,13 @@ +export const FEEDBACK_CATEGORIES = ['good_result', 'bad_result', 'app_bug', 'other'] as const +export type FeedbackCategory = (typeof FEEDBACK_CATEGORIES)[number] + +export const FEEDBACK_SOURCES = ['cli', 'sdk', 'web'] as const +export type FeedbackSource = (typeof FEEDBACK_SOURCES)[number] + +export const MESSAGE_VARIANTS = ['ai', 'user', 'agent', 'error'] as const +export type MessageVariant = (typeof MESSAGE_VARIANTS)[number] + +export const MAX_RECENT_MESSAGES = 10 +export const MAX_ERRORS = 50 +export const MAX_ERROR_MESSAGE_LENGTH = 2000 +export const MAX_ERROR_ID_LENGTH = 200 diff --git a/common/src/constants/free-agents.ts b/common/src/constants/free-agents.ts new file mode 100644 index 0000000000..2d1a55c7ff --- /dev/null +++ b/common/src/constants/free-agents.ts @@ -0,0 +1,244 @@ +import { parseAgentId } from '../util/agent-id-parsing' + +import { FREEBUFF_GEMINI_THINKER_AGENT_ID } from './freebuff-gemini-thinker' +import { + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_GEMINI_PRO_MODEL_ID, + FREEBUFF_GLM_MODEL_ID, + FREEBUFF_KIMI_MODEL_ID, + FREEBUFF_MINIMAX_MODEL_ID, + SUPPORTED_FREEBUFF_MODELS, +} from './freebuff-models' + +import type { CostMode } from './model-config' + +/** + * The cost mode that indicates FREE mode. + * Only allowlisted agent+model combinations cost 0 credits in this mode. + */ +export const FREE_COST_MODE = 'free' as const + +/** + * Root-orchestrator agent IDs counted as "a freebuff session" for abuse + * detection and usage auditing. Subagents (file-picker, basher, etc.) are + * excluded — they're spawned by the root, so counting them would inflate + * every user's apparent activity. + */ +export const FREEBUFF_ROOT_AGENT_IDS = [ + 'base2-free', + 'base2-free-kimi', + 'base2-free-deepseek', + 'base2-free-deepseek-flash', +] as const +const FREEBUFF_ROOT_AGENT_ID_SET: ReadonlySet = new Set( + FREEBUFF_ROOT_AGENT_IDS, +) +const FREEBUFF_ALLOWED_MODEL_IDS = SUPPORTED_FREEBUFF_MODELS.map( + (model) => model.id, +) + +export const FREEBUFF_ROOT_AGENT_ID_BY_MODEL: Record = { + [FREEBUFF_MINIMAX_MODEL_ID]: 'base2-free', + [FREEBUFF_KIMI_MODEL_ID]: 'base2-free-kimi', + [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]: 'base2-free-deepseek', + [FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID]: 'base2-free-deepseek-flash', +} + +export const FREEBUFF_REVIEWER_AGENT_ID_BY_MODEL: Record = { + [FREEBUFF_MINIMAX_MODEL_ID]: 'code-reviewer-minimax', + [FREEBUFF_KIMI_MODEL_ID]: 'code-reviewer-kimi', + [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]: 'code-reviewer-deepseek', + [FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID]: 'code-reviewer-deepseek-flash', +} + +export function getFreebuffRootAgentIdForModel(model: string): string { + return FREEBUFF_ROOT_AGENT_ID_BY_MODEL[model] ?? 'base2-free' +} + +/** + * Agents that are allowed to run in FREE mode. + * Only these specific agents (and their expected models) get 0 credits in FREE mode. + * This prevents abuse by users trying to use arbitrary agents for free. + * + * The mapping also specifies which models each agent is allowed to use in free mode. + * If an agent uses a different model, it will be charged full credits. + */ +export const FREE_MODE_AGENT_MODELS: Record> = { + // Root orchestrator + 'base2-free': new Set([ + FREEBUFF_MINIMAX_MODEL_ID, + FREEBUFF_GLM_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + FREEBUFF_KIMI_MODEL_ID, + ]), + 'base2-free-kimi': new Set([FREEBUFF_KIMI_MODEL_ID]), + 'base2-free-deepseek': new Set([FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]), + 'base2-free-deepseek-flash': new Set([FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID]), + + // File exploration agents + 'file-picker': new Set(['google/gemini-2.5-flash-lite']), + 'file-picker-max': new Set(['google/gemini-3.1-flash-lite-preview']), + 'file-lister': new Set(['google/gemini-3.1-flash-lite-preview']), + + // Research agents + 'researcher-web': new Set(['google/gemini-3.1-flash-lite-preview']), + 'researcher-docs': new Set(['google/gemini-3.1-flash-lite-preview']), + + // Browser automation + 'browser-use': new Set(['google/gemini-3.1-flash-lite-preview']), + + // Command execution + basher: new Set(['google/gemini-3.1-flash-lite-preview']), + 'tmux-cli': new Set([FREEBUFF_MINIMAX_MODEL_ID]), + + // Code reviewer for free mode + 'code-reviewer-minimax': new Set([ + FREEBUFF_MINIMAX_MODEL_ID, + FREEBUFF_GLM_MODEL_ID, + ]), + 'code-reviewer-kimi': new Set([FREEBUFF_KIMI_MODEL_ID]), + 'code-reviewer-deepseek': new Set([FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]), + 'code-reviewer-deepseek-flash': new Set([ + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ]), + // Legacy freebuff clients spawned code-reviewer-lite under provider-specific + // free roots before those reviewer IDs existed. + 'code-reviewer-lite': new Set([ + FREEBUFF_MINIMAX_MODEL_ID, + FREEBUFF_KIMI_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + ]), + + // Legacy: kept for the standalone gemini thinker agent if invoked directly. + [FREEBUFF_GEMINI_THINKER_AGENT_ID]: new Set([FREEBUFF_GEMINI_PRO_MODEL_ID]), +} + +/** + * Agents that don't charge credits when credits would be very small (<5). + * + * These are typically lightweight utility agents that: + * - Use cheap models (e.g., Gemini Flash) + * - Have limited, programmatic capabilities + * - Are frequently spawned as subagents + * + * Making them free avoids user confusion when they connect their own + * Claude subscription (BYOK) but still see credit charges for non-Claude models. + * + * NOTE: This is separate from FREE_MODE_ALLOWED_AGENTS which is for the + * explicit "free" cost mode. These agents get free credits only when + * the cost would be trivial (<5 credits). + */ +export const FREE_TIER_AGENTS = new Set([ + 'file-picker', + 'file-picker-max', + 'file-lister', + 'researcher-web', + 'researcher-docs', +]) + +/** + * Check if the current cost mode is FREE mode. + * In FREE mode, agents using allowed models cost 0 credits. + */ +export function isFreeMode(costMode: CostMode | string | undefined): boolean { + return costMode === FREE_COST_MODE +} + +export function isFreebuffRootAgent(fullAgentId: string): boolean { + const { publisherId, agentId } = parseAgentId(fullAgentId) + if (!agentId) return false + if (publisherId && publisherId !== 'codebuff') return false + return FREEBUFF_ROOT_AGENT_ID_SET.has(agentId) +} + +export function isFreebuffGeminiThinkerAgent(fullAgentId: string): boolean { + const { publisherId, agentId } = parseAgentId(fullAgentId) + if (!agentId) return false + if (publisherId && publisherId !== 'codebuff') return false + return agentId === FREEBUFF_GEMINI_THINKER_AGENT_ID +} + +export function shouldUseLocalTokenCountForFreebuffDeepseekFlash(params: { + agentId: string | undefined + model: string | undefined +}): boolean { + const { agentId: fullAgentId, model } = params + if (!fullAgentId || model !== FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) { + return false + } + + const { publisherId, agentId } = parseAgentId(fullAgentId) + if (publisherId && publisherId !== 'codebuff') return false + return agentId === 'base2-free-deepseek-flash' +} + +/** + * Check if a specific agent is allowed to use a specific model in FREE mode. + * This is the strictest check - validates both the agent AND model combination. + * + * Returns true only if: + * 1. The agent has a valid agent ID + * 2. The agent is in the allowed free-mode agents list + * 3. The agent is either internal or published by 'codebuff' (prevents spoofing) + * 4. The model is in that agent's allowed model set + */ +export function isFreeModeAllowedAgentModel( + fullAgentId: string, + model: string, +): boolean { + const { publisherId, agentId } = parseAgentId(fullAgentId) + + // Must have a valid agent ID + if (!agentId) return false + + // Must be either internal (no publisher) or from codebuff + if (publisherId && publisherId !== 'codebuff') return false + + // Get the allowed models for this agent + const allowedModels = FREE_MODE_AGENT_MODELS[agentId] + if (!allowedModels) return false + + // Empty set means programmatic agent (no LLM calls expected) + // For these, any model check should fail (they shouldn't be making LLM calls) + if (allowedModels.size === 0) return false + + // Exact match first + if (allowedModels.has(model)) return true + + // OpenRouter may return dated variants (e.g. "minimax/minimax-m2.7-20260211") + // so also check if the returned model starts with any allowed model prefix. + for (const allowed of allowedModels) { + if (model.startsWith(allowed + '-')) return true + } + + return false +} + +/** + * Check if an agent should be free (no credit charge) for small requests. + * This is separate from FREE mode - these agents get free credits only + * when the cost would be trivial (<5 credits). + * + * Handles all agent ID formats: + * - 'file-picker' + * - 'file-picker@1.0.0' + * - 'codebuff/file-picker@0.0.2' + */ +export function isFreeAgent(fullAgentId: string): boolean { + const { publisherId, agentId } = parseAgentId(fullAgentId) + + // Must have a valid agent ID + if (!agentId) return false + + // Must be in the free tier agents list + if (!FREE_TIER_AGENTS.has(agentId)) return false + + // Must be either internal (no publisher) or from codebuff + // This prevents publisher spoofing attacks + if (publisherId && publisherId !== 'codebuff') return false + + return true +} diff --git a/common/src/constants/freebuff-gemini-thinker.ts b/common/src/constants/freebuff-gemini-thinker.ts new file mode 100644 index 0000000000..007ac18f00 --- /dev/null +++ b/common/src/constants/freebuff-gemini-thinker.ts @@ -0,0 +1,16 @@ +export const FREEBUFF_GEMINI_THINKER_AGENT_ID = 'thinker-with-files-gemini' + +export const FREEBUFF_GEMINI_THINKER_SYSTEM_INSTRUCTION = + "Spawn the thinker-with-files-gemini agent for complex problems -- it's very smart. Skip it for routine edits and clearly-scoped changes. Pass the relevant filePaths since it has no conversation history." + +export const FREEBUFF_GEMINI_THINKER_INSTRUCTIONS_PROMPT = + '- For complex problems, spawn the thinker-with-files-gemini agent after gathering context. Skip it for routine edits and clearly-scoped changes. Pass the relevant filePaths.' + +export const FREEBUFF_GEMINI_THINKER_STEP_PROMPT = + 'Spawn the thinker-with-files-gemini agent for complex problems, not routine edits. Pass the relevant filePaths.' + +export const FREEBUFF_GEMINI_THINKER_PROMPT_LINES = [ + FREEBUFF_GEMINI_THINKER_SYSTEM_INSTRUCTION, + FREEBUFF_GEMINI_THINKER_INSTRUCTIONS_PROMPT, + FREEBUFF_GEMINI_THINKER_STEP_PROMPT, +] as const diff --git a/common/src/constants/freebuff-models.ts b/common/src/constants/freebuff-models.ts new file mode 100644 index 0000000000..715b258b50 --- /dev/null +++ b/common/src/constants/freebuff-models.ts @@ -0,0 +1,305 @@ +import { + addDaysToYmd, + getUtcForZonedTime, + getZonedParts, + type ZonedDateParts, +} from '../util/zoned-time' + +/** + * Models a freebuff user can pick between in the waiting-room model selector. + * + * Each model has its own queue (server keys queue position by `model`), so the + * list here is effectively the set of separate waiting lines. Order is the + * order shown in the UI. + */ +export interface FreebuffModelOption { + /** Stable ID used in the wire protocol and DB. Matches the model id passed + * to the chat-completions endpoint. */ + id: string + /** Short label for the selector UI. */ + displayName: string + /** One-line description shown next to the label. */ + tagline: string + /** Availability policy for the selector and server-side admission. */ + availability: 'always' | 'deployment_hours' + /** Optional caveat shown in the picker (e.g. data-collection warning). + * Rendered in the warning/secondary color so users spot it before + * picking the model. */ + warning?: string +} + +/** Server-facing fallback copy for APIs and provider errors that can't know + * the caller's local timezone. The CLI should render + * `getFreebuffDeploymentAvailabilityLabel()` instead. */ +export const FREEBUFF_DEPLOYMENT_HOURS_LABEL = '9am ET-5pm PT every day' +export const FREEBUFF_GEMINI_PRO_MODEL_ID = 'google/gemini-3.1-pro-preview' +export const FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID = 'deepseek/deepseek-v4-pro' +export const FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID = 'deepseek/deepseek-v4-flash' +export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1' +export const FREEBUFF_KIMI_MODEL_ID = 'moonshotai/kimi-k2.6' +export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7' +export const FREEBUFF_PREMIUM_SESSION_LIMIT = 5 +export const FREEBUFF_LIMITED_SESSION_LIMIT = 5 +export const FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE = 'America/Los_Angeles' +export const FREEBUFF_PREMIUM_SESSION_PERIOD = 'pacific_day' +export const FREEBUFF_LIMITED_SESSION_RESET_TIMEZONE = + FREEBUFF_PREMIUM_SESSION_RESET_TIMEZONE +export const FREEBUFF_LIMITED_SESSION_PERIOD = FREEBUFF_PREMIUM_SESSION_PERIOD +/** Deprecated wire compatibility field. Premium usage now resets at midnight + * Pacific time rather than using a rolling hourly window. */ +export const FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS = 24 +export const FREEBUFF_LIMITED_SESSION_WINDOW_HOURS = + FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS +const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York' +const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles' + +interface LocalTimeFormatOptions { + locale?: string + timeZone?: string +} + +/** Smart freebuff models that benefit from spawning the gemini-thinker + * subagent for deeper reasoning. Fast models (e.g. MiniMax) skip it because + * the extra round-trip would defeat the "fastest" tier. Used by the CLI to + * toggle the gemini-thinker spawnable + prompts based on the user's pick, + * and by the server to admit gemini-thinker child requests against a parent + * session bound to one of these models. */ +export const FREEBUFF_GEMINI_THINKER_PARENT_MODELS = new Set([ + FREEBUFF_KIMI_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, +]) + +export function canFreebuffModelSpawnGeminiThinker(modelId: string): boolean { + return FREEBUFF_GEMINI_THINKER_PARENT_MODELS.has(modelId) +} + +export const FREEBUFF_MODELS = [ + { + id: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + displayName: 'DeepSeek V4 Pro', + tagline: 'Smartest', + availability: 'always', + warning: 'Collects data for training', + }, + { + id: FREEBUFF_KIMI_MODEL_ID, + displayName: 'Kimi K2.6', + tagline: 'Balanced', + availability: 'always', + }, + { + id: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + displayName: 'DeepSeek V4 Flash', + tagline: 'Most efficient', + availability: 'always', + warning: 'Collects data for training', + }, + { + id: FREEBUFF_MINIMAX_MODEL_ID, + displayName: 'MiniMax M2.7', + tagline: 'Fastest', + availability: 'always', + }, +] as const satisfies readonly FreebuffModelOption[] + +export const LEGACY_FREEBUFF_MODELS = [ + { + id: FREEBUFF_GLM_MODEL_ID, + displayName: 'GLM 5.1', + tagline: 'Legacy', + availability: 'deployment_hours', + }, +] as const satisfies readonly FreebuffModelOption[] + +export const FREEBUFF_PREMIUM_MODEL_IDS = [ + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_KIMI_MODEL_ID, + FREEBUFF_GLM_MODEL_ID, +] as const + +export const SUPPORTED_FREEBUFF_MODELS = [ + ...FREEBUFF_MODELS, + ...LEGACY_FREEBUFF_MODELS, +] as const satisfies readonly FreebuffModelOption[] + +export type FreebuffModelId = (typeof FREEBUFF_MODELS)[number]['id'] +export type SupportedFreebuffModelId = + (typeof SUPPORTED_FREEBUFF_MODELS)[number]['id'] +export type FreebuffPremiumModelId = (typeof FREEBUFF_PREMIUM_MODEL_IDS)[number] + +/** What new freebuff users see selected in the picker. MiniMax is the + * fastest always-available option and backs the default base2-free agent. + * Callers that need a guaranteed-available id for resolution / auto-fallbacks + * should use FALLBACK_FREEBUFF_MODEL_ID instead. */ +export const DEFAULT_FREEBUFF_MODEL_ID: FreebuffModelId = + FREEBUFF_MINIMAX_MODEL_ID + +/** Always-available fallback used when the requested model can't be served + * right now (unknown id, deployment hours closed, etc.). Kept distinct from + * DEFAULT_FREEBUFF_MODEL_ID so a new user's "preferred default" can be the + * smartest model without auto-flipping anyone to a closed serverless model. */ +export const FALLBACK_FREEBUFF_MODEL_ID: FreebuffModelId = + FREEBUFF_MINIMAX_MODEL_ID + +export const LIMITED_FREEBUFF_MODEL_ID: FreebuffModelId = + FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID +export const LIMITED_FREEBUFF_MODELS = FREEBUFF_MODELS.filter( + (model) => model.id === LIMITED_FREEBUFF_MODEL_ID, +) + +export type FreebuffAccessTier = 'full' | 'limited' + +export function getFreebuffModelsForAccessTier( + accessTier: FreebuffAccessTier | null | undefined, +): readonly FreebuffModelOption[] { + if (accessTier === 'limited') return LIMITED_FREEBUFF_MODELS + return FREEBUFF_MODELS +} + +export function isFreebuffModelAllowedForAccessTier( + model: string | null | undefined, + accessTier: FreebuffAccessTier | null | undefined, +): boolean { + if (!model) return false + if (accessTier !== 'limited') return isSupportedFreebuffModelId(model) + return model === LIMITED_FREEBUFF_MODEL_ID +} + +export function isFreebuffModelId( + id: string | null | undefined, +): id is FreebuffModelId { + if (!id) return false + return FREEBUFF_MODELS.some((m) => m.id === id) +} + +export function resolveFreebuffModel( + id: string | null | undefined, +): FreebuffModelId { + return isFreebuffModelId(id) ? id : FALLBACK_FREEBUFF_MODEL_ID +} + +export function resolveFreebuffModelForAccessTier( + id: string | null | undefined, + accessTier: FreebuffAccessTier | null | undefined, +): SupportedFreebuffModelId { + if (accessTier === 'limited') return LIMITED_FREEBUFF_MODEL_ID + const resolved = resolveSupportedFreebuffModel(id) + return isFreebuffModelAllowedForAccessTier(resolved, accessTier) + ? resolved + : FALLBACK_FREEBUFF_MODEL_ID +} + +export function isSupportedFreebuffModelId( + id: string | null | undefined, +): id is SupportedFreebuffModelId { + if (!id) return false + return SUPPORTED_FREEBUFF_MODELS.some((m) => m.id === id) +} + +export function isFreebuffPremiumModelId( + id: string | null | undefined, +): id is FreebuffPremiumModelId { + if (!id) return false + return FREEBUFF_PREMIUM_MODEL_IDS.some((modelId) => modelId === id) +} + +export function resolveSupportedFreebuffModel( + id: string | null | undefined, +): SupportedFreebuffModelId { + return isSupportedFreebuffModelId(id) ? id : FALLBACK_FREEBUFF_MODEL_ID +} + +export function getFreebuffModel(id: string): FreebuffModelOption { + return ( + SUPPORTED_FREEBUFF_MODELS.find((m) => m.id === id) ?? + FREEBUFF_MODELS.find((m) => m.id === FALLBACK_FREEBUFF_MODEL_ID)! + ) +} + +function getNextFreebuffDeploymentStart(now: Date): Date { + const easternNow = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE) + const isBeforeTodayOpen = easternNow.hour < 9 + + const offset = isBeforeTodayOpen ? 0 : 1 + + return getUtcForZonedTime( + addDaysToYmd(easternNow.year, easternNow.month, easternNow.day, offset), + FREEBUFF_EASTERN_TIMEZONE, + 9, + 0, + ) +} + +function getCurrentFreebuffDeploymentEnd(now: Date): Date { + const pacificNow = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE) + return getUtcForZonedTime(pacificNow, FREEBUFF_PACIFIC_TIMEZONE, 17, 0) +} + +function isSameLocalDay(left: Date, right: Date, timeZone?: string): boolean { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + return formatter.format(left) === formatter.format(right) +} + +function formatLocalTime( + date: Date, + referenceNow: Date, + options: LocalTimeFormatOptions = {}, +): string { + const shouldShowWeekday = !isSameLocalDay( + date, + referenceNow, + options.timeZone, + ) + return new Intl.DateTimeFormat(options.locale, { + timeZone: options.timeZone, + weekday: shouldShowWeekday ? 'short' : undefined, + hour: 'numeric', + minute: '2-digit', + }).format(date) +} + +export function getFreebuffDeploymentAvailabilityLabel( + now: Date = new Date(), + options: LocalTimeFormatOptions = {}, +): string { + if (isFreebuffDeploymentHours(now)) { + const closesAt = getCurrentFreebuffDeploymentEnd(now) + return `until ${formatLocalTime(closesAt, now, options)}` + } + + const opensAt = getNextFreebuffDeploymentStart(now) + return `opens ${formatLocalTime(opensAt, now, options)}` +} + +export function isFreebuffDeploymentHours(now: Date = new Date()): boolean { + const eastern = getZonedParts(now, FREEBUFF_EASTERN_TIMEZONE) + const pacific = getZonedParts(now, FREEBUFF_PACIFIC_TIMEZONE) + return ( + eastern.hour * 60 + eastern.minute >= 9 * 60 && + pacific.hour * 60 + pacific.minute < 17 * 60 + ) +} + +export function isFreebuffModelAvailable( + id: string, + now: Date = new Date(), +): boolean { + const model = SUPPORTED_FREEBUFF_MODELS.find((m) => m.id === id) + if (!model) return false + return model.availability === 'always' || isFreebuffDeploymentHours(now) +} + +export function resolveAvailableFreebuffModel( + id: string | null | undefined, + now: Date = new Date(), +): FreebuffModelId { + const resolved = resolveFreebuffModel(id) + return isFreebuffModelAvailable(resolved, now) + ? resolved + : FALLBACK_FREEBUFF_MODEL_ID +} diff --git a/common/src/constants/grant-priorities.ts b/common/src/constants/grant-priorities.ts index c9670fb068..df17d1008a 100644 --- a/common/src/constants/grant-priorities.ts +++ b/common/src/constants/grant-priorities.ts @@ -1,9 +1,12 @@ import type { GrantType } from '@codebuff/common/types/grant' +// Lower = consumed first export const GRANT_PRIORITIES: Record = { + subscription: 10, free: 20, - ad: 30, // Ad credits consumed after free, before referral - referral: 40, + referral_legacy: 30, // Legacy recurring referrals (renews monthly, consumed first) + ad: 40, + referral: 50, // One-time referrals (never expires, preserved longer) admin: 60, organization: 70, purchase: 80, diff --git a/common/src/constants/index.ts b/common/src/constants/index.ts new file mode 100644 index 0000000000..090335b11e --- /dev/null +++ b/common/src/constants/index.ts @@ -0,0 +1,8 @@ +// Re-export all constants from domain-specific files for backwards compatibility +// This allows existing imports from '@codebuff/common/old-constants' to continue working + +export * from './model-config' +export * from './limits' +export * from './ui' +export * from './paths' +export * from './chatgpt-oauth' diff --git a/common/src/constants/limits.ts b/common/src/constants/limits.ts new file mode 100644 index 0000000000..14b419ed40 --- /dev/null +++ b/common/src/constants/limits.ts @@ -0,0 +1,20 @@ +export const PROFIT_MARGIN = 0.055 + +export const REQUEST_CREDIT_SHOW_THRESHOLD = 1 +export const MAX_DATE = new Date(86399999999999) +export const BILLING_PERIOD_DAYS = 30 +export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 // 30 days +export const SESSION_TIME_WINDOW_MS = 30 * 60 * 1000 // 30 minutes - used for matching sessions created around fingerprint creation +// New Codebuff accounts receive a one-time free credit grant on signup. +export const SIGNUP_FREE_CREDITS_GRANT = 500 + +// New accounts do not receive monthly free credits; grandfathered monthly grants +// are based on previous expiring free grants instead of this default. +export const DEFAULT_FREE_CREDITS_GRANT = 0 + +// Credit pricing configuration +export const CREDIT_PRICING = { + CENTS_PER_CREDIT: 1, // 1 credit = 1 cent = $0.01 + MIN_PURCHASE_CREDITS: 100, // $1.00 minimum + DISPLAY_RATE: '$0.01 per credit', +} as const diff --git a/common/src/constants/model-config.ts b/common/src/constants/model-config.ts new file mode 100644 index 0000000000..f45d0ed161 --- /dev/null +++ b/common/src/constants/model-config.ts @@ -0,0 +1,193 @@ +import { isExplicitlyDefinedModel } from '../util/model-utils' + +// Allowed model prefixes for validation +export const ALLOWED_MODEL_PREFIXES = [ + 'anthropic', + 'openai', + 'google', + 'x-ai', + 'deepseek', +] as const + +export const costModes = [ + 'free', + 'lite', + 'normal', + 'max', + 'experimental', + 'ask', +] as const +export type CostMode = (typeof costModes)[number] + +export const openaiModels = { + gpt4_1: 'gpt-4.1-2025-04-14', + gpt4o: 'gpt-4o-2024-11-20', + gpt4omini: 'gpt-4o-mini-2024-07-18', + o3mini: 'o3-mini-2025-01-31', + o3: 'o3-2025-04-16', + o3pro: 'o3-pro-2025-06-10', + o4mini: 'o4-mini-2025-04-16', + generatePatch: + 'ft:gpt-4o-2024-08-06:manifold-markets:generate-patch-batch2:AKYtDIhk', +} as const +export type OpenAIModel = (typeof openaiModels)[keyof typeof openaiModels] + +export const openrouterModels = { + openrouter_claude_sonnet_4_5: 'anthropic/claude-sonnet-4.5', + openrouter_claude_sonnet_4: 'anthropic/claude-4-sonnet-20250522', + openrouter_claude_opus_4: 'anthropic/claude-opus-4.1', + openrouter_claude_3_5_haiku: 'anthropic/claude-3.5-haiku-20241022', + openrouter_claude_3_5_sonnet: 'anthropic/claude-3.5-sonnet-20240620', + openrouter_gpt4o: 'openai/gpt-4o-2024-11-20', + openrouter_gpt5: 'openai/gpt-5.1', + openrouter_gpt5_chat: 'openai/gpt-5.1-chat', + openrouter_gpt4o_mini: 'openai/gpt-4o-mini-2024-07-18', + openrouter_gpt4_1_nano: 'openai/gpt-4.1-nano', + openrouter_o3_mini: 'openai/o3-mini-2025-01-31', + openrouter_gemini2_5_pro_preview: 'google/gemini-2.5-pro', + openrouter_gemini2_5_flash: 'google/gemini-2.5-flash', + openrouter_gemini2_5_flash_thinking: + 'google/gemini-2.5-flash-preview:thinking', + openrouter_grok_4: 'x-ai/grok-4-07-09', +} as const +export type openrouterModel = + (typeof openrouterModels)[keyof typeof openrouterModels] + +export const openCodeZenModels = { + opencode_kimi_k2_6: 'opencode/kimi-k2.6', + opencode_minimax_m2_7: 'opencode/minimax-m2.7', +} as const +export type OpenCodeZenModel = + (typeof openCodeZenModels)[keyof typeof openCodeZenModels] + +export const deepseekModels = { + deepseekChat: 'deepseek-chat', + deepseekReasoner: 'deepseek-reasoner', + deepseekV4ProDirect: 'deepseek-v4-pro', + deepseekV4Pro: 'deepseek/deepseek-v4-pro', + deepseekV4FlashDirect: 'deepseek-v4-flash', + deepseekV4Flash: 'deepseek/deepseek-v4-flash', +} as const +export type DeepseekModel = (typeof deepseekModels)[keyof typeof deepseekModels] + +// Vertex uses "endpoint IDs" for finetuned models, which are just integers +export const finetunedVertexModels = { + ft_filepicker_003: '196166068534771712', + ft_filepicker_005: '8493203957034778624', + ft_filepicker_007: '2589952415784501248', + ft_filepicker_topk_001: '3676445825887633408', + ft_filepicker_008: '2672143108984012800', + ft_filepicker_topk_002: '1694861989844615168', + ft_filepicker_010: '3808739064941641728', + ft_filepicker_010_epoch_2: '6231675664466968576', + ft_filepicker_topk_003: '1502192368286171136', +} as const +export const finetunedVertexModelNames: Record = { + [finetunedVertexModels.ft_filepicker_003]: 'ft_filepicker_003', + [finetunedVertexModels.ft_filepicker_005]: 'ft_filepicker_005', + [finetunedVertexModels.ft_filepicker_007]: 'ft_filepicker_007', + [finetunedVertexModels.ft_filepicker_topk_001]: 'ft_filepicker_topk_001', + [finetunedVertexModels.ft_filepicker_008]: 'ft_filepicker_008', + [finetunedVertexModels.ft_filepicker_topk_002]: 'ft_filepicker_topk_002', + [finetunedVertexModels.ft_filepicker_010]: 'ft_filepicker_010', + [finetunedVertexModels.ft_filepicker_010_epoch_2]: + 'ft_filepicker_010_epoch_2', + [finetunedVertexModels.ft_filepicker_topk_003]: 'ft_filepicker_topk_003', +} +export type FinetunedVertexModel = + (typeof finetunedVertexModels)[keyof typeof finetunedVertexModels] + +export const models = { + ...openaiModels, + ...deepseekModels, + ...openrouterModels, + ...finetunedVertexModels, +} as const + +export const shortModelNames = { + 'gemini-2.5-pro': models.openrouter_gemini2_5_pro_preview, + 'flash-2.5': models.openrouter_gemini2_5_flash, + 'opus-4': models.openrouter_claude_opus_4, + 'sonnet-4.5': models.openrouter_claude_sonnet_4_5, + 'sonnet-4': models.openrouter_claude_sonnet_4, + 'sonnet-3.7': models.openrouter_claude_sonnet_4, + 'sonnet-3.6': models.openrouter_claude_3_5_sonnet, + 'sonnet-3.5': models.openrouter_claude_3_5_sonnet, + 'gpt-4.1': models.gpt4_1, + 'o3-mini': models.o3mini, + o3: models.o3, + 'o4-mini': models.o4mini, + 'o3-pro': models.o3pro, +} + +export const providerModelNames = { + ...Object.fromEntries( + Object.entries(openaiModels).map(([name, model]) => [ + model, + 'openai' as const, + ]), + ), + ...Object.fromEntries( + Object.entries(openrouterModels).map(([name, model]) => [ + model, + 'openrouter' as const, + ]), + ), +} + +export type Model = (typeof models)[keyof typeof models] | (string & {}) + +const nonCacheableModels = [ + models.openrouter_grok_4, +] satisfies string[] as string[] +export function supportsCacheControl(model: Model): boolean { + if (model.startsWith('openai/')) { + return true + } + if (model.startsWith('anthropic/')) { + return true + } + if (!isExplicitlyDefinedModel(model)) { + // Default to no cache control for unknown models + return false + } + return !nonCacheableModels.includes(model) +} + +export function getModelFromShortName( + modelName: string | undefined, +): Model | undefined { + if (!modelName) return undefined + if (modelName && !(modelName in shortModelNames)) { + throw new Error( + `Unknown model: ${modelName}. Please use a valid model. Valid models are: ${Object.keys( + shortModelNames, + ).join(', ')}`, + ) + } + + return shortModelNames[modelName as keyof typeof shortModelNames] +} + +export const providerDomains = { + google: 'google.com', + anthropic: 'anthropic.com', + openai: 'chatgpt.com', + deepseek: 'deepseek.com', + xai: 'x.ai', +} as const + +export function getLogoForModel(modelName: string): string | undefined { + let domain: string | undefined + + if (Object.values(openaiModels).includes(modelName as OpenAIModel)) + domain = providerDomains.openai + else if (Object.values(deepseekModels).includes(modelName as DeepseekModel)) + domain = providerDomains.deepseek + else if (modelName.includes('claude')) domain = providerDomains.anthropic + else if (modelName.includes('grok')) domain = providerDomains.xai + + return domain + ? `https://www.google.com/s2/favicons?domain=${domain}&sz=256` + : undefined +} diff --git a/common/src/constants/paths.ts b/common/src/constants/paths.ts new file mode 100644 index 0000000000..70a3a194ff --- /dev/null +++ b/common/src/constants/paths.ts @@ -0,0 +1,69 @@ +export const STOP_MARKER = '[' + 'END]' +export const FIND_FILES_MARKER = '[' + 'FIND_FILES_PLEASE]' + +// Directory where agent template override files are stored +export const AGENT_TEMPLATES_DIR = '.agents/' +export const AGENT_DEFINITION_FILE = 'agent-definition.d.ts' + +export const API_KEY_ENV_VAR = 'CODEBUFF_API_KEY' + +export const INVALID_AUTH_TOKEN_MESSAGE = + 'Invalid auth token. You may have been logged out from the web portal. Please log in again.' + +export const DEFAULT_IGNORED_PATHS = [ + '.git', + '.env', + '.env.*', + '*.min.*', + 'node_modules', + 'venv', + 'virtualenv', + '.venv', + '.virtualenv', + '__pycache__', + '*.egg-info/', + '*.pyc', + '.DS_Store', + '.pytest_cache', + '.mypy_cache', + '.ruff_cache', + '.next', + 'package-lock.json', + 'bun.lockb', +] + +// Special message content tags indicating specific server states +export const ASKED_CONFIG = 'asked_config' +export const SHOULD_ASK_CONFIG = 'should_ask_config' +export const ONE_TIME_TAGS = [] as const +export const ONE_TIME_LABELS = [ + ...ONE_TIME_TAGS, + ASKED_CONFIG, + SHOULD_ASK_CONFIG, +] as const + +export const FILE_READ_STATUS = { + DOES_NOT_EXIST: '[FILE_DOES_NOT_EXIST]', + IGNORED: '[BLOCKED]', + TEMPLATE: '[TEMPLATE]', + OUTSIDE_PROJECT: '[FILE_OUTSIDE_PROJECT]', + TOO_LARGE: '[FILE_TOO_LARGE]', + ERROR: '[FILE_READ_ERROR]', +} as const + +export const HIDDEN_FILE_READ_STATUS = [ + FILE_READ_STATUS.DOES_NOT_EXIST, + FILE_READ_STATUS.IGNORED, + FILE_READ_STATUS.OUTSIDE_PROJECT, + FILE_READ_STATUS.TOO_LARGE, + FILE_READ_STATUS.ERROR, +] + +export function toOptionalFile(file: string | null) { + if (file === null) return null + return HIDDEN_FILE_READ_STATUS.some((status) => file.startsWith(status)) + ? null + : file +} + +export const TEST_USER_ID = 'test-user-id' diff --git a/common/src/constants/skills.ts b/common/src/constants/skills.ts new file mode 100644 index 0000000000..63b8d95a89 --- /dev/null +++ b/common/src/constants/skills.ts @@ -0,0 +1,60 @@ +/** + * Skills constants and validation rules. + * + * Skills are SKILL.md files with YAML frontmatter that define reusable + * instructions that agents can load on-demand via the skill tool. + */ + +/** + * The directory name where skills are stored (within .agents/). + */ +export const SKILLS_DIR_NAME = 'skills' + +/** + * The file name for skill definitions. + */ +export const SKILL_FILE_NAME = 'SKILL.md' + +/** + * Validation regex for skill names. + * - 1-64 characters + * - Lowercase alphanumeric with single hyphen separators + * - Cannot start or end with hyphen + * - No consecutive hyphens + */ +export const SKILL_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/ + +/** + * Maximum length for skill name. + */ +export const SKILL_NAME_MAX_LENGTH = 64 + +/** + * Maximum length for skill description. + */ +export const SKILL_DESCRIPTION_MAX_LENGTH = 1024 + +/** + * Validates a skill name according to the naming rules. + * @param name - The skill name to validate + * @returns true if valid, false otherwise + */ +export function isValidSkillName(name: string): boolean { + if (!name || name.length > SKILL_NAME_MAX_LENGTH) { + return false + } + return SKILL_NAME_REGEX.test(name) +} + +/** + * Validates a skill description according to length rules. + * @param description - The skill description to validate + * @returns true if valid, false otherwise + */ +export function isValidSkillDescription(description: string): boolean { + return ( + typeof description === 'string' && + description.length >= 1 && + description.length <= SKILL_DESCRIPTION_MAX_LENGTH + ) +} diff --git a/common/src/constants/subscription-plans.ts b/common/src/constants/subscription-plans.ts new file mode 100644 index 0000000000..57c482ef0b --- /dev/null +++ b/common/src/constants/subscription-plans.ts @@ -0,0 +1,49 @@ +export const SUBSCRIPTION_DISPLAY_NAME = 'Strong' as const + +export interface TierConfig { + monthlyPrice: number + creditsPerBlock: number + blockDurationHours: number + weeklyCreditsLimit: number +} + +export const SUBSCRIPTION_TIERS = { + 100: { + monthlyPrice: 100, + creditsPerBlock: 420, + blockDurationHours: 5, + weeklyCreditsLimit: 4200, + }, + 200: { + monthlyPrice: 200, + creditsPerBlock: 1050, + blockDurationHours: 5, + weeklyCreditsLimit: 10500, + }, + 500: { + monthlyPrice: 500, + creditsPerBlock: 2940, + blockDurationHours: 5, + weeklyCreditsLimit: 29400, + }, +} as const satisfies Record + +export type SubscriptionTierPrice = keyof typeof SUBSCRIPTION_TIERS + +export const DEFAULT_TIER = SUBSCRIPTION_TIERS[200] + +export function createSubscriptionPriceMappings(priceIds: Record) { + const priceToTier = Object.fromEntries( + Object.entries(priceIds).map(([tier, priceId]) => [priceId, Number(tier) as SubscriptionTierPrice]), + ) as Record + + function getTierFromPriceId(priceId: string): SubscriptionTierPrice | null { + return priceToTier[priceId] ?? null + } + + function getPriceIdFromTier(tier: SubscriptionTierPrice): string | null { + return priceIds[tier] ?? null + } + + return { getTierFromPriceId, getPriceIdFromTier } +} diff --git a/common/src/constants/ui.ts b/common/src/constants/ui.ts new file mode 100644 index 0000000000..238b56e051 --- /dev/null +++ b/common/src/constants/ui.ts @@ -0,0 +1,25 @@ +export const AuthState = { + LOGGED_OUT: 'LOGGED_OUT', + LOGGED_IN: 'LOGGED_IN', +} as const + +export type AuthState = (typeof AuthState)[keyof typeof AuthState] + +export const UserState = { + LOGGED_OUT: 'LOGGED_OUT', + GOOD_STANDING: 'GOOD_STANDING', // >= 100 credits + ATTENTION_NEEDED: 'ATTENTION_NEEDED', // 20-99 credits + CRITICAL: 'CRITICAL', // 1-19 credits + DEPLETED: 'DEPLETED', // <= 0 credits +} as const + +export type UserState = (typeof UserState)[keyof typeof UserState] + +export function getUserState(isLoggedIn: boolean, credits: number): UserState { + if (!isLoggedIn) return UserState.LOGGED_OUT + + if (credits >= 100) return UserState.GOOD_STANDING + if (credits >= 20) return UserState.ATTENTION_NEEDED + if (credits >= 1) return UserState.CRITICAL + return UserState.DEPLETED +} diff --git a/common/src/env.ts b/common/src/env.ts index f9328f91c2..3258241bb1 100644 --- a/common/src/env.ts +++ b/common/src/env.ts @@ -2,7 +2,8 @@ import { clientEnvSchema, clientProcessEnv } from './env-schema' const parsedEnv = clientEnvSchema.safeParse(clientProcessEnv) if (!parsedEnv.success) { - throw parsedEnv.error + console.error('Environment validation failed:', parsedEnv.error.issues) + throw new Error(`Invalid environment configuration: ${parsedEnv.error.message}`) } export const env = parsedEnv.data diff --git a/common/src/mcp/client.ts b/common/src/mcp/client.ts index d39119232f..b49ef792b3 100644 --- a/common/src/mcp/client.ts +++ b/common/src/mcp/client.ts @@ -18,6 +18,34 @@ const listToolsCache: Record< ReturnType > = {} +/** + * Substitutes environment variable references ($VAR_NAME) in a string with their values. + * Supports both simple replacement ("$VAR_NAME") and interpolation ("Bearer $VAR_NAME"). + */ +function substituteEnvInValue(value: string): string { + return value.replace(/\$([A-Z_][A-Z0-9_]*)/g, (match, varName) => { + const envValue = process.env[varName] + if (envValue === undefined) { + // Return original if env var not found + return match + } + return envValue + }) +} + +/** + * Substitutes environment variable references in all values of a record. + */ +function substituteEnvInRecord( + record: Record, +): Record { + const result: Record = {} + for (const [key, value] of Object.entries(record)) { + result[key] = substituteEnvInValue(value) + } + return result +} + function hashConfig(config: MCPConfig): string { if (config.type === 'stdio') { return JSON.stringify({ @@ -57,7 +85,7 @@ export async function getMCPClient(config: MCPConfig): Promise { transport = new StdioClientTransport({ command: config.command, args: config.args, - env: config.env, + env: substituteEnvInRecord(config.env), stderr: 'ignore', }) } else { @@ -65,16 +93,17 @@ export async function getMCPClient(config: MCPConfig): Promise { for (const [key, value] of Object.entries(config.params)) { url.searchParams.set(key, value) } + const headers = substituteEnvInRecord(config.headers) if (config.type === 'http') { transport = new StreamableHTTPClientTransport(url, { requestInit: { - headers: config.headers, + headers, }, }) } else if (config.type === 'sse') { transport = new SSEClientTransport(url, { requestInit: { - headers: config.headers, + headers, }, }) } else { diff --git a/common/src/old-constants.ts b/common/src/old-constants.ts index 252f9f6122..66d954fcda 100644 --- a/common/src/old-constants.ts +++ b/common/src/old-constants.ts @@ -1,355 +1,10 @@ -import { isExplicitlyDefinedModel } from './util/model-utils' - -export const PROFIT_MARGIN = 0.055 - -export const STOP_MARKER = '[' + 'END]' -export const FIND_FILES_MARKER = '[' + 'FIND_FILES_PLEASE]' -export const EXISTING_CODE_MARKER = '[[**REPLACE_WITH_EXISTING_CODE**]]' - -// Directory where agent template override files are stored -export const AGENT_TEMPLATES_DIR = '.agents/' -export const AGENT_DEFINITION_FILE = 'agent-definition.d.ts' - -export const API_KEY_ENV_VAR = 'CODEBUFF_API_KEY' - -export const INVALID_AUTH_TOKEN_MESSAGE = - 'Invalid auth token. You may have been logged out from the web portal. Please log in again.' - -// Allowed model prefixes for validation -export const ALLOWED_MODEL_PREFIXES = [ - 'anthropic', - 'openai', - 'google', - 'x-ai', -] as const - -export const DEFAULT_IGNORED_PATHS = [ - '.git', - '.env', - '.env.*', - '*.min.*', - 'node_modules', - 'venv', - 'virtualenv', - '.venv', - '.virtualenv', - '__pycache__', - '*.egg-info/', - '*.pyc', - '.DS_Store', - '.pytest_cache', - '.mypy_cache', - '.ruff_cache', - '.next', - 'package-lock.json', - 'bun.lockb', -] - -// Special message content tags indicating specific server states -export const ASKED_CONFIG = 'asked_config' -export const SHOULD_ASK_CONFIG = 'should_ask_config' -export const ONE_TIME_TAGS = [] as const -export const ONE_TIME_LABELS = [ - ...ONE_TIME_TAGS, - ASKED_CONFIG, - SHOULD_ASK_CONFIG, -] as const - -export const FILE_READ_STATUS = { - DOES_NOT_EXIST: '[FILE_DOES_NOT_EXIST]', - IGNORED: '[BLOCKED]', - TEMPLATE: '[TEMPLATE]', - OUTSIDE_PROJECT: '[FILE_OUTSIDE_PROJECT]', - TOO_LARGE: '[FILE_TOO_LARGE]', - ERROR: '[FILE_READ_ERROR]', -} as const - -export const HIDDEN_FILE_READ_STATUS = [ - FILE_READ_STATUS.DOES_NOT_EXIST, - FILE_READ_STATUS.IGNORED, - FILE_READ_STATUS.OUTSIDE_PROJECT, - FILE_READ_STATUS.TOO_LARGE, - FILE_READ_STATUS.ERROR, -] - -export function toOptionalFile(file: string | null) { - if (file === null) return null - return HIDDEN_FILE_READ_STATUS.some((status) => file.startsWith(status)) - ? null - : file -} - -export const REQUEST_CREDIT_SHOW_THRESHOLD = 1 -export const MAX_DATE = new Date(86399999999999) -export const BILLING_PERIOD_DAYS = 30 -export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 // 30 days -export const SESSION_TIME_WINDOW_MS = 30 * 60 * 1000 // 30 minutes - used for matching sessions created around fingerprint creation -export const CREDITS_REFERRAL_BONUS = 250 -export const AFFILIATE_USER_REFFERAL_LIMIT = 500 - -// Default number of free credits granted per cycle -export const DEFAULT_FREE_CREDITS_GRANT = 500 - -// Credit pricing configuration -export const CREDIT_PRICING = { - CENTS_PER_CREDIT: 1, // 1 credit = 1 cent = $0.01 - MIN_PURCHASE_CREDITS: 100, // $1.00 minimum - DISPLAY_RATE: '$0.01 per credit', -} as const - -export const AuthState = { - LOGGED_OUT: 'LOGGED_OUT', - LOGGED_IN: 'LOGGED_IN', -} as const - -export type AuthState = (typeof AuthState)[keyof typeof AuthState] - -export const UserState = { - LOGGED_OUT: 'LOGGED_OUT', - GOOD_STANDING: 'GOOD_STANDING', // >= 100 credits - ATTENTION_NEEDED: 'ATTENTION_NEEDED', // 20-99 credits - CRITICAL: 'CRITICAL', // 1-19 credits - DEPLETED: 'DEPLETED', // <= 0 credits -} as const - -export type UserState = (typeof UserState)[keyof typeof UserState] - -export function getUserState(isLoggedIn: boolean, credits: number): UserState { - if (!isLoggedIn) return UserState.LOGGED_OUT - - if (credits >= 100) return UserState.GOOD_STANDING - if (credits >= 20) return UserState.ATTENTION_NEEDED - if (credits >= 1) return UserState.CRITICAL - return UserState.DEPLETED -} - -export const costModes = [ - 'lite', - 'normal', - 'max', - 'experimental', - 'ask', -] as const -export type CostMode = (typeof costModes)[number] - -export const getModelForMode = ( - costMode: CostMode, - operation: 'agent' | 'file-requests' | 'check-new-files', -) => { - if (operation === 'agent') { - return { - lite: models.openrouter_gemini2_5_flash, - normal: models.openrouter_claude_sonnet_4, - max: models.openrouter_claude_sonnet_4, - experimental: models.openrouter_gemini2_5_pro_preview, - ask: models.openrouter_gemini2_5_pro_preview, - }[costMode] - } - if (operation === 'file-requests') { - return { - lite: models.openrouter_claude_3_5_haiku, - normal: models.openrouter_claude_3_5_haiku, - max: models.openrouter_claude_sonnet_4, - experimental: models.openrouter_claude_sonnet_4, - ask: models.openrouter_claude_3_5_haiku, - }[costMode] - } - if (operation === 'check-new-files') { - return { - lite: models.openrouter_claude_3_5_haiku, - normal: models.openrouter_claude_sonnet_4, - max: models.openrouter_claude_sonnet_4, - experimental: models.openrouter_claude_sonnet_4, - ask: models.openrouter_claude_sonnet_4, - }[costMode] - } - throw new Error(`Unknown operation: ${operation}`) -} - -// export const claudeModels = { -// sonnet: 'claude-sonnet-4-20250514', -// sonnet3_7: 'claude-3-7-sonnet-20250219', -// sonnet3_5: 'claude-3-5-sonnet-20241022', -// opus4: 'claude-opus-4-20250514', -// haiku: 'claude-3-5-haiku-20241022', -// } as const - -export const openaiModels = { - gpt4_1: 'gpt-4.1-2025-04-14', - gpt4o: 'gpt-4o-2024-11-20', - gpt4omini: 'gpt-4o-mini-2024-07-18', - o3mini: 'o3-mini-2025-01-31', - o3: 'o3-2025-04-16', - o3pro: 'o3-pro-2025-06-10', - o4mini: 'o4-mini-2025-04-16', - generatePatch: - 'ft:gpt-4o-2024-08-06:manifold-markets:generate-patch-batch2:AKYtDIhk', -} as const -export type OpenAIModel = (typeof openaiModels)[keyof typeof openaiModels] - -export const openrouterModels = { - openrouter_claude_sonnet_4_5: 'anthropic/claude-sonnet-4.5', - openrouter_claude_sonnet_4: 'anthropic/claude-4-sonnet-20250522', - openrouter_claude_opus_4: 'anthropic/claude-opus-4.1', - openrouter_claude_3_5_haiku: 'anthropic/claude-3.5-haiku-20241022', - openrouter_claude_3_5_sonnet: 'anthropic/claude-3.5-sonnet-20240620', - openrouter_gpt4o: 'openai/gpt-4o-2024-11-20', - openrouter_gpt5: 'openai/gpt-5.1', - openrouter_gpt5_chat: 'openai/gpt-5.1-chat', - openrouter_gpt4o_mini: 'openai/gpt-4o-mini-2024-07-18', - openrouter_gpt4_1_nano: 'openai/gpt-4.1-nano', - openrouter_o3_mini: 'openai/o3-mini-2025-01-31', - openrouter_gemini2_5_pro_preview: 'google/gemini-2.5-pro', - openrouter_gemini2_5_flash: 'google/gemini-2.5-flash', - openrouter_gemini2_5_flash_thinking: - 'google/gemini-2.5-flash-preview:thinking', - openrouter_grok_4: 'x-ai/grok-4-07-09', -} as const -export type openrouterModel = - (typeof openrouterModels)[keyof typeof openrouterModels] - -export const deepseekModels = { - deepseekChat: 'deepseek-chat', - deepseekReasoner: 'deepseek-reasoner', -} as const -export type DeepseekModel = (typeof deepseekModels)[keyof typeof deepseekModels] - -// Vertex uses "endpoint IDs" for finetuned models, which are just integers -export const finetunedVertexModels = { - ft_filepicker_003: '196166068534771712', - ft_filepicker_005: '8493203957034778624', - ft_filepicker_007: '2589952415784501248', - ft_filepicker_topk_001: '3676445825887633408', - ft_filepicker_008: '2672143108984012800', - ft_filepicker_topk_002: '1694861989844615168', - ft_filepicker_010: '3808739064941641728', - ft_filepicker_010_epoch_2: '6231675664466968576', - ft_filepicker_topk_003: '1502192368286171136', -} as const -export const finetunedVertexModelNames: Record = { - [finetunedVertexModels.ft_filepicker_003]: 'ft_filepicker_003', - [finetunedVertexModels.ft_filepicker_005]: 'ft_filepicker_005', - [finetunedVertexModels.ft_filepicker_007]: 'ft_filepicker_007', - [finetunedVertexModels.ft_filepicker_topk_001]: 'ft_filepicker_topk_001', - [finetunedVertexModels.ft_filepicker_008]: 'ft_filepicker_008', - [finetunedVertexModels.ft_filepicker_topk_002]: 'ft_filepicker_topk_002', - [finetunedVertexModels.ft_filepicker_010]: 'ft_filepicker_010', - [finetunedVertexModels.ft_filepicker_010_epoch_2]: - 'ft_filepicker_010_epoch_2', - [finetunedVertexModels.ft_filepicker_topk_003]: 'ft_filepicker_topk_003', -} -export type FinetunedVertexModel = - (typeof finetunedVertexModels)[keyof typeof finetunedVertexModels] - -export const models = { - // ...claudeModels, - ...openaiModels, - ...deepseekModels, - ...openrouterModels, - ...finetunedVertexModels, -} as const - -export const shortModelNames = { - 'gemini-2.5-pro': models.openrouter_gemini2_5_pro_preview, - 'flash-2.5': models.openrouter_gemini2_5_flash, - 'opus-4': models.openrouter_claude_opus_4, - 'sonnet-4.5': models.openrouter_claude_sonnet_4_5, - 'sonnet-4': models.openrouter_claude_sonnet_4, - 'sonnet-3.7': models.openrouter_claude_sonnet_4, - 'sonnet-3.6': models.openrouter_claude_3_5_sonnet, - 'sonnet-3.5': models.openrouter_claude_3_5_sonnet, - 'gpt-4.1': models.gpt4_1, - 'o3-mini': models.o3mini, - o3: models.o3, - 'o4-mini': models.o4mini, - 'o3-pro': models.o3pro, -} - -export const providerModelNames = { - // ...Object.fromEntries( - // Object.entries(openrouterModels).map(([name, model]) => [ - // model, - // 'claude' as const, - // ]) - // ), - ...Object.fromEntries( - Object.entries(openaiModels).map(([name, model]) => [ - model, - 'openai' as const, - ]), - ), - ...Object.fromEntries( - Object.entries(openrouterModels).map(([name, model]) => [ - model, - 'openrouter' as const, - ]), - ), -} - -export type Model = (typeof models)[keyof typeof models] | (string & {}) - -export const shouldCacheModels = [ - 'anthropic/claude-opus-4.1', - 'anthropic/claude-sonnet-4', - 'anthropic/claude-opus-4', - 'anthropic/claude-3.7-sonnet', - 'anthropic/claude-3.5-haiku', - 'z-ai/glm-4.5', - 'qwen/qwen3-coder', -] -const nonCacheableModels = [ - models.openrouter_grok_4, -] satisfies string[] as string[] -export function supportsCacheControl(model: Model): boolean { - if (model.startsWith('openai/')) { - return true - } - if (model.startsWith('anthropic/')) { - return true - } - if (!isExplicitlyDefinedModel(model)) { - // Default to no cache control for unknown models - return false - } - return !nonCacheableModels.includes(model) -} - -export const TEST_USER_ID = 'test-user-id' - -export function getModelFromShortName( - modelName: string | undefined, -): Model | undefined { - if (!modelName) return undefined - if (modelName && !(modelName in shortModelNames)) { - throw new Error( - `Unknown model: ${modelName}. Please use a valid model. Valid models are: ${Object.keys( - shortModelNames, - ).join(', ')}`, - ) - } - - return shortModelNames[modelName as keyof typeof shortModelNames] -} - -export const providerDomains = { - google: 'google.com', - anthropic: 'anthropic.com', - openai: 'chatgpt.com', - deepseek: 'deepseek.com', - xai: 'x.ai', -} as const - -export function getLogoForModel(modelName: string): string | undefined { - let domain: string | undefined - - if (Object.values(openaiModels).includes(modelName as OpenAIModel)) - domain = providerDomains.openai - else if (Object.values(deepseekModels).includes(modelName as DeepseekModel)) - domain = providerDomains.deepseek - else if (modelName.includes('claude')) domain = providerDomains.anthropic - else if (modelName.includes('grok')) domain = providerDomains.xai - - return domain - ? `https://www.google.com/s2/favicons?domain=${domain}&sz=256` - : undefined -} +/** + * @deprecated Import from '@codebuff/common/constants' or specific files instead: + * - '@codebuff/common/constants/model-config' for model-related constants + * - '@codebuff/common/constants/limits' for billing and numeric limits + * - '@codebuff/common/constants/ui' for auth/user state + * - '@codebuff/common/constants/paths' for file paths and markers + * + * This file re-exports all constants for backwards compatibility. + */ +export * from './constants' diff --git a/common/src/project-file-tree.ts b/common/src/project-file-tree.ts index 9bc45383f0..20a2ddd017 100644 --- a/common/src/project-file-tree.ts +++ b/common/src/project-file-tree.ts @@ -3,12 +3,41 @@ import path from 'path' import * as ignore from 'ignore' import { sortBy } from 'lodash' -import { DEFAULT_IGNORED_PATHS } from './old-constants' +import { DEFAULT_IGNORED_PATHS } from './constants/paths' import { fileExists, isValidProjectRoot } from './util/file' import type { CodebuffFileSystem } from './types/filesystem' import type { DirectoryNode, FileTreeNode } from './util/file' +/** + * Logs file tree errors in debug mode only. + * Errors are logged but not thrown to preserve tree-building behavior. + * + * File tree operations commonly encounter expected errors (permissions, + * deleted files) that are not fatal. We only log in debug mode to avoid + * noisy output during normal operation. + */ +function logFileTreeError( + operation: string, + filePath: string, + error: unknown, +): void { + // Only log in debug mode to avoid noisy output + if (!process.env.DEBUG && !process.env.CODEBUFF_DEBUG) { + return + } + + const err = error as { code?: string } | undefined + const code = err?.code + const errorMessage = error instanceof Error ? error.message : String(error) + + console.debug( + `[FileTree] ${operation} failed for "${filePath}"${ + code ? ` (${code})` : '' + }: ${errorMessage}`, + ) +} + export const DEFAULT_MAX_FILES = 10_000 export async function getProjectFileTree(params: { @@ -20,7 +49,7 @@ export async function getProjectFileTree(params: { const { projectRoot, fs } = withDefaults let { maxFiles } = withDefaults - const start = Date.now() + const _start = Date.now() const defaultIgnore = ignore.default() for (const pattern of DEFAULT_IGNORED_PATHS) { defaultIgnore.add(pattern) @@ -97,12 +126,16 @@ export async function getProjectFileTree(params: { }) totalFiles++ } - } catch (error: any) { - // Don't print errors, you probably just don't have access to the file. + } catch (error: unknown) { + // File may be inaccessible due to permissions or may have been deleted. + // Log with context for debugging, but continue building the tree. + logFileTreeError('fs.stat', filePath, error) } } - } catch (error: any) { - // Don't print errors, you probably just don't have access to the directory. + } catch (error: unknown) { + // Directory may be inaccessible due to permissions. + // Log with context for debugging, but continue building the tree. + logFileTreeError('fs.readdir', fullPath, error) } } return root.children @@ -178,7 +211,10 @@ export async function parseGitignore(params: { let ignoreContent: string try { ignoreContent = await fs.readFile(ignoreFilePath, 'utf8') - } catch { + } catch (error: unknown) { + // Ignore file may be inaccessible or deleted after existence check. + // Log with context for debugging, but continue without these ignore rules. + logFileTreeError('fs.readFile (ignore file)', ignoreFilePath, error) continue } const lines = ignoreContent.split('\n') @@ -207,6 +243,27 @@ export function getAllFilePaths( }) } +export interface PathInfo { + path: string + isDirectory: boolean +} + +export function getAllPathsWithDirectories( + nodes: FileTreeNode[], + basePath: string = '', +): PathInfo[] { + return nodes.flatMap((node) => { + const nodePath = basePath ? path.join(basePath, node.name) : node.name + if (node.type === 'file') { + return [{ path: nodePath, isDirectory: false }] + } + // Include the directory itself, plus recurse into children + const dirEntry: PathInfo = { path: nodePath, isDirectory: true } + const children = getAllPathsWithDirectories(node.children || [], nodePath) + return [dirEntry, ...children] + }) +} + export function flattenTree(nodes: FileTreeNode[]): FileTreeNode[] { return nodes.flatMap((node) => { if (node.type === 'file') { diff --git a/common/src/schemas/feedback.ts b/common/src/schemas/feedback.ts new file mode 100644 index 0000000000..adc5701729 --- /dev/null +++ b/common/src/schemas/feedback.ts @@ -0,0 +1,50 @@ +import { z } from 'zod/v4' + +import { + FEEDBACK_CATEGORIES, + FEEDBACK_SOURCES, + MAX_ERRORS, + MAX_ERROR_ID_LENGTH, + MAX_ERROR_MESSAGE_LENGTH, + MAX_RECENT_MESSAGES, + MESSAGE_VARIANTS, +} from '../constants/feedback' + +export const feedbackRequestSchema = z.object({ + text: z.string().trim().min(1), + category: z.enum(FEEDBACK_CATEGORIES), + type: z.enum(['message', 'general']), + clientFeedbackId: z.string().uuid().optional(), + source: z.enum(FEEDBACK_SOURCES).optional(), + messageId: z.string().min(1).max(200).optional(), + messageVariant: z.enum(MESSAGE_VARIANTS).optional(), + completionTime: z.string().max(50).optional(), + credits: z.number().nonnegative().finite().optional(), + agentMode: z.string().max(100).optional(), + sessionCreditsUsed: z.number().nonnegative().finite().optional(), + recentMessages: z + .array( + z.object({ + type: z.enum(MESSAGE_VARIANTS), + id: z.string().max(200), + completionTime: z.string().max(50).optional(), + credits: z.number().nonnegative().finite().optional(), + }), + ) + .max(MAX_RECENT_MESSAGES) + .optional(), + errors: z + .array( + z.object({ + id: z.string().max(MAX_ERROR_ID_LENGTH), + message: z.string().max(MAX_ERROR_MESSAGE_LENGTH), + }), + ) + .max(MAX_ERRORS) + .optional(), +}).refine( + (data) => data.type !== 'message' || (data.messageId != null && data.messageId !== ''), + { message: 'messageId is required when type is "message"', path: ['messageId'] }, +) + +export type FeedbackRequest = z.infer diff --git a/common/src/templates/initial-agents-dir/README.md b/common/src/templates/initial-agents-dir/README.md index 16c2d6ee2a..43053980d3 100644 --- a/common/src/templates/initial-agents-dir/README.md +++ b/common/src/templates/initial-agents-dir/README.md @@ -132,6 +132,7 @@ export default { ### Web & Research - **`web_search`**: Search the internet for information +- **`read_url`**: Fetch a URL and extract readable page text - **`read_docs`**: Read technical documentation - **`browser_logs`**: Navigate and inspect web pages @@ -170,9 +171,9 @@ async *handleSteps() { Choose models based on your agent's needs: -- **`anthropic/claude-sonnet-4`**: Best for complex reasoning and code generation -- **`openai/gpt-5`**: Strong general-purpose capabilities -- **`x-ai/grok-4-fast`**: Fast and cost-effective for simple or medium-complexity tasks +- **`anthropic/claude-opus-4.7`**: Best general-purpose capabilities and code generation +- **`openai/gpt-5.2`**: Best at complex reasoning and planning +- **`google/gemini-3.1-flash-lite-preview`**: Fast and cost-effective for simple or medium-complexity tasks **Any model on OpenRouter**: Unlike Claude Code which locks you into Anthropic's models, Codebuff supports any model available on [OpenRouter](https://openrouter.ai/models) - from Claude and GPT to specialized models like Qwen, DeepSeek, and others. Switch models for different tasks or use the latest releases without waiting for platform updates. diff --git a/common/src/templates/initial-agents-dir/skills/README.md b/common/src/templates/initial-agents-dir/skills/README.md new file mode 100644 index 0000000000..48414203a4 --- /dev/null +++ b/common/src/templates/initial-agents-dir/skills/README.md @@ -0,0 +1,64 @@ +# Skills + +Skills are reusable instruction sets that agents can load on-demand via the `skill` tool. + +## Creating a Skill + +1. Create a directory with your skill name (lowercase alphanumeric with hyphens): + ``` + .agents/skills/my-skill/ + ``` + +2. Create a `SKILL.md` file with YAML frontmatter: + ```markdown + --- + name: my-skill + description: A short description of what this skill does + license: MIT + metadata: + category: development + --- + + # My Skill + + Instructions and content for the skill... + ``` + +## Frontmatter Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Skill name (1-64 chars, lowercase alphanumeric with hyphens, must match directory name) | +| `description` | Yes | Short description (1-1024 chars) used for agent discovery | +| `license` | No | License identifier (e.g., "MIT", "Apache-2.0") | +| `metadata` | No | Key-value pairs for additional categorization | + +## Name Validation + +Skill names must: +- Be 1-64 characters long +- Use only lowercase letters, numbers, and hyphens +- Not start or end with a hyphen +- Not contain consecutive hyphens +- Match the directory name exactly + +Valid examples: `git-release`, `api-design`, `review2` +Invalid examples: `Git-Release`, `my--skill`, `-skill`, `skill-` + +## Discovery Locations + +Skills are discovered from these locations (in order of precedence): +1. `~/.agents/skills/` (global, lowest priority) +2. `.agents/skills/` (project, highest priority) + +Project skills override global skills with the same name. + +## How Agents Use Skills + +Agents see available skills listed in the `skill` tool description. When an agent needs a skill's instructions, it calls: + +``` +skill({ name: "my-skill" }) +``` + +The full SKILL.md content is then returned to the agent. diff --git a/common/src/templates/initial-agents-dir/skills/example-skill/SKILL.md b/common/src/templates/initial-agents-dir/skills/example-skill/SKILL.md new file mode 100644 index 0000000000..d2644c2e88 --- /dev/null +++ b/common/src/templates/initial-agents-dir/skills/example-skill/SKILL.md @@ -0,0 +1,29 @@ +--- +name: example-skill +description: An example skill demonstrating the SKILL.md format +license: MIT +metadata: + category: examples + audience: developers +--- + +# Example Skill + +This is an example skill that demonstrates the SKILL.md format. + +## When to use this skill + +Use this skill when you need an example of how skills work. + +## Instructions + +1. Skills are loaded on-demand via the `skill` tool +2. The agent sees available skills listed in the tool description +3. When needed, the agent calls `skill({ name: "example-skill" })` to load the full content +4. The skill content is then available in the conversation context + +## Notes + +- Skills should have clear, specific descriptions +- The name must be lowercase alphanumeric with hyphens +- The name must match the directory name diff --git a/common/src/templates/initial-agents-dir/types/agent-definition.ts b/common/src/templates/initial-agents-dir/types/agent-definition.ts index f449cfe0ad..b2b157ab09 100644 --- a/common/src/templates/initial-agents-dir/types/agent-definition.ts +++ b/common/src/templates/initial-agents-dir/types/agent-definition.ts @@ -345,7 +345,7 @@ export type TerminalTools = 'run_terminal_command' | 'code_search' /** * Web and browser tools */ -export type WebTools = 'web_search' | 'read_docs' +export type WebTools = 'web_search' | 'read_docs' | 'read_url' /** * Agent management tools @@ -370,25 +370,35 @@ export type ModelName = // Recommended Models // OpenAI + | 'openai/gpt-5.3' + | 'openai/gpt-5.3-codex' + | 'openai/gpt-5.2' | 'openai/gpt-5.1' | 'openai/gpt-5.1-chat' | 'openai/gpt-5-mini' | 'openai/gpt-5-nano' // Anthropic + | 'anthropic/claude-sonnet-4.6' + | 'anthropic/claude-opus-4.7' + | 'anthropic/claude-opus-4.6' + | 'anthropic/claude-opus-4.5' + | 'anthropic/claude-haiku-4.5' | 'anthropic/claude-sonnet-4.5' | 'anthropic/claude-opus-4.1' // Gemini + | 'google/gemini-3.1-pro-preview' + | 'google/gemini-3-pro-preview' + | 'google/gemini-3-flash-preview' + | 'google/gemini-3.1-flash-lite-preview' | 'google/gemini-2.5-pro' | 'google/gemini-2.5-flash' | 'google/gemini-2.5-flash-lite' - | 'google/gemini-2.5-flash-preview-09-2025' - | 'google/gemini-2.5-flash-lite-preview-09-2025' // X-AI - | 'x-ai/grok-4-07-09' | 'x-ai/grok-4-fast' + | 'x-ai/grok-4.1-fast' | 'x-ai/grok-code-fast-1' // Qwen @@ -405,6 +415,10 @@ export type ModelName = | 'qwen/qwen3-30b-a3b:nitro' // DeepSeek + | 'deepseek/deepseek-v4-pro' + | 'deepseek-v4-pro' + | 'deepseek/deepseek-v4-flash' + | 'deepseek-v4-flash' | 'deepseek/deepseek-chat-v3-0324' | 'deepseek/deepseek-chat-v3-0324:nitro' | 'deepseek/deepseek-r1-0528' @@ -413,8 +427,17 @@ export type ModelName = // Other open source models | 'moonshotai/kimi-k2' | 'moonshotai/kimi-k2:nitro' + | 'moonshotai/kimi-k2.6' + | 'z-ai/glm-5' + | 'z-ai/glm-5.1' | 'z-ai/glm-4.6' | 'z-ai/glm-4.6:nitro' + | 'z-ai/glm-4.7' + | 'z-ai/glm-4.7:nitro' + | 'z-ai/glm-4.7-flash' + | 'z-ai/glm-4.7-flash:nitro' + | 'minimax/minimax-m2.5' + | 'minimax/minimax-m2.7' | (string & {}) import type { ToolName, GetToolParams } from './tools' diff --git a/common/src/templates/initial-agents-dir/types/tools.ts b/common/src/templates/initial-agents-dir/types/tools.ts index 4d47cc8c4c..b330950757 100644 --- a/common/src/templates/initial-agents-dir/types/tools.ts +++ b/common/src/templates/initial-agents-dir/types/tools.ts @@ -3,20 +3,27 @@ */ export type ToolName = | 'add_message' + | 'apply_patch' | 'ask_user' | 'code_search' | 'end_turn' | 'find_files' | 'glob' + | 'gravity_index' | 'list_directory' | 'lookup_agent_info' + | 'propose_str_replace' + | 'propose_write_file' | 'read_docs' | 'read_files' | 'read_subtree' + | 'read_url' + | 'render_ui' | 'run_file_change_hooks' | 'run_terminal_command' | 'set_messages' | 'set_output' + | 'skill' | 'spawn_agents' | 'str_replace' | 'suggest_followups' @@ -31,20 +38,27 @@ export type ToolName = */ export interface ToolParamsMap { add_message: AddMessageParams + apply_patch: ApplyPatchParams ask_user: AskUserParams code_search: CodeSearchParams end_turn: EndTurnParams find_files: FindFilesParams glob: GlobParams + gravity_index: GravityIndexParams list_directory: ListDirectoryParams lookup_agent_info: LookupAgentInfoParams + propose_str_replace: ProposeStrReplaceParams + propose_write_file: ProposeWriteFileParams read_docs: ReadDocsParams read_files: ReadFilesParams read_subtree: ReadSubtreeParams + read_url: ReadUrlParams + render_ui: RenderUiParams run_file_change_hooks: RunFileChangeHooksParams run_terminal_command: RunTerminalCommandParams set_messages: SetMessagesParams set_output: SetOutputParams + skill: SkillParams spawn_agents: SpawnAgentsParams str_replace: StrReplaceParams suggest_followups: SuggestFollowupsParams @@ -63,6 +77,21 @@ export interface AddMessageParams { content: string } +/** + * Apply a file operation (create, update, or delete) using Codex-style apply_patch format. + */ +export interface ApplyPatchParams { + /** The file operation to perform. */ + operation: { + /** Operation type: create_file, update_file, or delete_file */ + type: 'create_file' | 'update_file' | 'delete_file' + /** File path relative to project root */ + path: string + /** Diff content. Required for create_file and update_file. Lines prefixed with + for creates, unified diff with @@ hunks for updates. */ + diff?: string + } +} + /** * Ask the user multiple choice questions and pause execution until they respond. */ @@ -133,6 +162,47 @@ export interface GlobParams { cwd?: string } +/** + * Search, browse, inspect, or report integrations in the Gravity Index. + */ +export type GravityIndexParams = + | { + /** Search for the best service recommendation. */ + action: 'search' + /** What the user needs, including stack, constraints, and required capabilities when known. */ + query: string + /** Continue a previous Gravity Index search as a follow-up. */ + search_id?: string + /** Optional structured context about the project, stack, or constraints. */ + context?: Record + } + | { + /** Browse catalog services by category and/or keyword. */ + action: 'browse' + /** Optional category filter, e.g. Database, Auth, Payments, Hosting, Email, AI. */ + category?: string + /** Optional keyword filter, e.g. sendgrid or postgres. */ + q?: string + } + | { + /** List every category with service counts. */ + action: 'list_categories' + } + | { + /** Fetch full detail for a single service by slug. */ + action: 'get_service' + /** Service slug, e.g. supabase, stripe, sendgrid. */ + slug: string + } + | { + /** Report that an integration from a prior search was completed. */ + action: 'report_integration' + /** search_id from the earlier search result. */ + search_id: string + /** Slug of the service that was actually integrated. */ + integrated_slug: string + } + /** * List files and directories in the specified path. Returns separate arrays of file names and directory names. */ @@ -149,6 +219,35 @@ export interface LookupAgentInfoParams { agentId: string } +/** + * Propose string replacements in a file without actually applying them. + */ +export interface ProposeStrReplaceParams { + /** The path to the file to edit. */ + path: string + /** Array of replacements to make. */ + replacements: { + /** The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation. */ + oldString: string + /** The string to replace the corresponding oldString with. Can be empty to delete. */ + newString: string + /** Whether to allow multiple replacements of oldString. */ + allowMultiple?: boolean + }[] +} + +/** + * Propose creating or editing a file without actually applying the changes. + */ +export interface ProposeWriteFileParams { + /** Path to the file relative to the **project root** */ + path: string + /** What the change is intended to do in only one sentence. */ + instructions: string + /** Edit snippet to apply to the file. */ + content: string +} + /** * Fetch up-to-date documentation for libraries and frameworks using Context7 API. */ @@ -179,6 +278,33 @@ export interface ReadSubtreeParams { maxTokens?: number } +/** + * Fetch a URL and extract readable text from the page. + */ +export interface ReadUrlParams { + /** The full http:// or https:// URL to fetch and extract readable text from. */ + url: string + /** Maximum number of extracted text characters to return. Defaults to 20000. */ + max_chars?: number +} + +/** + * Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link. + */ +export interface RenderUiParams { + /** The UI widget to render. */ + widget: { + /** Widget type. Currently, the only supported widget is button. */ + type: 'button' + /** Short button label shown to the user. */ + text: string + /** The http:// or https:// URL to open when the user clicks the button. */ + link: string + /** Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions. */ + variant?: 'primary' | 'secondary' + } +} + /** * Parameters for run_file_change_hooks tool */ @@ -213,6 +339,14 @@ export interface SetMessagesParams { */ export interface SetOutputParams {} +/** + * Load a skill's full instructions when relevant to the current task. Skills are loaded on-demand - only load them when you need their specific guidance. + */ +export interface SkillParams { + /** The name of the skill to load */ + name: string +} + /** * Spawn multiple agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. If you need to run agents sequentially, use spawn_agents with one agent at a time instead. */ @@ -236,10 +370,10 @@ export interface StrReplaceParams { /** Array of replacements to make. */ replacements: { /** The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation. */ - old: string - /** The string to replace the corresponding old string with. Can be empty to delete. */ - new: string - /** Whether to allow multiple replacements of old string. */ + oldString: string + /** The string to replace the corresponding oldString with. Can be empty to delete. */ + newString: string + /** Whether to allow multiple replacements of oldString. */ allowMultiple?: boolean }[] } @@ -276,7 +410,7 @@ export interface ThinkDeeplyParams { } /** - * Search the web for current information using Linkup API. + * Search the web for current information using Serper API. */ export interface WebSearchParams { /** The search query to find relevant web content */ diff --git a/common/src/testing/TESTING_PATTERNS.md b/common/src/testing/TESTING_PATTERNS.md new file mode 100644 index 0000000000..203114ae20 --- /dev/null +++ b/common/src/testing/TESTING_PATTERNS.md @@ -0,0 +1,351 @@ +# Testing Patterns Guide + +This guide documents best practices for writing tests in the Codebuff codebase, based on lessons learned from buffbench runs and production issues. + +## Table of Contents + +1. [Mock Cleanup](#mock-cleanup) +2. [Type-Safe Mocks](#type-safe-mocks) +3. [Assertion Best Practices](#assertion-best-practices) +4. [Test Isolation](#test-isolation) +5. [Common Patterns](#common-patterns) + +--- + +## Mock Cleanup + +### ❌ DON'T: Use `afterAll` for mock restoration + +```typescript +// BAD: Mocks leak between tests +afterAll(() => { + mockSpy.mockRestore() +}) +``` + +### ✅ DO: Use `afterEach` for mock restoration + +```typescript +// GOOD: Each test starts with clean state +afterEach(() => { + mockSpy.mockRestore() +}) +``` + +**Why**: `afterAll` runs only once after all tests complete. If one test modifies mock behavior, subsequent tests inherit that state, causing flaky tests and hard-to-debug failures. + +--- + +## Type-Safe Mocks + +### ❌ DON'T: Use `as any` casts for mocks + +```typescript +// BAD: Type safety lost, bugs hide +spyOn(db, 'insert').mockReturnValue({ + values: mock(() => Promise.resolve({ id: 'test-id' })), +} as any) +``` + +### ✅ DO: Use typed mock factories + +```typescript +// GOOD: Type-safe, reusable, documented +import { setupDbSpies } from '@codebuff/common/testing/mocks' + +const dbSpies = setupDbSpies(db, { defaultInsertId: 'test-id' }) +// dbSpies.insert is properly typed +``` + +### Available Mock Factories + +```typescript +import { + // Logger mocks + createMockLogger, + createMockLoggerWithCapture, + + // Analytics mocks + createMockAnalytics, + setupAnalyticsMocks, + + // Database mocks + setupDbSpies, + createMockDbOperations, + + // Crypto mocks + setupCryptoMocks, + createMockUuid, + + // Stream mocks + createToolCallChunk, + createMockStream, +} from '@codebuff/common/testing/mocks' +``` + +--- + +## Assertion Best Practices + +### ❌ DON'T: Assert on raw strings with formatting + +```typescript +// BAD: Brittle to whitespace/format changes +expect(JSON.stringify(result)).toContain('"role":"assistant"') +``` + +### ✅ DO: Parse JSON and assert on structured fields + +```typescript +// GOOD: Robust to formatting changes +const parsed = JSON.parse(result) +expect(parsed.role).toBe('assistant') +expect(parsed.content).toHaveLength(1) +``` + +### ❌ DON'T: Use substring checks for role validation + +```typescript +// BAD: False positives possible +expect(serializedHistory).toContain('assistant') +``` + +### ✅ DO: Check exact field values + +```typescript +// GOOD: Precise and reliable +expect(messages.some((m) => m.role === 'assistant')).toBe(true) +``` + +--- + +## Test Isolation + +### ❌ DON'T: Share mutable state between tests + +```typescript +// BAD: Tests affect each other +let sharedState = { count: 0 } + +it('test 1', () => { + sharedState.count++ + expect(sharedState.count).toBe(1) +}) + +it('test 2', () => { + // Fails if test 1 runs first! + expect(sharedState.count).toBe(0) +}) +``` + +### ✅ DO: Reset state in `beforeEach` + +```typescript +// GOOD: Each test has fresh state +let state: { count: number } + +beforeEach(() => { + state = { count: 0 } +}) + +it('test 1', () => { + state.count++ + expect(state.count).toBe(1) +}) + +it('test 2', () => { + expect(state.count).toBe(0) // Works! +}) +``` + +--- + +## Common Patterns + +### Testing with Mock Logger + +```typescript +import { createMockLoggerWithCapture } from '@codebuff/common/testing/mocks' + +describe('myFunction', () => { + it('logs errors appropriately', async () => { + const { logger, getByLevel } = createMockLoggerWithCapture() + + await myFunction({ logger }) + + const errors = getByLevel('error') + expect(errors).toHaveLength(0) // No errors logged + }) +}) +``` + +### Testing with Mock Analytics + +```typescript +import { setupAnalyticsMocks } from '@codebuff/common/testing/mocks' +import * as analytics from '@codebuff/common/analytics' + +describe('tracking', () => { + let analyticsSpy: AnalyticsSpies + + beforeEach(() => { + analyticsSpy = setupAnalyticsMocks(analytics) + }) + + afterEach(() => { + analyticsSpy.restore() + }) + + it('tracks the event', async () => { + await doSomething() + expect(analyticsSpy.trackEvent).toHaveBeenCalledWith('something_done', { + prop: 'value', + }) + }) +}) +``` + +### Testing with Deterministic UUIDs + +```typescript +import { setupCryptoMocks } from '@codebuff/common/testing/mocks' + +describe('ID generation', () => { + let cryptoSpies: CryptoMockSpies + + beforeEach(() => { + cryptoSpies = setupCryptoMocks({ prefix: 'test', sequential: true }) + }) + + afterEach(() => { + cryptoSpies.restore() + }) + + it('creates items with sequential IDs', async () => { + const item1 = await createItem() + const item2 = await createItem() + + expect(item1.id).toBe('test-0000-0000-0000-000000000000') + expect(item2.id).toBe('test-0000-0000-0000-000000000001') + }) +}) +``` + +### Testing LLM Streams + +```typescript +import { + createMockStream, + createTextChunk, + createToolCallChunk, + collectStreamChunks, +} from '@codebuff/common/testing/mocks' + +describe('stream processing', () => { + it('handles tool calls', async () => { + const stream = createMockStream([ + createTextChunk('Analyzing...'), + createToolCallChunk('read_files', { paths: ['test.ts'] }), + createTextChunk('Done!'), + createToolCallChunk('end_turn', {}), + ]) + + const { chunks } = await collectStreamChunks(stream) + + const toolCalls = chunks.filter((c) => c.type === 'tool-call') + expect(toolCalls).toHaveLength(2) + expect(toolCalls[0].toolName).toBe('read_files') + }) +}) +``` + +### Testing Database Operations + +```typescript +import { setupDbSpies } from '@codebuff/common/testing/mocks' +import db from '@codebuff/internal/db' + +describe('data layer', () => { + let dbSpies: DbSpies + + beforeEach(() => { + dbSpies = setupDbSpies(db, { defaultInsertId: 'new-record-id' }) + }) + + afterEach(() => { + dbSpies.restore() + }) + + it('inserts a new record', async () => { + const result = await createRecord({ name: 'Test' }) + + expect(dbSpies.insert).toHaveBeenCalled() + expect(result.id).toBe('new-record-id') + }) +}) +``` + +--- + +## Additional Lessons from Buffbench + +### Cross-Browser Styles + +When adding custom scrollbar styles, always include Firefox support: + +```css +/* WebKit (Chrome, Safari, Edge) */ +::-webkit-scrollbar { + width: 6px; +} + +/* Firefox */ +scrollbar-width: thin; +scrollbar-color: hsl(var(--border) / 0.6) transparent; +``` + +### Duplicate Code Detection + +Before adding utility functions, search for existing implementations: + +```bash +# Search for similar functions +rg "filterOutSystemRole\|filterSystem" --type ts +``` + +### Shared Mock File Context + +Don't duplicate mock file context creators. Use the shared one: + +```typescript +import { mockFileContext } from '@codebuff/common/testing/fixtures/agent-runtime' + +// Don't create a new one in each test file +``` + +### Error Path Coverage + +Always add tests for error scenarios: + +```typescript +it('handles API errors gracefully', async () => { + mockApi.mockRejectedValueOnce(new Error('Network error')) + + const result = await fetchData() + + expect(result.error).toBe('Network error') +}) +``` + +--- + +## Migration Checklist + +When updating tests to use these patterns: + +1. [ ] Replace `as any` casts with typed mock factories +2. [ ] Move mock restoration from `afterAll` to `afterEach` +3. [ ] Replace string assertions with structured assertions +4. [ ] Use shared fixtures instead of duplicating mock data +5. [ ] Add error path coverage if missing +6. [ ] Ensure deterministic IDs with `setupCryptoMocks` diff --git a/common/src/testing/fixtures/agent-runtime.ts b/common/src/testing/fixtures/agent-runtime.ts index 5b15832ba2..f4d1430127 100644 --- a/common/src/testing/fixtures/agent-runtime.ts +++ b/common/src/testing/fixtures/agent-runtime.ts @@ -1,41 +1,80 @@ /** - * Test-only AgentRuntime dependency fixture. + * Test fixtures for agent runtime testing. * - * This file intentionally hardcodes dummy values (e.g. API keys) for tests. - * Do not import from production code. + * Provides pre-built test fixtures and factory functions for + * testing agent runtime components without needing to set up + * all the dependencies manually. + * + * @example + * ```typescript + * import { + * createTestAgentRuntimeParams, + * createTestAgentRuntimeDeps, + * mockFileContext, + * } from '@codebuff/common/testing/fixtures/agent-runtime' + * + * const params = createTestAgentRuntimeParams() + * const { agentTemplate, localAgentTemplates } = params + * ``` */ -import { getInitialAgentState } from '../../types/session-state' +import { mock } from 'bun:test' + +import { promptSuccess } from '../../util/error' -import type { AgentTemplate } from '../../types/agent-template' -import type { - AgentRuntimeDeps, - AgentRuntimeScopedDeps, -} from '../../types/contracts/agent-runtime' -import type { GetUserInfoFromApiKeyInput, UserColumn } from '../../types/contracts/database' -import type { ClientEnv, CiEnv } from '../../types/contracts/env' -import type { Logger } from '../../types/contracts/logger' -import type { PrintModeEvent } from '../../types/print-mode' -import type { AgentState } from '../../types/session-state' import type { ProjectFileContext } from '../../util/file' -import type { ToolSet } from 'ai' -export const testLogger: Logger = { +export const mockFileContext: ProjectFileContext = { + projectRoot: '/test', + cwd: '/test', + fileTree: [], + fileTokenScores: {}, + knowledgeFiles: {}, + userKnowledgeFiles: {}, + agentTemplates: {}, + customToolDefinitions: {}, + gitChanges: { + status: '', + diff: '', + diffCached: '', + lastCommitMessages: '', + }, + changesSinceLastChat: {}, + shellConfigFiles: {}, + systemInfo: { + platform: 'test', + shell: 'test', + nodeVersion: 'test', + arch: 'test', + homedir: '/home/test', + cpus: 1, + chromeAvailable: false, + }, +} + +/** @deprecated Use mockFileContext */ +export const testFileContext: ProjectFileContext = mockFileContext + +export const testLogger = { debug: () => {}, error: () => {}, info: () => {}, warn: () => {}, } -export const testFetch = async () => { - throw new Error('fetch not implemented in test runtime') -} -testFetch.preconnect = async () => { - throw new Error('fetch.preconnect not implemented in test runtime') -} +export const testFetch = Object.assign( + async () => { + throw new Error('fetch not implemented in test runtime') + }, + { + preconnect: async () => { + throw new Error('fetch.preconnect not implemented in test runtime') + }, + }, +) -export const testClientEnv: ClientEnv = { - NEXT_PUBLIC_CB_ENVIRONMENT: 'test', +export const testClientEnv = { + NEXT_PUBLIC_CB_ENVIRONMENT: 'test' as const, NEXT_PUBLIC_CODEBUFF_APP_URL: 'https://test.codebuff.com', NEXT_PUBLIC_SUPPORT_EMAIL: 'support@codebuff.test', NEXT_PUBLIC_POSTHOG_API_KEY: 'test-posthog-key', @@ -46,7 +85,7 @@ export const testClientEnv: ClientEnv = { NEXT_PUBLIC_WEB_PORT: 3000, } -export const testCiEnv: CiEnv = { +export const testCiEnv = { CI: undefined, GITHUB_ACTIONS: undefined, RENDER: undefined, @@ -55,43 +94,42 @@ export const testCiEnv: CiEnv = { CODEBUFF_API_KEY: 'test-api-key', } -export const TEST_AGENT_RUNTIME_IMPL = Object.freeze< - AgentRuntimeDeps & AgentRuntimeScopedDeps ->({ - // Environment +/** @deprecated Use createTestAgentRuntimeParams() */ +export const TEST_AGENT_RUNTIME_IMPL = Object.freeze({ clientEnv: testClientEnv, ciEnv: testCiEnv, - - // Database - getUserInfoFromApiKey: async ({ + trackEvent: () => {}, + logger: testLogger, + fetch: testFetch, + getUserInfoFromApiKey: async ({ fields, - }: GetUserInfoFromApiKeyInput) => { + }: { + apiKey: string + fields: readonly T[] + }) => { const user = { id: 'test-user-id', - email: 'test-email', + email: 'test@example.com', discord_id: 'test-discord-id', - referral_code: 'ref-test-code', stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), } as const - - return Object.fromEntries(fields.map((field) => [field, user[field]])) as { - [K in T]: (typeof user)[K] + return Object.fromEntries( + fields.map((field) => [field, user[field as keyof typeof user]]), + ) as { + [K in T]: (typeof user)[K & keyof typeof user] } }, fetchAgentFromDatabase: async () => null, startAgentRun: async () => 'test-agent-run-id', finishAgentRun: async () => {}, addAgentStep: async () => 'test-agent-step-id', - - // Billing consumeCreditsWithFallback: async () => { throw new Error( 'consumeCreditsWithFallback not implemented in test runtime', ) }, - - // LLM promptAiSdkStream: async function* () { throw new Error('promptAiSdkStream not implemented in test runtime') }, @@ -101,20 +139,7 @@ export const TEST_AGENT_RUNTIME_IMPL = Object.freeze< promptAiSdkStructured: async function () { throw new Error('promptAiSdkStructured not implemented in test runtime') }, - - // Mutable State - databaseAgentCache: new Map(), - - // Analytics - trackEvent: () => {}, - - // Other - logger: testLogger, - fetch: testFetch, - - // Scoped deps - - // Database + databaseAgentCache: new Map(), handleStepsLogChunk: () => { throw new Error('handleStepsLogChunk not implemented in test runtime') }, @@ -136,147 +161,174 @@ export const TEST_AGENT_RUNTIME_IMPL = Object.freeze< sendAction: () => { throw new Error('sendAction not implemented in test runtime') }, - apiKey: 'test-api-key', }) -/** - * Mock file context for tests - */ -export const testFileContext: ProjectFileContext = { - projectRoot: '/test', - cwd: '/test', - fileTree: [], - fileTokenScores: {}, - knowledgeFiles: {}, - userKnowledgeFiles: {}, - agentTemplates: {}, - customToolDefinitions: {}, - gitChanges: { - status: '', - diff: '', - diffCached: '', - lastCommitMessages: '', - }, - changesSinceLastChat: {}, - shellConfigFiles: {}, - systemInfo: { - platform: 'test', - shell: 'test', - nodeVersion: 'test', - arch: 'test', - homedir: '/home/test', - cpus: 1, - }, -} - -/** - * Mock agent template for tests - */ -export const testAgentTemplate: AgentTemplate = { - id: 'test-agent', - displayName: 'Test Agent', - spawnerPrompt: 'Testing', - model: 'claude-3-5-sonnet-20241022', - inputSchema: {}, - outputMode: 'last_message', - includeMessageHistory: true, - inheritParentSystemPrompt: false, - mcpServers: {}, - toolNames: ['read_files', 'write_file', 'end_turn'], - spawnableAgents: [], - systemPrompt: 'Test system prompt', - instructionsPrompt: 'Test user prompt', - stepPrompt: 'Test agent step prompt', -} - -/** - * Extended test params that include all commonly needed properties for - * testing agent runtime functions like loopAgentSteps and handleSpawnAgents. - * - * This type extends AgentRuntimeDeps & AgentRuntimeScopedDeps with additional - * properties that are frequently required in tests. - */ -export type TestAgentRuntimeParams = AgentRuntimeDeps & - AgentRuntimeScopedDeps & { - // Identifiers - clientSessionId: string - fingerprintId: string - userInputId: string - userId: string | undefined - repoId: string | undefined - repoUrl: string | undefined - runId: string - - // Agent configuration - agentState: AgentState - agentTemplate: AgentTemplate - localAgentTemplates: Record - ancestorRunIds: string[] - - // Context - fileContext: ProjectFileContext - system: string - tools: ToolSet - prompt: string | undefined - spawnParams: Record | undefined - - // Control - signal: AbortSignal - previousToolCallFinished: Promise - - // Callbacks - onResponseChunk: (chunk: string | PrintModeEvent) => void - writeToClient: (chunk: string | PrintModeEvent) => void +export interface TestAgentRuntimeParams { + agentTemplate: { + id: string + displayName: string + model: string + inputSchema: Record + outputMode: string + includeMessageHistory: boolean + inheritParentSystemPrompt: boolean + mcpServers: Record + toolNames: string[] + spawnableAgents: string[] + systemPrompt: string + instructionsPrompt: string + stepPrompt: string } + localAgentTemplates: Record + sendAction: ReturnType + requestFiles: ReturnType + requestToolCall: ReturnType + onResponseChunk: ReturnType + fileContext: ProjectFileContext + promptAiSdkStream: ReturnType + promptAiSdk: ReturnType + promptAiSdkStructured: ReturnType + requestMcpToolData: ReturnType + startAgentRun: ReturnType + finishAgentRun: ReturnType + addAgentStep: ReturnType + logger: typeof testLogger + trackEvent: ReturnType + clientEnv: typeof testClientEnv + ciEnv: typeof testCiEnv + apiKey: string + fetch: typeof testFetch + fetchAgentFromDatabase: ReturnType + databaseAgentCache: Map + consumeCreditsWithFallback: ReturnType + getUserInfoFromApiKey: ReturnType + handleStepsLogChunk: ReturnType + requestOptionalFile: ReturnType + sendSubagentChunk: ReturnType +} -/** - * Creates a complete test params object that includes all commonly needed properties. - * Use this when calling functions like loopAgentSteps, handleSpawnAgents, etc. - * - * @param overrides - Optional overrides for any properties - * @returns Complete test params object - */ export function createTestAgentRuntimeParams( overrides: Partial = {}, ): TestAgentRuntimeParams { - const agentState = overrides.agentState ?? getInitialAgentState() - - return { - // Include all base runtime deps - ...TEST_AGENT_RUNTIME_IMPL, - - // Identifiers - clientSessionId: 'test-session', - fingerprintId: 'test-fingerprint', - userInputId: 'test-input', - userId: 'test-user', - repoId: undefined, - repoUrl: undefined, - runId: 'test-run-id', - - // Agent configuration - agentState, - agentTemplate: testAgentTemplate, - localAgentTemplates: { 'test-agent': testAgentTemplate }, - ancestorRunIds: [], - - // Context - fileContext: testFileContext, - system: 'Test system prompt', - tools: {}, - prompt: undefined, - spawnParams: undefined, - - // Control - signal: new AbortController().signal, - previousToolCallFinished: Promise.resolve(), + const defaultTemplate: TestAgentRuntimeParams['agentTemplate'] = { + id: 'test-agent', + displayName: 'Test Agent', + model: 'claude-3-5-sonnet-20241022', + inputSchema: {}, + outputMode: 'last_message', + includeMessageHistory: true, + inheritParentSystemPrompt: false, + mcpServers: {}, + toolNames: ['read_files', 'write_file', 'end_turn'], + spawnableAgents: [], + systemPrompt: 'You are a test agent.', + instructionsPrompt: 'Help the user with testing.', + stepPrompt: '', + } - // Callbacks - onResponseChunk: () => {}, - writeToClient: () => {}, + const agentTemplate = overrides.agentTemplate ?? defaultTemplate - // Apply overrides last + return { + agentTemplate, + localAgentTemplates: overrides.localAgentTemplates ?? { + 'test-agent': agentTemplate, + }, + sendAction: overrides.sendAction ?? mock(() => {}), + requestFiles: overrides.requestFiles ?? mock(async () => ({})), + requestToolCall: + overrides.requestToolCall ?? + mock(async () => ({ success: true, result: 'mock result' })), + onResponseChunk: overrides.onResponseChunk ?? mock(() => {}), + fileContext: overrides.fileContext ?? mockFileContext, + promptAiSdkStream: + overrides.promptAiSdkStream ?? + mock(async function* () { + yield { type: 'text' as const, text: 'Mock response\n\n' } + yield { + type: 'tool-call' as const, + toolName: 'end_turn', + toolCallId: 'mock-id', + input: {}, + } + return promptSuccess('mock-message-id') + }), + promptAiSdk: overrides.promptAiSdk ?? mock(async () => promptSuccess('Mock response')), + promptAiSdkStructured: + overrides.promptAiSdkStructured ?? mock(async () => promptSuccess({})), + requestMcpToolData: overrides.requestMcpToolData ?? mock(async () => ({})), + startAgentRun: overrides.startAgentRun ?? mock(async () => 'test-run-id'), + finishAgentRun: overrides.finishAgentRun ?? mock(async () => {}), + addAgentStep: overrides.addAgentStep ?? mock(async () => 'test-step-id'), + logger: overrides.logger ?? testLogger, + trackEvent: overrides.trackEvent ?? mock(() => {}), + clientEnv: overrides.clientEnv ?? testClientEnv, + ciEnv: overrides.ciEnv ?? testCiEnv, + apiKey: overrides.apiKey ?? 'test-api-key', + fetch: overrides.fetch ?? testFetch, + fetchAgentFromDatabase: + overrides.fetchAgentFromDatabase ?? mock(async () => null), + databaseAgentCache: overrides.databaseAgentCache ?? new Map(), + consumeCreditsWithFallback: + overrides.consumeCreditsWithFallback ?? mock(async () => {}), + getUserInfoFromApiKey: + overrides.getUserInfoFromApiKey ?? + mock(async () => ({ + id: 'test-user-id', + email: 'test@example.com', + })), + handleStepsLogChunk: overrides.handleStepsLogChunk ?? mock(() => {}), + requestOptionalFile: + overrides.requestOptionalFile ?? mock(async () => null), + sendSubagentChunk: overrides.sendSubagentChunk ?? mock(() => {}), ...overrides, } } + +export function createTestAgentRuntimeDeps(): Omit< + TestAgentRuntimeParams, + 'agentTemplate' | 'localAgentTemplates' +> { + return { + sendAction: mock(() => {}), + requestFiles: mock(async () => ({})), + requestToolCall: mock(async () => ({ + success: true, + result: 'mock result', + })), + onResponseChunk: mock(() => {}), + fileContext: mockFileContext, + promptAiSdkStream: mock(async function* () { + yield { type: 'text' as const, text: 'Mock response\n\n' } + yield { + type: 'tool-call' as const, + toolName: 'end_turn', + toolCallId: 'mock-id', + input: {}, + } + return promptSuccess('mock-message-id') + }), + promptAiSdk: mock(async () => promptSuccess('Mock response')), + promptAiSdkStructured: mock(async () => promptSuccess({})), + requestMcpToolData: mock(async () => ({})), + startAgentRun: mock(async () => 'test-run-id'), + finishAgentRun: mock(async () => {}), + addAgentStep: mock(async () => 'test-step-id'), + logger: testLogger, + trackEvent: mock(() => {}), + clientEnv: testClientEnv, + ciEnv: testCiEnv, + apiKey: 'test-api-key', + fetch: testFetch, + fetchAgentFromDatabase: mock(async () => null), + databaseAgentCache: new Map(), + consumeCreditsWithFallback: mock(async () => {}), + getUserInfoFromApiKey: mock(async () => ({ + id: 'test-user-id', + email: 'test@example.com', + })), + handleStepsLogChunk: mock(() => {}), + requestOptionalFile: mock(async () => null), + sendSubagentChunk: mock(() => {}), + } +} diff --git a/common/src/testing/index.ts b/common/src/testing/index.ts new file mode 100644 index 0000000000..18892c2b46 --- /dev/null +++ b/common/src/testing/index.ts @@ -0,0 +1,84 @@ +/** + * Consolidated testing utilities for Codebuff. + * + * This module re-exports all testing utilities from a single entry point, + * making it easy to import everything you need for testing. + * + * ## Module Overview + * + * - **mocks**: Typed mock factories for logger, analytics, database, crypto, and streams + * - **fixtures**: Pre-built test fixtures for agent runtime and other components + * - **errors**: Typed error creators for testing error handling + * - **mock-modules**: Dynamic module mocking utilities + * - **env**: Test environment helpers + * + * @example + * ```typescript + * import { + * // Mock factories + * createMockLogger, + * createMockAnalytics, + * setupDbSpies, + * setupCryptoMocks, + * + * // Fixtures + * createTestAgentRuntimeParams, + * + * // Errors + * createNodeError, + * + * // Module mocking + * mockModule, + * clearMockedModules, + * } from '@codebuff/common/testing' + * ``` + * + * @module testing + */ + +// ============================================================================ +// Mock Factories +// ============================================================================ + +export * from './mocks' + +// ============================================================================ +// Fixtures +// ============================================================================ + +export { + createTestAgentRuntimeParams, + createTestAgentRuntimeDeps, + mockFileContext, +} from './fixtures/agent-runtime' +export type { TestAgentRuntimeParams } from './fixtures/agent-runtime' + +// ============================================================================ +// Error Utilities +// ============================================================================ + +export { createNodeError, createPostgresError } from './errors' +export type { NodeError, PostgresError } from './errors' + +// ============================================================================ +// Module Mocking +// ============================================================================ + +export { mockModule, clearMockedModules } from './mock-modules' + +// ============================================================================ +// Test Setup Utilities +// ============================================================================ + +export { createTestSetup, sleep, waitFor, captureCallArgs } from './setup' +export type { CreateTestSetupOptions, TestSetupResult } from './setup' + +// ============================================================================ +// Environment Helpers (re-exported from sibling modules) +// ============================================================================ + +// Note: These are in separate files for historical reasons but are commonly +// used together with other testing utilities. +// Import directly from their modules if you need only env helpers: +// - '@codebuff/common/testing-env-process' for process env +// - '@codebuff/common/testing-env-ci' for CI env diff --git a/common/src/testing/mock-types.ts b/common/src/testing/mock-types.ts new file mode 100644 index 0000000000..f41147ee58 --- /dev/null +++ b/common/src/testing/mock-types.ts @@ -0,0 +1,123 @@ +/** + * Shared mock types for testing. + * + * This module provides common mock types and factory functions that are + * frequently used across test files. Using these shared types ensures + * consistency and reduces duplication in test code. + * + * @example + * ```typescript + * import { + * createMockLogger, + * type MockUserInfo, + * type MockCreditResult, + * } from '@codebuff/common/testing/mock-types' + * + * const logger = createMockLogger() + * const userInfo: MockUserInfo = { id: 'user-123' } + * ``` + */ + +import { mock } from 'bun:test' + +import type { Logger } from '../types/contracts/logger' + +/** + * Mock user info returned by API key lookup functions. + * Contains the minimal user identification data needed for testing. + */ +export interface MockUserInfo { + id: string +} + +/** + * Mock result from credit consumption operations. + * Used when testing billing-related functionality. + */ +export interface MockCreditResult { + success: boolean + value: { chargedToOrganization: boolean } +} + +/** + * Mock file stat result for filesystem operations. + * Provides typed methods for checking file type. + */ +export interface MockStatResult { + isDirectory: () => boolean + isFile: () => boolean +} + +/** + * Typed mock logger where each method is a Bun test mock. + * Useful for verifying that specific log methods were called. + */ +export type MockLogger = { + [K in keyof Logger]: ReturnType & Logger[K] +} + +/** + * Creates a mock logger with all methods as Bun test mocks. + * Each method can be inspected for calls using mock.calls. + * + * @example + * ```typescript + * const logger = createMockLogger() + * someFunction({ logger }) + * expect(logger.error.mock.calls.length).toBe(1) + * ``` + */ +export function createMockLogger(): MockLogger { + return { + info: mock(() => {}) as ReturnType & Logger['info'], + error: mock(() => {}) as ReturnType & Logger['error'], + warn: mock(() => {}) as ReturnType & Logger['warn'], + debug: mock(() => {}) as ReturnType & Logger['debug'], + } +} + +/** + * Creates a mock stat result for filesystem testing. + * + * @param options - Configure whether the mock represents a directory or file + * @returns A MockStatResult with the specified behavior + * + * @example + * ```typescript + * const dirStat = createMockStatResult({ isDirectory: true }) + * const fileStat = createMockStatResult({ isFile: true }) + * ``` + */ +export function createMockStatResult(options: { + isDirectory?: boolean + isFile?: boolean +}): MockStatResult { + return { + isDirectory: () => options.isDirectory ?? false, + isFile: () => options.isFile ?? false, + } +} + +/** + * Creates a mock credit result for billing-related tests. + * + * @param options - Configure the success state and organization charging + * @returns A MockCreditResult with the specified values + * + * @example + * ```typescript + * const successResult = createMockCreditResult({ success: true }) + * const orgResult = createMockCreditResult({ success: true, chargedToOrganization: true }) + * ``` + */ +export function createMockCreditResult( + options: { + success?: boolean + chargedToOrganization?: boolean + } = {}, +): MockCreditResult { + return { + success: options.success ?? true, + value: { chargedToOrganization: options.chargedToOrganization ?? false }, + } +} diff --git a/common/src/testing/mocks/analytics.ts b/common/src/testing/mocks/analytics.ts new file mode 100644 index 0000000000..a9c2a6d693 --- /dev/null +++ b/common/src/testing/mocks/analytics.ts @@ -0,0 +1,261 @@ +/** + * Typed analytics mock factory for testing. + * + * Provides type-safe mocks for analytics functions used throughout the codebase. + * Helps avoid the need for `as any` casts when mocking analytics in tests. + * + * @example + * ```typescript + * import { createMockAnalytics, setupAnalyticsMocks } from '@codebuff/common/testing/mocks/analytics' + * + * // Option 1: Create mock object + * const analytics = createMockAnalytics() + * someFunction({ trackEvent: analytics.trackEvent }) + * + * // Option 2: Setup spies on actual module + * const spies = setupAnalyticsMocks() + * await runTest() + * expect(spies.trackEvent).toHaveBeenCalledWith('event_name', { prop: 'value' }) + * spies.restore() + * ``` + */ + +import { mock, spyOn } from 'bun:test' + +import type { Mock } from 'bun:test' + +/** + * Properties that can be tracked with an event. + */ +export type EventProperties = Record + +/** + * Signature for the trackEvent function. + */ +export type TrackEventFn = ( + eventName: string, + properties?: EventProperties, +) => void + +/** + * Signature for the flushAnalytics function. + */ +export type FlushAnalyticsFn = () => Promise + +/** + * Signature for the identifyUser function. + */ +export type IdentifyUserFn = ( + userId: string, + traits?: Record, +) => void + +/** + * Interface for the complete mock analytics object. + */ +export interface MockAnalytics { + /** Track a named event with optional properties */ + trackEvent: Mock + /** Flush pending analytics events */ + flushAnalytics: Mock + /** Identify a user with optional traits */ + identifyUser: Mock +} + +/** + * Tracked event entry for inspection. + */ +export interface TrackedEvent { + name: string + properties?: EventProperties + timestamp: Date +} + +/** + * Options for creating mock analytics. + */ +export interface CreateMockAnalyticsOptions { + /** + * Whether to capture tracked events for later inspection. + * @default false + */ + captureEvents?: boolean +} + +/** + * Creates a type-safe mock analytics object for testing. + * + * @param options - Configuration options + * @returns A mock analytics object with all methods as tracked mocks + * + * @example + * ```typescript + * const analytics = createMockAnalytics() + * + * // Pass to function under test + * await processPayment({ analytics }) + * + * // Verify events were tracked + * expect(analytics.trackEvent).toHaveBeenCalledWith('payment_processed', { + * amount: 100, + * currency: 'USD', + * }) + * ``` + */ +export function createMockAnalytics( + options: CreateMockAnalyticsOptions = {}, +): MockAnalytics { + return { + trackEvent: mock(() => {}), + flushAnalytics: mock(async () => {}), + identifyUser: mock(() => {}), + } +} + +/** + * Result of creating mock analytics with event capture. + */ +export interface MockAnalyticsWithCapture { + /** The mock analytics object */ + analytics: MockAnalytics + /** Array of all tracked events */ + events: TrackedEvent[] + /** Clear all captured events */ + clearEvents: () => void + /** Get events by name */ + getEventsByName: (name: string) => TrackedEvent[] + /** Check if an event was tracked */ + hasEvent: (name: string) => boolean + /** Get the last event tracked */ + getLastEvent: () => TrackedEvent | undefined +} + +/** + * Creates mock analytics that captures all tracked events for inspection. + * + * @returns An object containing the analytics mock and utilities for inspection + * + * @example + * ```typescript + * const { analytics, events, getEventsByName } = createMockAnalyticsWithCapture() + * + * await runUserFlow({ analytics }) + * + * // Check events were tracked in order + * expect(events.map(e => e.name)).toEqual([ + * 'flow_started', + * 'step_completed', + * 'flow_finished', + * ]) + * + * // Check specific event properties + * const completionEvents = getEventsByName('step_completed') + * expect(completionEvents[0].properties).toMatchObject({ stepId: 'step1' }) + * ``` + */ +export function createMockAnalyticsWithCapture(): MockAnalyticsWithCapture { + const events: TrackedEvent[] = [] + + const analytics: MockAnalytics = { + trackEvent: mock((name: string, properties?: EventProperties) => { + events.push({ + name, + properties, + timestamp: new Date(), + }) + }), + flushAnalytics: mock(async () => {}), + identifyUser: mock(() => {}), + } + + return { + analytics, + events, + clearEvents: () => { + events.length = 0 + }, + getEventsByName: (name: string) => events.filter((e) => e.name === name), + hasEvent: (name: string) => events.some((e) => e.name === name), + getLastEvent: () => events[events.length - 1], + } +} + +/** + * Result of setting up analytics spies on the actual module. + */ +export interface AnalyticsSpies { + /** Spy on trackEvent */ + trackEvent: ReturnType + /** Spy on flushAnalytics */ + flushAnalytics: ReturnType + /** Restore all spies */ + restore: () => void + /** Clear all spy call history */ + clear: () => void +} + +/** + * Sets up spies on the analytics module. + * Use this when you need to spy on the actual module rather than inject a mock. + * + * @param analyticsModule - The analytics module to spy on + * @returns Object containing the spies and cleanup utilities + * + * @example + * ```typescript + * import * as analytics from '@codebuff/common/analytics' + * + * describe('my test', () => { + * let analyticsSpy: AnalyticsSpies + * + * beforeEach(() => { + * analyticsSpy = setupAnalyticsMocks(analytics) + * }) + * + * afterEach(() => { + * analyticsSpy.restore() + * }) + * + * it('tracks the event', async () => { + * await doSomething() + * expect(analyticsSpy.trackEvent).toHaveBeenCalledWith('something_done') + * }) + * }) + * ``` + */ +export function setupAnalyticsMocks(analyticsModule: { + trackEvent: TrackEventFn + flushAnalytics: FlushAnalyticsFn +}): AnalyticsSpies { + const trackEventSpy = spyOn(analyticsModule, 'trackEvent').mockImplementation( + () => {}, + ) + const flushAnalyticsSpy = spyOn( + analyticsModule, + 'flushAnalytics', + ).mockImplementation(async () => {}) + + return { + trackEvent: trackEventSpy, + flushAnalytics: flushAnalyticsSpy, + restore: () => { + trackEventSpy.mockRestore() + flushAnalyticsSpy.mockRestore() + }, + clear: () => { + trackEventSpy.mockClear() + flushAnalyticsSpy.mockClear() + }, + } +} + +/** + * Restores all mock methods on an analytics object. + * + * @param analytics - The mock analytics to restore + */ +export function restoreMockAnalytics(analytics: MockAnalytics): void { + analytics.trackEvent.mockRestore() + analytics.flushAnalytics.mockRestore() + analytics.identifyUser.mockRestore() +} diff --git a/common/src/testing/mocks/child-process.ts b/common/src/testing/mocks/child-process.ts new file mode 100644 index 0000000000..d80f13d68f --- /dev/null +++ b/common/src/testing/mocks/child-process.ts @@ -0,0 +1,93 @@ +/** Typed child process mock for testing code that spawns processes. */ + +import { EventEmitter } from 'events' + +import { mock } from 'bun:test' + +import type { Mock } from 'bun:test' +import type { ChildProcess } from 'child_process' + +/** Mock child process with typed stdout/stderr EventEmitters. */ +export interface MockChildProcess extends EventEmitter { + stdout: EventEmitter + stderr: EventEmitter + pid: number + killed: boolean + kill: Mock<(signal?: string) => boolean> +} + +/** Creates a typed mock child process with EventEmitter-based stdout/stderr. */ +export function createMockChildProcess(): MockChildProcess { + const mockProcess = new EventEmitter() as MockChildProcess + mockProcess.stdout = new EventEmitter() + mockProcess.stderr = new EventEmitter() + mockProcess.pid = Math.floor(Math.random() * 10000) + mockProcess.killed = false + mockProcess.kill = mock((signal?: string) => { + mockProcess.killed = true + mockProcess.emit('close', signal === 'SIGKILL' ? 137 : 0) + return true + }) + return mockProcess +} + +/** Result type for code search tool output. */ +export interface CodeSearchResult { + stdout?: string + stderr?: string + message?: string + errorMessage?: string +} + +/** Typed accessor for code search result value. */ +export function asCodeSearchResult(result: unknown): CodeSearchResult { + if ( + result && + typeof result === 'object' && + 'type' in result && + result.type === 'json' && + 'value' in result + ) { + return result.value as CodeSearchResult + } + return {} +} + +/** Creates a mock spawn function that returns the provided mock process. */ +export function createMockSpawn( + mockProcess: MockChildProcess, +): Mock<(command: string, args: string[], options?: object) => ChildProcess> { + return mock(() => mockProcess as unknown as ChildProcess) +} + +/** Helper to create ripgrep JSON match output. */ +export function createRgJsonMatch( + filePath: string, + lineNumber: number, + lineText: string, +): string { + return JSON.stringify({ + type: 'match', + data: { + path: { text: filePath }, + lines: { text: lineText }, + line_number: lineNumber, + }, + }) +} + +/** Helper to create ripgrep JSON context output (for -A, -B, -C flags). */ +export function createRgJsonContext( + filePath: string, + lineNumber: number, + lineText: string, +): string { + return JSON.stringify({ + type: 'context', + data: { + path: { text: filePath }, + lines: { text: lineText }, + line_number: lineNumber, + }, + }) +} diff --git a/common/src/testing/mocks/crypto.ts b/common/src/testing/mocks/crypto.ts new file mode 100644 index 0000000000..a5dbb972bb --- /dev/null +++ b/common/src/testing/mocks/crypto.ts @@ -0,0 +1,218 @@ +/** + * Typed crypto mock factory for testing. + * + * Provides type-safe mocks for crypto functions, particularly randomUUID. + * Makes tests deterministic by returning predictable UUIDs. + * + * @example + * ```typescript + * import { setupCryptoMocks, createMockUuid } from '@codebuff/common/testing/mocks/crypto' + * + * // Setup deterministic UUIDs + * const spies = setupCryptoMocks() + * await runTest() + * spies.restore() + * + * // Or create specific UUIDs + * const uuid = createMockUuid('test-prefix') + * // Returns: 'test-prefix-0000-0000-0000-000000000000' + * ``` + */ + +import { spyOn } from 'bun:test' + +/** + * A valid UUID v4 format string. + */ +export type UUID = `${string}-${string}-${string}-${string}-${string}` + +/** + * Options for setting up crypto mocks. + */ +export interface SetupCryptoMocksOptions { + /** + * A prefix to use for generated UUIDs. + * The format will be: `{prefix}-0000-0000-0000-000000000000` + * @default 'mock-uuid' + */ + prefix?: string + + /** + * Whether to generate sequential UUIDs. + * If true, each call returns a different UUID: mock-uuid-1, mock-uuid-2, etc. + * @default false + */ + sequential?: boolean + + /** + * A specific list of UUIDs to return in order. + * If provided, UUIDs are returned from this list in sequence. + * When exhausted, falls back to default behavior. + */ + uuids?: UUID[] +} + +/** + * Result of setting up crypto mocks. + */ +export interface CryptoMockSpies { + /** The spy on randomUUID */ + randomUUID: ReturnType + /** Restore the original implementation */ + restore: () => void + /** Clear call history */ + clear: () => void + /** Get the current call count */ + getCallCount: () => number +} + +/** + * Creates a deterministic mock UUID with a given prefix. + * + * @param prefix - The prefix for the UUID + * @param index - Optional index for sequential UUIDs + * @returns A valid UUID-format string + * + * @example + * ```typescript + * createMockUuid('test') + * // Returns: 'test-uuid-0000-0000-000000000000' + * + * createMockUuid('test', 5) + * // Returns: 'test-uuid-0000-0005-000000000000' + * ``` + */ +export function createMockUuid(prefix: string, index?: number): UUID { + const indexStr = + index !== undefined ? String(index).padStart(12, '0') : '000000000000' + return `${prefix}-0000-0000-0000-${indexStr}` as UUID +} + +/** + * Sets up a spy on crypto.randomUUID with deterministic behavior. + * + * @param options - Configuration options + * @returns Object containing the spy and cleanup utilities + * + * @example + * ```typescript + * describe('my test', () => { + * let cryptoSpies: CryptoMockSpies + * + * beforeEach(() => { + * cryptoSpies = setupCryptoMocks({ prefix: 'test' }) + * }) + * + * afterEach(() => { + * cryptoSpies.restore() + * }) + * + * it('creates deterministic IDs', async () => { + * const result = await createSomething() + * expect(result.id).toBe('test-0000-0000-0000-000000000000') + * }) + * }) + * ``` + */ +export function setupCryptoMocks( + options: SetupCryptoMocksOptions = {}, +): CryptoMockSpies { + const { prefix = 'mock-uuid', sequential = false, uuids = [] } = options + + let callCount = 0 + + const randomUUIDSpy = spyOn(crypto, 'randomUUID').mockImplementation(() => { + const currentIndex = callCount + callCount++ + + // First try to return from the provided list + if (currentIndex < uuids.length) { + return uuids[currentIndex] + } + + // Then fall back to generated UUIDs + if (sequential) { + return createMockUuid(prefix, currentIndex) + } + + return createMockUuid(prefix) + }) + + return { + randomUUID: randomUUIDSpy, + restore: () => { + randomUUIDSpy.mockRestore() + }, + clear: () => { + callCount = 0 + randomUUIDSpy.mockClear() + }, + getCallCount: () => callCount, + } +} + +/** + * Sets up crypto mocks that return specific UUIDs in sequence. + * Useful when you need specific IDs for assertions. + * + * @param uuids - The UUIDs to return in order + * @returns Object containing the spy and cleanup utilities + * + * @example + * ```typescript + * const spies = setupSequentialCryptoMocks([ + * 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + * 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + * ]) + * + * crypto.randomUUID() // 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + * crypto.randomUUID() // 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' + * crypto.randomUUID() // 'mock-uuid-0000-0000-0000-000000000002' (fallback) + * ``` + */ +export function setupSequentialCryptoMocks(uuids: UUID[]): CryptoMockSpies { + return setupCryptoMocks({ uuids, sequential: true }) +} + +/** + * A set of commonly used test UUIDs for consistency across tests. + */ +export const TEST_UUIDS = { + /** Default user ID for tests */ + USER: 'test-user-0000-0000-000000000001' as UUID, + /** Default session ID for tests */ + SESSION: 'test-sess-0000-0000-000000000001' as UUID, + /** Default run ID for tests */ + RUN: 'test-run0-0000-0000-000000000001' as UUID, + /** Default step ID for tests */ + STEP: 'test-step-0000-0000-000000000001' as UUID, + /** Default message ID for tests */ + MESSAGE: 'test-msg0-0000-0000-000000000001' as UUID, + /** Default agent ID for tests */ + AGENT: 'test-agnt-0000-0000-000000000001' as UUID, +} as const + +/** + * Creates a UUID generator that returns sequential UUIDs with a prefix. + * Useful for generating multiple related IDs. + * + * @param prefix - The prefix for generated UUIDs + * @returns A function that generates sequential UUIDs + * + * @example + * ```typescript + * const generateId = createUuidGenerator('item') + * + * generateId() // 'item-uuid-0000-0000-000000000000' + * generateId() // 'item-uuid-0000-0001-000000000000' + * generateId() // 'item-uuid-0000-0002-000000000000' + * ``` + */ +export function createUuidGenerator(prefix: string): () => UUID { + let index = 0 + return () => { + const uuid = createMockUuid(prefix, index) + index++ + return uuid + } +} diff --git a/common/src/testing/mocks/database.ts b/common/src/testing/mocks/database.ts new file mode 100644 index 0000000000..c78353b2c8 --- /dev/null +++ b/common/src/testing/mocks/database.ts @@ -0,0 +1,337 @@ +/** + * Typed database mock factory for testing. + * + * Provides type-safe mocks for Drizzle database operations used throughout the codebase. + * Replaces the need for `as any` casts when setting up database spies. + * + * @example + * ```typescript + * import { createMockDbOperations, setupDbSpies } from '@codebuff/common/testing/mocks/database' + * + * // Option 1: Create mock operations object + * const dbOps = createMockDbOperations() + * + * // Option 2: Setup spies on actual db module + * const spies = setupDbSpies(db) + * await runTest() + * spies.restore() + * ``` + */ + +import { mock, spyOn } from 'bun:test' + +import type { Mock } from 'bun:test' + +/** + * Type for the chainable insert result. + */ +export interface MockInsertResult { + values: Mock<(data: T | T[]) => Promise<{ id: string }>> + returning: Mock<() => Promise> + onConflictDoNothing: Mock<() => MockInsertResult> + onConflictDoUpdate: Mock< + (config: { target: unknown; set: unknown }) => MockInsertResult + > +} + +/** + * Type for the chainable update result. + */ +export interface MockUpdateResult { + set: Mock<(data: Partial) => MockUpdateSetResult> +} + +/** + * Type for the update.set result. + */ +export interface MockUpdateSetResult { + where: Mock<(condition: unknown) => Promise> + returning: Mock<() => Promise> +} + +/** + * Type for the chainable select result. + */ +export interface MockSelectResult { + from: Mock<(table: unknown) => MockSelectFromResult> +} + +/** + * Type for the select.from result. + */ +export interface MockSelectFromResult { + where: Mock<(condition: unknown) => MockSelectWhereResult> + leftJoin: Mock< + (table: unknown, condition: unknown) => MockSelectFromResult + > + innerJoin: Mock< + (table: unknown, condition: unknown) => MockSelectFromResult + > + orderBy: Mock<(...columns: unknown[]) => MockSelectFromResult> + limit: Mock<(n: number) => MockSelectFromResult> + offset: Mock<(n: number) => MockSelectFromResult> + then: Mock<(resolve: (value: T[]) => void) => Promise> +} + +/** + * Type for the select.from.where result. + */ +export interface MockSelectWhereResult { + then: Mock<(resolve: (value: T[]) => void) => Promise> + leftJoin: Mock< + (table: unknown, condition: unknown) => MockSelectWhereResult + > + innerJoin: Mock< + (table: unknown, condition: unknown) => MockSelectWhereResult + > + orderBy: Mock<(...columns: unknown[]) => MockSelectWhereResult> + limit: Mock<(n: number) => MockSelectWhereResult> + offset: Mock<(n: number) => MockSelectWhereResult> +} + +/** + * Type for the chainable delete result. + */ +export interface MockDeleteResult { + where: Mock<(condition: unknown) => Promise> +} + +/** + * Interface for the complete mock database operations. + */ +export interface MockDbOperations { + insert: Mock<(table: unknown) => MockInsertResult> + update: Mock<(table: unknown) => MockUpdateResult> + select: Mock<(columns?: unknown) => MockSelectResult> + delete: Mock<(table: unknown) => MockDeleteResult> + transaction: Mock<(fn: (tx: MockDbOperations) => Promise) => Promise> +} + +/** + * Options for creating mock database operations. + */ +export interface CreateMockDbOptions { + /** + * Default data to return from select queries. + */ + defaultSelectData?: unknown[] + + /** + * Default ID to return from insert operations. + */ + defaultInsertId?: string +} + +/** + * Creates type-safe mock database operations for testing. + * + * @param options - Configuration options for the mock + * @returns A mock database operations object + * + * @example + * ```typescript + * const dbOps = createMockDbOperations({ + * defaultSelectData: [{ id: '1', name: 'Test' }], + * defaultInsertId: 'new-id', + * }) + * + * // The mocks are chainable just like real Drizzle + * await dbOps.insert(users).values({ name: 'Test' }) + * await dbOps.select().from(users).where(eq(users.id, '1')) + * ``` + */ +export function createMockDbOperations( + options: CreateMockDbOptions = {}, +): MockDbOperations { + const { defaultSelectData = [], defaultInsertId = 'mock-id' } = options + + const createMockSelectWhereResult = ( + data: T[] = defaultSelectData as T[], + ): MockSelectWhereResult => { + const result: MockSelectWhereResult = { + then: mock((resolve) => { + resolve(data) + return Promise.resolve(data) + }), + leftJoin: mock(() => result), + innerJoin: mock(() => result), + orderBy: mock(() => result), + limit: mock(() => result), + offset: mock(() => result), + } + return result + } + + const createMockSelectFromResult = ( + data: T[] = defaultSelectData as T[], + ): MockSelectFromResult => { + const whereResult = createMockSelectWhereResult(data) + const result: MockSelectFromResult = { + where: mock(() => whereResult), + leftJoin: mock(() => result), + innerJoin: mock(() => result), + orderBy: mock(() => result), + limit: mock(() => result), + offset: mock(() => result), + then: mock((resolve) => { + resolve(data) + return Promise.resolve(data) + }), + } + return result + } + + const createMockInsertResult = (): MockInsertResult => { + const result: MockInsertResult = { + values: mock(() => Promise.resolve({ id: defaultInsertId })), + returning: mock(() => Promise.resolve([])), + onConflictDoNothing: mock(() => result), + onConflictDoUpdate: mock(() => result), + } + return result + } + + const createMockUpdateSetResult = (): MockUpdateSetResult => ({ + where: mock(() => Promise.resolve()), + returning: mock(() => Promise.resolve([])), + }) + + const createMockUpdateResult = (): MockUpdateResult => ({ + set: mock(() => createMockUpdateSetResult()), + }) + + const createMockDeleteResult = (): MockDeleteResult => ({ + where: mock(() => Promise.resolve()), + }) + + const dbOps: MockDbOperations = { + insert: mock(() => createMockInsertResult()), + update: mock(() => createMockUpdateResult()), + select: mock(() => ({ + from: mock(() => createMockSelectFromResult()), + })), + delete: mock(() => createMockDeleteResult()), + transaction: mock(async (fn) => fn(dbOps)), + } + + return dbOps +} + +/** + * Result of setting up database spies. + */ +export interface DbSpies { + /** Spy on insert operations */ + insert: ReturnType + /** Spy on update operations */ + update: ReturnType + /** Restore all spies */ + restore: () => void + /** Clear all spy call history */ + clear: () => void +} + +/** + * Sets up spies on a database module for insert and update operations. + * This is the most common pattern used in tests. + * + * @param db - The database module to spy on + * @param options - Configuration options + * @returns Object containing the spies and cleanup utilities + * + * @example + * ```typescript + * import db from '@codebuff/internal/db' + * + * describe('my test', () => { + * let dbSpies: DbSpies + * + * beforeEach(() => { + * dbSpies = setupDbSpies(db) + * }) + * + * afterEach(() => { + * dbSpies.restore() + * }) + * + * it('inserts data', async () => { + * await createUser({ name: 'Test' }) + * expect(dbSpies.insert).toHaveBeenCalled() + * }) + * }) + * ``` + */ + +/** + * Sets up spies on a database module for insert and update operations. + * Accepts any object with insert and update methods. + */ +export function setupDbSpies( + db: { insert: unknown; update: unknown }, + options: CreateMockDbOptions = {}, +): DbSpies { + const { defaultInsertId = 'test-run-id' } = options + + const mockInsertResult = { + values: mock(() => Promise.resolve({ id: defaultInsertId })), + } + + const mockUpdateResult = { + set: mock(() => ({ + where: mock(() => Promise.resolve()), + })), + } + + // Cast db to a spyable type - the actual db module has complex types that + // don't play well with spyOn's inference, but the spy still works at runtime + const spyableDb = db as { insert: () => unknown; update: () => unknown } + const insertSpy = spyOn(spyableDb, 'insert').mockReturnValue(mockInsertResult) + const updateSpy = spyOn(spyableDb, 'update').mockReturnValue(mockUpdateResult) + + return { + insert: insertSpy, + update: updateSpy, + restore: () => { + insertSpy.mockRestore() + updateSpy.mockRestore() + }, + clear: () => { + insertSpy.mockClear() + updateSpy.mockClear() + }, + } +} + +/** + * Creates a mock for a database query builder chain that returns specific data. + * + * @param data - The data to return from the query + * @returns A thenable mock that resolves to the data + * + * @example + * ```typescript + * const mockQuery = createMockQueryResult([ + * { id: '1', name: 'User 1' }, + * { id: '2', name: 'User 2' }, + * ]) + * + * spyOn(userService, 'findAll').mockReturnValue(mockQuery) + * ``` + */ +export function createMockQueryResult(data: T[]): Promise & { + where: Mock<() => Promise> + orderBy: Mock<() => Promise> + limit: Mock<() => Promise> +} { + const promise = Promise.resolve(data) as Promise & { + where: Mock<() => Promise> + orderBy: Mock<() => Promise> + limit: Mock<() => Promise> + } + + promise.where = mock(() => promise) + promise.orderBy = mock(() => promise) + promise.limit = mock(() => promise) + + return promise +} diff --git a/common/src/testing/mocks/fetch.ts b/common/src/testing/mocks/fetch.ts new file mode 100644 index 0000000000..ee18764d27 --- /dev/null +++ b/common/src/testing/mocks/fetch.ts @@ -0,0 +1,219 @@ +/** Typed fetch mock utilities for testing. */ + +import { mock } from 'bun:test' + +import type { Mock } from 'bun:test' + +export interface MockResponseOptions { + status?: number + statusText?: string + headers?: HeadersInit +} + +export type MockFetch = Mock + +export interface InstallMockFetchResult { + mockFetch: MockFetch + restore: () => void + getCalls: () => MockFetchCall[] + clear: () => void +} + +export interface MockFetchCall { + url: string | URL | Request + init?: RequestInit + jsonBody?: unknown +} + +export interface CreateMockFetchOptions { + defaultImpl?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Promise +} + +/** Creates a Response with JSON body. */ +export function mockJsonResponse( + data: unknown, + options: MockResponseOptions = {}, +): Response { + const { status = 200, statusText, headers = {} } = options + + return new Response(JSON.stringify(data), { + status, + statusText, + headers: { + 'Content-Type': 'application/json', + ...normalizeHeaders(headers), + }, + }) +} + +/** Creates a Response with text body. */ +export function mockTextResponse( + text: string, + options: MockResponseOptions = {}, +): Response { + const { status = 200, statusText, headers = {} } = options + + return new Response(text, { + status, + statusText, + headers: { + 'Content-Type': 'text/plain', + ...normalizeHeaders(headers), + }, + }) +} + +/** Creates an error Response with default status text. */ +export function mockErrorResponse( + status: number, + body?: string | object, + options: Omit = {}, +): Response { + const { statusText, headers = {} } = options + + let responseBody: string + let contentType: string + + if (body === undefined) { + responseBody = '' + contentType = 'text/plain' + } else if (typeof body === 'string') { + responseBody = body + contentType = 'text/plain' + } else { + responseBody = JSON.stringify(body) + contentType = 'application/json' + } + + return new Response(responseBody, { + status, + statusText: statusText ?? getDefaultStatusText(status), + headers: { + 'Content-Type': contentType, + ...normalizeHeaders(headers), + }, + }) +} + +/** Creates a mock fetch function. */ +export function createMockFetch( + options: CreateMockFetchOptions = {}, +): MockFetch { + const { defaultImpl } = options + + const baseFn = + defaultImpl ?? + (async (): Promise => { + throw new Error('Mock fetch not configured for this call') + }) + + const mockFn = Object.assign(mock(baseFn), { + preconnect: mock(async () => {}), + }) as unknown as MockFetch + + return mockFn +} + +/** + * Installs mock fetch globally. Returns mockFetch for configuration - + * the wrapper always captures calls before delegating to mockFetch. + */ +export function installMockFetch( + options: CreateMockFetchOptions = {}, +): InstallMockFetchResult { + const originalFetch = globalThis.fetch + const capturedCalls: MockFetchCall[] = [] + + const mockFetch = createMockFetch({ + defaultImpl: + options.defaultImpl ?? + (async (): Promise => { + throw new Error('Mock fetch not configured for this call') + }), + }) + + // Wrap to capture calls + const wrappedMockFn = mock( + async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const call: MockFetchCall = { + url: input, + init, + } + + // Try to parse JSON body if present + if (init?.body && typeof init.body === 'string') { + try { + call.jsonBody = JSON.parse(init.body) + } catch { + // Not JSON, that's fine + } + } + + capturedCalls.push(call) + + // Call the actual mock implementation + return mockFetch(input, init) + }, + ) + + const wrappedMock = Object.assign(wrappedMockFn, { + preconnect: mock(async () => {}), + }) as unknown as MockFetch + + ;(globalThis as any).fetch = wrappedMock + + return { + mockFetch, + restore: () => { + globalThis.fetch = originalFetch + }, + getCalls: () => [...capturedCalls], + clear: () => { + capturedCalls.length = 0 + mockFetch.mockClear() + wrappedMock.mockClear() + }, + } +} + +function normalizeHeaders(headers: HeadersInit): Record { + if (headers instanceof Headers) { + const result: Record = {} + headers.forEach((value, key) => { + result[key] = value + }) + return result + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers) + } + + return headers as Record +} + +function getDefaultStatusText(status: number): string { + const statusTexts: Record = { + 200: 'OK', + 201: 'Created', + 204: 'No Content', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 409: 'Conflict', + 422: 'Unprocessable Entity', + 429: 'Too Many Requests', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + } + + return statusTexts[status] ?? '' +} diff --git a/common/src/testing/mocks/filesystem.ts b/common/src/testing/mocks/filesystem.ts new file mode 100644 index 0000000000..6c9703622e --- /dev/null +++ b/common/src/testing/mocks/filesystem.ts @@ -0,0 +1,166 @@ +import { mock } from 'bun:test' + +import type { CodebuffFileSystem } from '../../types/filesystem' +import type { Mock } from 'bun:test' +import type { PathLike , Stats } from 'node:fs' + +export interface CreateMockFsOptions { + files?: Record + directories?: Record + readFileImpl?: (path: string) => Promise + readdirImpl?: (path: string) => Promise + writeFileImpl?: (path: string, content: string) => Promise + mkdirImpl?: ( + path: string, + options?: { recursive?: boolean }, + ) => Promise + statImpl?: (path: string) => Promise +} + +export interface MockFs extends CodebuffFileSystem {} + +export interface MockFsWithMocks { + readFile: Mock< + (path: PathLike, options?: { encoding?: BufferEncoding }) => Promise + > + readdir: Mock<(path: PathLike) => Promise> + writeFile: Mock<(path: PathLike, data: string) => Promise> + mkdir: Mock< + ( + path: PathLike, + options?: { recursive?: boolean }, + ) => Promise + > + stat: Mock<(path: PathLike) => Promise> +} + +/** Creates a mock filesystem compatible with CodebuffFileSystem. */ +export function createMockFs(options: CreateMockFsOptions = {}): MockFs { + const { + files = {}, + directories = {}, + readFileImpl, + readdirImpl, + writeFileImpl, + mkdirImpl, + statImpl, + } = options + + const writtenFiles: Record = { ...files } + const createdDirs: Set = new Set(Object.keys(directories)) + + const defaultReadFile = async (path: PathLike): Promise => { + const pathStr = String(path) + if (pathStr in writtenFiles) { + return writtenFiles[pathStr] + } + throw new Error(`File not found: ${pathStr}`) + } + + const defaultReaddir = async (path: PathLike): Promise => { + const pathStr = String(path) + if (pathStr in directories) { + return directories[pathStr] + } + throw new Error(`Directory not found: ${pathStr}`) + } + + const defaultWriteFile = async ( + path: PathLike, + data: string, + ): Promise => { + const pathStr = String(path) + writtenFiles[pathStr] = data + } + + const defaultMkdir = async (path: PathLike): Promise => { + const pathStr = String(path) + createdDirs.add(pathStr) + return undefined + } + + const defaultStat = async (path: PathLike): Promise => { + const pathStr = String(path) + const isFile = pathStr in writtenFiles + const isDir = pathStr in directories || createdDirs.has(pathStr) + + if (!isFile && !isDir) { + throw new Error(`Path not found: ${pathStr}`) + } + + return { + isFile: () => isFile, + isDirectory: () => isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isSymbolicLink: () => false, + isFIFO: () => false, + isSocket: () => false, + dev: 0, + ino: 0, + mode: isDir ? 0o755 : 0o644, + nlink: 1, + uid: 0, + gid: 0, + rdev: 0, + size: isFile ? writtenFiles[pathStr].length : 0, + blksize: 4096, + blocks: 0, + atimeMs: Date.now(), + mtimeMs: Date.now(), + ctimeMs: Date.now(), + birthtimeMs: Date.now(), + atime: new Date(), + mtime: new Date(), + ctime: new Date(), + birthtime: new Date(), + } as Stats + } + + const readFileFn = readFileImpl + ? async (path: PathLike) => readFileImpl(String(path)) + : defaultReadFile + + const readdirFn = readdirImpl + ? async (path: PathLike) => readdirImpl(String(path)) + : defaultReaddir + + const writeFileFn = writeFileImpl + ? async (path: PathLike, data: string) => writeFileImpl(String(path), data) + : defaultWriteFile + + const mkdirFn = mkdirImpl + ? async (path: PathLike, opts?: { recursive?: boolean }) => + mkdirImpl(String(path), opts) + : defaultMkdir + + const statFn = statImpl + ? async (path: PathLike) => statImpl(String(path)) + : defaultStat + + return { + readFile: mock(readFileFn), + readdir: mock(readdirFn), + writeFile: mock(writeFileFn), + mkdir: mock(mkdirFn), + stat: mock(statFn), + } as unknown as MockFs +} + +export function restoreMockFs(mockFs: MockFs): void { + const mocks = mockFs as unknown as MockFsWithMocks + mocks.readFile.mockRestore() + mocks.readdir.mockRestore() + mocks.writeFile.mockRestore() + mocks.mkdir.mockRestore() + mocks.stat.mockRestore() +} + +export function clearMockFs(mockFs: MockFs): void { + const mocks = mockFs as unknown as MockFsWithMocks + mocks.readFile.mockClear() + mocks.readdir.mockClear() + mocks.writeFile.mockClear() + mocks.mkdir.mockClear() + mocks.stat.mockClear() +} diff --git a/common/src/testing/mocks/index.ts b/common/src/testing/mocks/index.ts new file mode 100644 index 0000000000..2f47a2278c --- /dev/null +++ b/common/src/testing/mocks/index.ts @@ -0,0 +1,101 @@ +/** + * Mock utilities index. + * + * Re-exports all mock factories for convenient importing. + */ + +export { + createMockLogger, + createMockLoggerWithCapture, + restoreMockLogger, + clearMockLogger, +} from './logger' +export type { + LogLevel, + LogMethod, + MockLogMethod, + MockLogger, + CreateMockLoggerOptions, + CapturedLogEntry, + MockLoggerWithCapture, +} from './logger' + +export { + createMockAnalytics, + createMockAnalyticsWithCapture, + setupAnalyticsMocks, + restoreMockAnalytics, +} from './analytics' +export type { + MockAnalytics, + MockAnalyticsWithCapture, + AnalyticsSpies, + CreateMockAnalyticsOptions, + TrackedEvent, + EventProperties, +} from './analytics' + +export { createMockDbOperations, setupDbSpies } from './database' +export type { MockDbOperations, DbSpies, CreateMockDbOptions } from './database' + +export { setupCryptoMocks, createMockUuid, TEST_UUIDS } from './crypto' +export type { CryptoMockSpies, UUID, SetupCryptoMocksOptions } from './crypto' +export { createUuidGenerator, setupSequentialCryptoMocks } from './crypto' + +export { + createToolCallChunk, + createMockStream, + createMockTextStream, +} from './stream' + +export { createMockTimers, installMockTimers } from './timers' +export type { PendingTimer, MockTimers } from './timers' + +export { createMockFs, restoreMockFs, clearMockFs } from './filesystem' +export type { MockFs, MockFsWithMocks, CreateMockFsOptions } from './filesystem' + +export { + createMockFetch, + installMockFetch, + mockJsonResponse, + mockTextResponse, + mockErrorResponse, +} from './fetch' +export type { + MockFetch, + MockFetchCall, + MockResponseOptions, + CreateMockFetchOptions, + InstallMockFetchResult, +} from './fetch' + +export { + createMockCapture, + createMockTreeSitterCaptures, + createMockTree, + createMockTreeSitterParser, + createMockTreeSitterQuery, + createMockLanguageConfig, +} from './tree-sitter' +export type { + MockTreeNode, + MockTree, + MockCapture, + MockParser, + MockQuery, + CreateMockParserOptions, + CreateMockQueryOptions, + CreateMockLanguageConfigOptions, +} from './tree-sitter' + +export { + createMockChildProcess, + createMockSpawn, + asCodeSearchResult, + createRgJsonMatch, + createRgJsonContext, +} from './child-process' +export type { + MockChildProcess, + CodeSearchResult, +} from './child-process' diff --git a/common/src/testing/mocks/logger.ts b/common/src/testing/mocks/logger.ts new file mode 100644 index 0000000000..1b6652112f --- /dev/null +++ b/common/src/testing/mocks/logger.ts @@ -0,0 +1,135 @@ +import { mock } from 'bun:test' + +import type { Mock } from 'bun:test' + +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal' + +export type LogMethod = ( + data: unknown, + msg?: string, + ...args: unknown[] +) => unknown + +export type MockLogMethod = Mock + +export interface MockLogger { + trace: MockLogMethod + debug: MockLogMethod + info: MockLogMethod + warn: MockLogMethod + error: MockLogMethod + fatal: MockLogMethod + child: Mock<(bindings: Record) => MockLogger> +} + +export interface CreateMockLoggerOptions { + captureOutput?: boolean + customImplementations?: Partial> +} + +export interface CapturedLogEntry { + level: LogLevel + message: string + meta?: Record + timestamp: Date +} + +export function createMockLogger( + options: CreateMockLoggerOptions = {}, +): MockLogger { + const { customImplementations = {} } = options + + const createLogMethod = (level: LogLevel): MockLogMethod => { + const customImpl = customImplementations[level] + if (customImpl) { + return mock(customImpl) + } + return mock(() => {}) + } + + const mockLogger: MockLogger = { + trace: createLogMethod('trace'), + debug: createLogMethod('debug'), + info: createLogMethod('info'), + warn: createLogMethod('warn'), + error: createLogMethod('error'), + fatal: createLogMethod('fatal'), + child: mock(() => createMockLogger(options)), + } + + return mockLogger +} + +export interface MockLoggerWithCapture { + logger: MockLogger + captured: CapturedLogEntry[] + clearCaptured: () => void + getByLevel: (level: LogLevel) => CapturedLogEntry[] + getByMessage: (pattern: string | RegExp) => CapturedLogEntry[] +} + +/** Creates a mock logger that captures all output for inspection. */ +export function createMockLoggerWithCapture(): MockLoggerWithCapture { + const captured: CapturedLogEntry[] = [] + + const createCapturingLogMethod = (level: LogLevel): MockLogMethod => { + return mock((data: unknown, msg?: string) => { + const message = typeof data === 'string' ? data : (msg ?? String(data)) + const meta = + typeof data === 'object' && data !== null + ? (data as Record) + : undefined + captured.push({ + level, + message, + meta, + timestamp: new Date(), + }) + }) + } + + const logger: MockLogger = { + trace: createCapturingLogMethod('trace'), + debug: createCapturingLogMethod('debug'), + info: createCapturingLogMethod('info'), + warn: createCapturingLogMethod('warn'), + error: createCapturingLogMethod('error'), + fatal: createCapturingLogMethod('fatal'), + child: mock(() => createMockLoggerWithCapture().logger), + } + + return { + logger, + captured, + clearCaptured: () => { + captured.length = 0 + }, + getByLevel: (level: LogLevel) => captured.filter((e) => e.level === level), + getByMessage: (pattern: string | RegExp) => + captured.filter((e) => + typeof pattern === 'string' + ? e.message.includes(pattern) + : pattern.test(e.message), + ), + } +} + +export function restoreMockLogger(logger: MockLogger): void { + logger.trace.mockRestore() + logger.debug.mockRestore() + logger.info.mockRestore() + logger.warn.mockRestore() + logger.error.mockRestore() + logger.fatal.mockRestore() + logger.child.mockRestore() +} + +export function clearMockLogger(logger: MockLogger): void { + logger.trace.mockClear() + logger.debug.mockClear() + logger.info.mockClear() + logger.warn.mockClear() + logger.error.mockClear() + logger.fatal.mockClear() + logger.child.mockClear() +} diff --git a/common/src/testing/mocks/stream.ts b/common/src/testing/mocks/stream.ts new file mode 100644 index 0000000000..3677133215 --- /dev/null +++ b/common/src/testing/mocks/stream.ts @@ -0,0 +1,313 @@ +/** + * Typed stream mock factory for testing LLM streaming. + * + * Provides type-safe utilities for creating mock LLM streams + * and testing streaming behavior. + * + * @example + * ```typescript + * import { createMockStream, createToolCallChunk } from '@codebuff/common/testing/mocks/stream' + * + * // Create a mock stream with text and tool calls + * const stream = createMockStream([ + * { type: 'text', text: 'Hello ' }, + * { type: 'text', text: 'world!' }, + * createToolCallChunk('end_turn', {}), + * ]) + * + * // Use in tests + * for await (const chunk of stream) { + * console.log(chunk) + * } + * ``` + */ + +import { mock } from 'bun:test' + +import type { Mock } from 'bun:test' + +/** + * A text chunk from an LLM stream. + */ +export interface TextChunk { + type: 'text' + text: string + agentId?: string +} + +/** + * A tool call chunk from an LLM stream. + */ +export interface ToolCallChunk { + type: 'tool-call' + toolName: string + toolCallId: string + input: Record +} + +/** + * A reasoning chunk from an LLM stream. + */ +export interface ReasoningChunk { + type: 'reasoning' + text: string +} + +/** + * Union of all stream chunk types. + */ +export type StreamChunk = TextChunk | ToolCallChunk | ReasoningChunk + +/** + * Options for creating a tool call chunk. + */ +export interface CreateToolCallOptions { + /** + * Custom tool call ID. If not provided, a random one is generated. + */ + toolCallId?: string +} + +let toolCallIdCounter = 0 + +/** + * Creates a tool call chunk for testing. + * + * @param toolName - The name of the tool being called + * @param input - The input parameters for the tool + * @param options - Additional options + * @returns A properly typed tool call chunk + * + * @example + * ```typescript + * const chunk = createToolCallChunk('read_files', { paths: ['file.ts'] }) + * // { type: 'tool-call', toolName: 'read_files', toolCallId: 'tool-call-1', input: { paths: ['file.ts'] } } + * ``` + */ +export function createToolCallChunk( + toolName: string, + input: Record, + options: CreateToolCallOptions = {}, +): ToolCallChunk { + const { toolCallId = `tool-call-${++toolCallIdCounter}` } = options + return { + type: 'tool-call', + toolName, + toolCallId, + input, + } +} + +/** + * Creates a text chunk for testing. + * + * @param text - The text content + * @param agentId - Optional agent ID for subagent chunks + * @returns A text chunk + * + * @example + * ```typescript + * const chunk = createTextChunk('Hello world!') + * // { type: 'text', text: 'Hello world!' } + * ``` + */ +export function createTextChunk(text: string, agentId?: string): TextChunk { + const chunk: TextChunk = { type: 'text', text } + if (agentId) { + chunk.agentId = agentId + } + return chunk +} + +/** + * Creates a reasoning chunk for testing. + * + * @param text - The reasoning text + * @returns A reasoning chunk + */ +export function createReasoningChunk(text: string): ReasoningChunk { + return { type: 'reasoning', text } +} + +/** + * Creates a mock async generator that yields the provided chunks. + * + * @param chunks - The chunks to yield + * @param returnValue - The value to return when the generator completes + * @returns An async generator that yields the chunks + * + * @example + * ```typescript + * const stream = createMockStream([ + * createTextChunk('Processing...'), + * createToolCallChunk('read_files', { paths: ['test.ts'] }), + * createTextChunk('Done!'), + * createToolCallChunk('end_turn', {}), + * ]) + * + * // Consume the stream + * const chunks = [] + * for await (const chunk of stream) { + * chunks.push(chunk) + * } + * ``` + */ +export function createMockStream( + chunks: StreamChunk[], + returnValue: string | null = 'mock-message-id', +): AsyncGenerator { + async function* generator(): AsyncGenerator< + StreamChunk, + string | null, + undefined + > { + for (const chunk of chunks) { + yield chunk + } + return returnValue + } + return generator() +} + +/** + * Creates a mock stream that yields text in multiple chunks. + * Useful for testing streaming text display. + * + * @param text - The complete text to stream + * @param chunkSize - Size of each chunk + * @param endWithTool - Whether to end with an end_turn tool call + * @returns A mock stream + * + * @example + * ```typescript + * const stream = createMockTextStream('Hello world!', 3) + * // Yields: 'Hel', 'lo ', 'wor', 'ld!' + * ``` + */ +export function createMockTextStream( + text: string, + chunkSize: number = 10, + endWithTool: boolean = true, +): AsyncGenerator { + const chunks: StreamChunk[] = [] + + for (let i = 0; i < text.length; i += chunkSize) { + chunks.push(createTextChunk(text.slice(i, i + chunkSize))) + } + + if (endWithTool) { + chunks.push(createToolCallChunk('end_turn', {})) + } + + return createMockStream(chunks) +} + +/** + * Options for creating a mock prompt function. + */ +export interface MockPromptOptions { + /** + * Default response text. + */ + defaultResponse?: string + + /** + * Whether to include an end_turn tool call. + */ + includeEndTurn?: boolean + + /** + * Custom chunks to yield. + */ + chunks?: StreamChunk[] +} + +/** + * Mock prompt function result type. + */ +export type MockPromptFn = Mock< + ( + params: Record, + ) => AsyncGenerator +> + +/** + * Creates a mock promptAiSdkStream function for testing. + * + * @param options - Configuration options + * @returns A mock function that returns streams + * + * @example + * ```typescript + * const mockPrompt = createMockPromptAiSdkStream({ + * defaultResponse: 'I understand your request.', + * }) + * + * loopAgentStepsBaseParams.promptAiSdkStream = mockPrompt + * + * await loopAgentSteps({ ...params }) + * + * expect(mockPrompt).toHaveBeenCalledTimes(1) + * ``` + */ +export function createMockPromptAiSdkStream( + options: MockPromptOptions = {}, +): MockPromptFn { + const { + defaultResponse = 'Mock response\n\n', + includeEndTurn = true, + chunks, + } = options + + return mock(async function* () { + if (chunks) { + for (const chunk of chunks) { + yield chunk + } + } else { + yield createTextChunk(defaultResponse) + if (includeEndTurn) { + yield createToolCallChunk('end_turn', {}) + } + } + return 'mock-message-id' + }) +} + +/** + * Collects all chunks from a stream into an array. + * Useful for testing stream content. + * + * @param stream - The stream to collect from + * @returns An array of all chunks and the return value + * + * @example + * ```typescript + * const stream = createMockStream([...]) + * const { chunks, returnValue } = await collectStreamChunks(stream) + * + * expect(chunks).toHaveLength(3) + * expect(returnValue).toBe('mock-message-id') + * ``` + */ +export async function collectStreamChunks( + stream: AsyncGenerator, +): Promise<{ chunks: T[]; returnValue: R }> { + const chunks: T[] = [] + + let result = await stream.next() + while (!result.done) { + chunks.push(result.value) + result = await stream.next() + } + + return { chunks, returnValue: result.value } +} + +/** + * Resets the tool call ID counter. + * Call this in beforeEach to ensure deterministic IDs. + */ +export function resetToolCallIdCounter(): void { + toolCallIdCounter = 0 +} diff --git a/common/src/testing/mocks/timers.ts b/common/src/testing/mocks/timers.ts new file mode 100644 index 0000000000..ec45b0740a --- /dev/null +++ b/common/src/testing/mocks/timers.ts @@ -0,0 +1,132 @@ +/** + * @deprecated Use Bun's built-in mock.setSystemTime() instead. + */ + +export interface PendingTimer { + id: number + ms: number + fn: () => void + active: boolean + createdAt: number +} + +export interface MockTimers { + setTimeout: typeof globalThis.setTimeout + clearTimeout: typeof globalThis.clearTimeout + install: () => void + restore: () => void + runAll: () => void + advanceBy: (ms: number) => void + getPending: () => PendingTimer[] + getPendingCount: () => number + clearAll: () => void + isPending: (id: number) => boolean + getNext: () => PendingTimer | undefined +} + +/** @deprecated Use Bun's built-in mock.setSystemTime() instead. */ +export function createMockTimers(): MockTimers { + const pendingTimers: PendingTimer[] = [] + let nextId = 1 + let currentTime = 0 + + const originalSetTimeout = globalThis.setTimeout + const originalClearTimeout = globalThis.clearTimeout + + const mockSetTimeout = ((fn: () => void, ms?: number): number => { + const id = nextId++ + pendingTimers.push({ + id, + ms: Number(ms ?? 0), + fn, + active: true, + createdAt: currentTime, + }) + return id + }) as typeof globalThis.setTimeout + + const mockClearTimeout = ((id?: number): void => { + if (id === undefined) return + const timer = pendingTimers.find((t) => t.id === id) + if (timer) { + timer.active = false + } + }) as typeof globalThis.clearTimeout + + const getActivePending = (): PendingTimer[] => { + return pendingTimers.filter((t) => t.active) + } + + return { + setTimeout: mockSetTimeout, + clearTimeout: mockClearTimeout, + + install(): void { + globalThis.setTimeout = mockSetTimeout + globalThis.clearTimeout = mockClearTimeout + }, + + restore(): void { + globalThis.setTimeout = originalSetTimeout + globalThis.clearTimeout = originalClearTimeout + pendingTimers.length = 0 + nextId = 1 + currentTime = 0 + }, + + runAll(): void { + const active = getActivePending() + for (const timer of active) { + if (timer.active) { + timer.active = false + timer.fn() + } + } + }, + + advanceBy(ms: number): void { + currentTime += ms + const active = getActivePending() + .filter((t) => t.createdAt + t.ms <= currentTime) + .sort((a, b) => a.createdAt + a.ms - (b.createdAt + b.ms)) + + for (const timer of active) { + if (timer.active) { + timer.active = false + timer.fn() + } + } + }, + + getPending(): PendingTimer[] { + return getActivePending() + }, + + getPendingCount(): number { + return getActivePending().length + }, + + clearAll(): void { + for (const timer of pendingTimers) { + timer.active = false + } + }, + + isPending(id: number): boolean { + return pendingTimers.some((t) => t.id === id && t.active) + }, + + getNext(): PendingTimer | undefined { + return getActivePending().sort( + (a, b) => a.createdAt + a.ms - (b.createdAt + b.ms), + )[0] + }, + } +} + +/** @deprecated Use Bun's built-in mock.setSystemTime() instead. */ +export function installMockTimers(): MockTimers { + const timers = createMockTimers() + timers.install() + return timers +} diff --git a/common/src/testing/mocks/tree-sitter.ts b/common/src/testing/mocks/tree-sitter.ts new file mode 100644 index 0000000000..83e6f512b8 --- /dev/null +++ b/common/src/testing/mocks/tree-sitter.ts @@ -0,0 +1,127 @@ +import { mock } from 'bun:test' + +export interface MockTreeNode { + text: string + type?: string + startPosition?: { row: number; column: number } + endPosition?: { row: number; column: number } + children?: MockTreeNode[] +} + +export interface MockTree { + rootNode: MockTreeNode +} + +export interface MockCapture { + name: string + node: MockTreeNode +} + +export interface MockParser { + parse: (input: string) => MockTree | null +} + +export interface MockQuery { + captures: (node: MockTreeNode) => MockCapture[] +} + +export interface CreateMockParserOptions { + tree?: MockTree | null + parseImpl?: (input: string) => MockTree | null +} + +export interface CreateMockQueryOptions { + captures?: MockCapture[] + capturesImpl?: (node: MockTreeNode) => MockCapture[] +} + +export function createMockCapture(name: string, text: string): MockCapture { + return { + name, + node: { text }, + } +} + +export function createMockTreeSitterCaptures( + items: Array<{ name: string; text: string }>, +): MockCapture[] { + return items.map(({ name, text }) => createMockCapture(name, text)) +} + +export function createMockTree(rootNodeText: string = 'mock tree'): MockTree { + return { + rootNode: { text: rootNodeText }, + } +} + +export function createMockTreeSitterParser( + options: CreateMockParserOptions = {}, +): MockParser { + const { tree, parseImpl } = options + const defaultTree = createMockTree() + const parseFn = parseImpl ?? (() => tree ?? defaultTree) + + return { + parse: mock(parseFn), + } +} + +export function createMockTreeSitterQuery( + options: CreateMockQueryOptions = {}, +): MockQuery { + const { captures = [], capturesImpl } = options + const capturesFn = capturesImpl ?? (() => captures) + + return { + captures: mock(capturesFn), + } +} + +export interface CreateMockLanguageConfigOptions { + extensions?: string[] + wasmFile?: string + queryText?: string + parser?: MockParser | null + query?: MockQuery | null + captures?: MockCapture[] + tree?: MockTree | null +} + +export function createMockLanguageConfig( + options: CreateMockLanguageConfigOptions = {}, +): { + extensions: string[] + wasmFile: string + queryText: string + parser: MockParser | null + query: MockQuery | null +} { + const { + extensions = ['.ts'], + wasmFile = 'tree-sitter-typescript.wasm', + queryText = 'mock query', + parser, + query, + captures, + tree, + } = options + + const finalQuery = + query ?? + (captures + ? createMockTreeSitterQuery({ captures }) + : createMockTreeSitterQuery()) + const finalParser = + parser ?? + (tree !== undefined + ? createMockTreeSitterParser({ tree }) + : createMockTreeSitterParser()) + + return { + extensions, + wasmFile, + queryText, + parser: finalParser, + query: finalQuery, + } +} diff --git a/common/src/testing/setup.ts b/common/src/testing/setup.ts new file mode 100644 index 0000000000..631178350c --- /dev/null +++ b/common/src/testing/setup.ts @@ -0,0 +1,282 @@ +/** + * Test setup utilities for common patterns. + * + * Provides helper functions for setting up and tearing down test fixtures + * in a consistent way across the codebase. + * + * @example + * ```typescript + * import { createTestSetup, TestSetupResult } from '@codebuff/common/testing/setup' + * + * describe('my test', () => { + * const setup = createTestSetup() + * + * beforeEach(() => setup.beforeEach()) + * afterEach(() => setup.afterEach()) + * }) + * ``` + */ + +import { setupAnalyticsMocks } from './mocks/analytics' +import { setupCryptoMocks } from './mocks/crypto' +import { setupDbSpies } from './mocks/database' +import { createMockLogger } from './mocks/logger' +import { resetToolCallIdCounter } from './mocks/stream' + +import type { + AnalyticsSpies, + TrackEventFn, + FlushAnalyticsFn, +} from './mocks/analytics' +import type { CryptoMockSpies } from './mocks/crypto' +import type { DbSpies } from './mocks/database' +import type { MockLogger } from './mocks/logger' + +/** + * Options for creating a test setup. + */ +export interface CreateTestSetupOptions { + /** + * Whether to set up analytics mocks. + * @default true + */ + analytics?: boolean + + /** + * Whether to set up crypto mocks. + * @default true + */ + crypto?: boolean + + /** + * Whether to set up database mocks. + * Requires passing the db module. + * @default false + */ + database?: boolean + + /** + * The database module to mock (required if database is true). + * Must have insert and update methods that are functions. + */ + dbModule?: { + insert: (...args: unknown[]) => unknown + update: (...args: unknown[]) => unknown + } + + /** + * The analytics module to mock (required if analytics is true). + */ + analyticsModule?: { + trackEvent: TrackEventFn + flushAnalytics: FlushAnalyticsFn + } + + /** + * Prefix for crypto mock UUIDs. + * @default 'test' + */ + cryptoPrefix?: string +} + +/** + * Result of creating a test setup. + */ +export interface TestSetupResult { + /** The mock logger instance */ + logger: MockLogger + + /** Analytics spies (if enabled) */ + analyticsSpy?: AnalyticsSpies + + /** Crypto spies (if enabled) */ + cryptoSpy?: CryptoMockSpies + + /** Database spies (if enabled) */ + dbSpy?: DbSpies + + /** Call this in beforeEach */ + beforeEach: () => void + + /** Call this in afterEach */ + afterEach: () => void + + /** Restore all mocks */ + restore: () => void +} + +/** + * Creates a test setup with common mocks pre-configured. + * + * @param options - Configuration options + * @returns A test setup result with mocks and lifecycle methods + * + * @example + * ```typescript + * import * as analytics from '@codebuff/common/analytics' + * import db from '@codebuff/internal/db' + * + * describe('my test', () => { + * const setup = createTestSetup({ + * analytics: true, + * analyticsModule: analytics, + * database: true, + * dbModule: db, + * }) + * + * beforeEach(() => setup.beforeEach()) + * afterEach(() => setup.afterEach()) + * + * it('does something', () => { + * expect(setup.analyticsSpy.trackEvent).toHaveBeenCalled() + * }) + * }) + * ``` + */ +export function createTestSetup( + options: CreateTestSetupOptions = {}, +): TestSetupResult { + const { + analytics = true, + crypto = true, + database = false, + dbModule, + analyticsModule, + cryptoPrefix = 'test', + } = options + + const logger = createMockLogger() + let analyticsSpy: AnalyticsSpies | undefined + let cryptoSpy: CryptoMockSpies | undefined + let dbSpy: DbSpies | undefined + + const beforeEach = (): void => { + // Reset tool call ID counter for deterministic tests + resetToolCallIdCounter() + + // Set up analytics mocks + if (analytics && analyticsModule) { + analyticsSpy = setupAnalyticsMocks(analyticsModule) + } + + // Set up crypto mocks + if (crypto) { + cryptoSpy = setupCryptoMocks({ prefix: cryptoPrefix, sequential: true }) + } + + // Set up database mocks + if (database && dbModule) { + dbSpy = setupDbSpies(dbModule) + } + } + + const afterEach = (): void => { + // Restore all mocks + analyticsSpy?.restore() + cryptoSpy?.restore() + dbSpy?.restore() + + // Reset the spies + analyticsSpy = undefined + cryptoSpy = undefined + dbSpy = undefined + } + + const restore = afterEach + + return { + logger, + get analyticsSpy() { + return analyticsSpy + }, + get cryptoSpy() { + return cryptoSpy + }, + get dbSpy() { + return dbSpy + }, + beforeEach, + afterEach, + restore, + } +} + +/** + * A simple sleep function for async tests. + * + * @param ms - Milliseconds to sleep + * @returns A promise that resolves after the specified time + * + * @example + * ```typescript + * await sleep(100) // Wait 100ms + * ``` + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Waits for a condition to be true, polling at the specified interval. + * + * @param condition - Function that returns true when the condition is met + * @param timeout - Maximum time to wait in ms + * @param interval - Polling interval in ms + * @returns A promise that resolves when the condition is met + * @throws Error if the timeout is reached + * + * @example + * ```typescript + * await waitFor(() => document.querySelector('.loaded') !== null) + * ``` + */ +export async function waitFor( + condition: () => boolean | Promise, + timeout: number = 5000, + interval: number = 50, +): Promise { + const start = Date.now() + + while (Date.now() - start < timeout) { + const result = await condition() + if (result) { + return + } + await sleep(interval) + } + + throw new Error(`waitFor timed out after ${timeout}ms`) +} + +/** + * Wraps a function to capture its call arguments. + * Useful for verifying function calls in tests. + * + * @param fn - The function to wrap + * @returns An object with the wrapped function and captured calls + * + * @example + * ```typescript + * const { fn, calls } = captureCallArgs((a: number, b: string) => a + b.length) + * + * fn(1, 'hello') + * fn(2, 'world') + * + * expect(calls).toEqual([ + * [1, 'hello'], + * [2, 'world'], + * ]) + * ``` + */ +export function captureCallArgs( + fn: (...args: T) => R, +): { fn: (...args: T) => R; calls: T[] } { + const calls: T[] = [] + + const wrappedFn = (...args: T): R => { + calls.push(args) + return fn(...args) + } + + return { fn: wrappedFn, calls } +} diff --git a/common/src/tools/__tests__/compile-tool-definitions.test.ts b/common/src/tools/__tests__/compile-tool-definitions.test.ts new file mode 100644 index 0000000000..a4766d8363 --- /dev/null +++ b/common/src/tools/__tests__/compile-tool-definitions.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test' + +import { compileToolDefinitions } from '../compile-tool-definitions' + +describe('compileToolDefinitions', () => { + test('emits type aliases for root union tool schemas', () => { + const definitions = compileToolDefinitions() + + expect(definitions).toContain('export type GravityIndexParams =') + expect(definitions).not.toContain('export interface GravityIndexParams {') + expect(definitions).toContain('"action": "search"') + expect(definitions).toContain('"action": "report_integration"') + }) + + test('keeps object tool schemas as interfaces', () => { + const definitions = compileToolDefinitions() + + expect(definitions).toContain('export interface WebSearchParams {') + }) +}) diff --git a/common/src/tools/compile-tool-definitions.ts b/common/src/tools/compile-tool-definitions.ts index a2dc2c372e..fb478324d5 100644 --- a/common/src/tools/compile-tool-definitions.ts +++ b/common/src/tools/compile-tool-definitions.ts @@ -18,18 +18,24 @@ export function compileToolDefinitions(): string { // Convert Zod schema to TypeScript interface using JSON schema let typeDefinition: string + let jsonSchema: unknown try { - const jsonSchema = z.toJSONSchema(parameterSchema, { io: 'input' }) + jsonSchema = z.toJSONSchema(parameterSchema, { io: 'input' }) typeDefinition = jsonSchemaToTypeScript(jsonSchema) } catch (error) { console.warn(`Failed to convert schema for ${toolName}:`, error) typeDefinition = '{ [key: string]: any }' } + const typeName = `${toPascalCase(toolName)}Params` + const declaration = canEmitInterface(jsonSchema) + ? `export interface ${typeName} ${typeDefinition}` + : `export type ${typeName} = ${typeDefinition}` + return `/** * ${parameterSchema.description || `Parameters for ${toolName} tool`} */ -export interface ${toPascalCase(toolName)}Params ${typeDefinition}` +${declaration}` }) .join('\n\n') @@ -89,13 +95,26 @@ function jsonSchemaToTypeScript(schema: any): string { return getTypeFromJsonSchema(schema) } +function canEmitInterface(schema: any): boolean { + return ( + schema.type === 'object' && + !!schema.properties && + !schema.anyOf && + !schema.oneOf + ) +} + /** * Gets TypeScript type from JSON Schema property */ function getTypeFromJsonSchema(prop: any): string { + if (prop.const !== undefined) { + return JSON.stringify(prop.const) + } + if (prop.type === 'string') { if (prop.enum) { - return prop.enum.map((v: string) => `"${v}"`).join(' | ') + return prop.enum.map((v: string) => JSON.stringify(v)).join(' | ') } return 'string' } diff --git a/common/src/tools/constants.ts b/common/src/tools/constants.ts index 123a4e0d8e..5fe789eb76 100644 --- a/common/src/tools/constants.ts +++ b/common/src/tools/constants.ts @@ -14,12 +14,14 @@ export const TOOLS_WHICH_WONT_FORCE_NEXT_STEP = [ 'add_message', 'update_subgoal', 'create_plan', + 'render_ui', 'suggest_followups', 'task_completed', ] // List of all available tools export const toolNames = [ + 'apply_patch', 'add_subgoal', 'add_message', 'ask_user', @@ -29,6 +31,7 @@ export const toolNames = [ 'end_turn', 'find_files', 'glob', + 'gravity_index', 'list_directory', 'lookup_agent_info', 'propose_str_replace', @@ -36,10 +39,13 @@ export const toolNames = [ 'read_docs', 'read_files', 'read_subtree', + 'read_url', + 'render_ui', 'run_file_change_hooks', 'run_terminal_command', 'set_messages', 'set_output', + 'skill', 'spawn_agents', 'spawn_agent_inline', 'str_replace', @@ -53,12 +59,14 @@ export const toolNames = [ ] as const export const publishedTools = [ + 'apply_patch', 'add_message', 'ask_user', 'code_search', 'end_turn', 'find_files', 'glob', + 'gravity_index', 'list_directory', 'lookup_agent_info', 'propose_str_replace', @@ -66,10 +74,13 @@ export const publishedTools = [ 'read_docs', 'read_files', 'read_subtree', + 'read_url', + 'render_ui', 'run_file_change_hooks', 'run_terminal_command', 'set_messages', 'set_output', + 'skill', 'spawn_agents', 'str_replace', 'suggest_followups', diff --git a/common/src/tools/list.ts b/common/src/tools/list.ts index bc2157b1c5..4f40570d0e 100644 --- a/common/src/tools/list.ts +++ b/common/src/tools/list.ts @@ -3,6 +3,7 @@ import z from 'zod/v4' import { FileChangeSchema } from '../actions' import { addMessageParams } from './params/tool/add-message' import { addSubgoalParams } from './params/tool/add-subgoal' +import { applyPatchParams } from './params/tool/apply-patch' import { askUserParams } from './params/tool/ask-user' import { browserLogsParams } from './params/tool/browser-logs' import { codeSearchParams } from './params/tool/code-search' @@ -10,6 +11,7 @@ import { createPlanParams } from './params/tool/create-plan' import { endTurnParams } from './params/tool/end-turn' import { findFilesParams } from './params/tool/find-files' import { globParams } from './params/tool/glob' +import { gravityIndexParams } from './params/tool/gravity-index' import { listDirectoryParams } from './params/tool/list-directory' import { lookupAgentInfoParams } from './params/tool/lookup-agent-info' import { proposeStrReplaceParams } from './params/tool/propose-str-replace' @@ -17,10 +19,13 @@ import { proposeWriteFileParams } from './params/tool/propose-write-file' import { readDocsParams } from './params/tool/read-docs' import { readFilesParams } from './params/tool/read-files' import { readSubtreeParams } from './params/tool/read-subtree' +import { readUrlParams } from './params/tool/read-url' +import { renderUIParams } from './params/tool/render-ui' import { runFileChangeHooksParams } from './params/tool/run-file-change-hooks' import { runTerminalCommandParams } from './params/tool/run-terminal-command' import { setMessagesParams } from './params/tool/set-messages' import { setOutputParams } from './params/tool/set-output' +import { skillParams } from './params/tool/skill' import { spawnAgentInlineParams } from './params/tool/spawn-agent-inline' import { spawnAgentsParams } from './params/tool/spawn-agents' import { strReplaceParams } from './params/tool/str-replace' @@ -39,6 +44,7 @@ import type { ToolCallPart } from '../types/messages/content-part' export const toolParams = { add_message: addMessageParams, add_subgoal: addSubgoalParams, + apply_patch: applyPatchParams, ask_user: askUserParams, browser_logs: browserLogsParams, code_search: codeSearchParams, @@ -46,6 +52,7 @@ export const toolParams = { end_turn: endTurnParams, find_files: findFilesParams, glob: globParams, + gravity_index: gravityIndexParams, list_directory: listDirectoryParams, lookup_agent_info: lookupAgentInfoParams, propose_str_replace: proposeStrReplaceParams, @@ -53,10 +60,13 @@ export const toolParams = { read_docs: readDocsParams, read_files: readFilesParams, read_subtree: readSubtreeParams, + read_url: readUrlParams, + render_ui: renderUIParams, run_file_change_hooks: runFileChangeHooksParams, run_terminal_command: runTerminalCommandParams, set_messages: setMessagesParams, set_output: setOutputParams, + skill: skillParams, spawn_agents: spawnAgentsParams, spawn_agent_inline: spawnAgentInlineParams, str_replace: strReplaceParams, @@ -91,6 +101,10 @@ export type CodebuffToolMessage = ToolMessage & { // Tool call to send to client export const clientToolCallSchema = z.discriminatedUnion('toolName', [ + z.object({ + toolName: z.literal('apply_patch'), + input: toolParams.apply_patch.inputSchema, + }), z.object({ toolName: z.literal('ask_user'), input: toolParams.ask_user.inputSchema, @@ -119,6 +133,10 @@ export const clientToolCallSchema = z.discriminatedUnion('toolName', [ toolName: z.literal('run_file_change_hooks'), input: toolParams.run_file_change_hooks.inputSchema, }), + z.object({ + toolName: z.literal('read_url'), + input: toolParams.read_url.inputSchema, + }), z.object({ toolName: z.literal('run_terminal_command'), input: toolParams.run_terminal_command.inputSchema.and( diff --git a/common/src/tools/params/__tests__/coerce-to-array.test.ts b/common/src/tools/params/__tests__/coerce-to-array.test.ts new file mode 100644 index 0000000000..a9da91c8fd --- /dev/null +++ b/common/src/tools/params/__tests__/coerce-to-array.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'bun:test' +import z from 'zod/v4' + +import { + coerceToArray, + coerceToObject, + normalizeReplacementAliases, +} from '../utils' + +describe('coerceToArray', () => { + it('passes through arrays unchanged', () => { + expect(coerceToArray(['a', 'b'])).toEqual(['a', 'b']) + expect(coerceToArray([{ old: 'x', new: 'y' }])).toEqual([ + { old: 'x', new: 'y' }, + ]) + expect(coerceToArray([])).toEqual([]) + }) + + it('wraps a single string in an array', () => { + expect(coerceToArray('file.ts')).toEqual(['file.ts']) + }) + + it('wraps a single object in an array', () => { + expect(coerceToArray({ old: 'x', new: 'y' })).toEqual([ + { old: 'x', new: 'y' }, + ]) + }) + + it('wraps a single number in an array', () => { + expect(coerceToArray(42)).toEqual([42]) + }) + + it('parses a stringified JSON array', () => { + expect(coerceToArray('["file1.ts", "file2.ts"]')).toEqual([ + 'file1.ts', + 'file2.ts', + ]) + }) + + it('wraps a non-JSON string (does not parse as array)', () => { + expect(coerceToArray('not-json')).toEqual(['not-json']) + }) + + it('wraps a stringified JSON object (not an array) in an array', () => { + expect(coerceToArray('{"key": "value"}')).toEqual(['{"key": "value"}']) + }) + + it('passes through null', () => { + expect(coerceToArray(null)).toBeNull() + }) + + it('passes through undefined', () => { + expect(coerceToArray(undefined)).toBeUndefined() + }) +}) + +describe('coerceToObject', () => { + it('passes through objects unchanged', () => { + expect(coerceToObject({ key: 'value' })).toEqual({ key: 'value' }) + }) + + it('parses a stringified JSON object', () => { + expect(coerceToObject('{"key": "value"}')).toEqual({ key: 'value' }) + }) + + it('leaves non-JSON strings untouched', () => { + expect(coerceToObject('not-json')).toBe('not-json') + }) + + it('passes through arrays and primitives so validation can reject them', () => { + expect(coerceToObject(['a'])).toEqual(['a']) + expect(coerceToObject(1)).toBe(1) + }) +}) + +describe('coerceToArray with Zod schemas', () => { + it('coerces a single string into an array for z.array(z.string())', () => { + const schema = z.object({ + paths: z.preprocess(coerceToArray, z.array(z.string())), + }) + const result = schema.safeParse({ paths: 'file.ts' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.paths).toEqual(['file.ts']) + } + }) + + it('coerces a single object into an array for z.array(z.object(...))', () => { + const schema = z.object({ + replacements: z.preprocess( + coerceToArray, + z.array(z.object({ old: z.string(), new: z.string() })), + ), + }) + const result = schema.safeParse({ replacements: { old: 'x', new: 'y' } }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.replacements).toEqual([{ old: 'x', new: 'y' }]) + } + }) + + it('still validates correctly when already an array', () => { + const schema = z.object({ + paths: z.preprocess(coerceToArray, z.array(z.string())), + }) + const result = schema.safeParse({ paths: ['a.ts', 'b.ts'] }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.paths).toEqual(['a.ts', 'b.ts']) + } + }) + + it('still rejects invalid inner types after coercion', () => { + const schema = z.object({ + paths: z.preprocess(coerceToArray, z.array(z.string())), + }) + const result = schema.safeParse({ paths: 123 }) + expect(result.success).toBe(false) + }) + + it('works with optional arrays', () => { + const schema = z.object({ + paths: z.preprocess(coerceToArray, z.array(z.string())).optional(), + }) + const withValue = schema.safeParse({ paths: 'file.ts' }) + expect(withValue.success).toBe(true) + if (withValue.success) { + expect(withValue.data.paths).toEqual(['file.ts']) + } + + const withoutValue = schema.safeParse({}) + expect(withoutValue.success).toBe(true) + if (withoutValue.success) { + expect(withoutValue.data.paths).toBeUndefined() + } + }) + + it('produces identical JSON schema with or without preprocess', () => { + const plain = z.object({ paths: z.array(z.string()) }) + const coerced = z.object({ + paths: z.preprocess(coerceToArray, z.array(z.string())), + }) + + const plainSchema = z.toJSONSchema(plain, { io: 'input' }) + const coercedSchema = z.toJSONSchema(coerced, { io: 'input' }) + expect(coercedSchema).toEqual(plainSchema) + }) +}) + +describe('coerceToObject with Zod schemas', () => { + it('produces identical JSON schema with or without preprocess', () => { + const plain = z.object({ + params: z.record(z.string(), z.any()).optional(), + }) + const coerced = z.object({ + params: z + .preprocess(coerceToObject, z.record(z.string(), z.any())) + .optional(), + }) + + const plainSchema = z.toJSONSchema(plain, { io: 'input' }) + const coercedSchema = z.toJSONSchema(coerced, { io: 'input' }) + expect(coercedSchema).toEqual(plainSchema) + }) +}) + +describe('normalizeReplacementAliases', () => { + it('maps old_str and new_str onto the documented replacement keys', () => { + expect( + normalizeReplacementAliases({ + old_str: 'before', + new_str: 'after', + allowMultiple: true, + }), + ).toEqual({ + old_str: 'before', + new_str: 'after', + oldString: 'before', + newString: 'after', + allowMultiple: true, + }) + }) + + it('maps old_string and new_string onto the documented replacement keys', () => { + expect( + normalizeReplacementAliases({ + old_string: 'before', + new_string: 'after', + }), + ).toEqual({ + old_string: 'before', + new_string: 'after', + oldString: 'before', + newString: 'after', + }) + }) + + it('does not overwrite documented replacement keys', () => { + expect( + normalizeReplacementAliases({ + oldString: 'before', + newString: 'after', + old_str: 'ignored', + new_str: 'ignored', + }), + ).toEqual({ + oldString: 'before', + newString: 'after', + old_str: 'ignored', + new_str: 'ignored', + }) + }) +}) diff --git a/common/src/tools/params/tool/apply-patch.ts b/common/src/tools/params/tool/apply-patch.ts new file mode 100644 index 0000000000..1414be1817 --- /dev/null +++ b/common/src/tools/params/tool/apply-patch.ts @@ -0,0 +1,110 @@ +import z from 'zod/v4' + +import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' + +import type { $ToolParams } from '../../constants' + +export const applyPatchResultSchema = z.union([ + z.object({ + message: z.string(), + applied: z.array( + z.object({ + file: z.string(), + action: z.enum(['add', 'update', 'delete']), + }), + ), + }), + z.object({ + errorMessage: z.string(), + }), +]) + +const toolName = 'apply_patch' +const endsAgentStep = false + +const operationSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('create_file'), + path: z.string().min(1, 'Path cannot be empty'), + diff: z.string().min(1, 'Diff cannot be empty'), + }), + z.object({ + type: z.literal('update_file'), + path: z.string().min(1, 'Path cannot be empty'), + diff: z.string().min(1, 'Diff cannot be empty'), + }), + z.object({ + type: z.literal('delete_file'), + path: z.string().min(1, 'Path cannot be empty'), + }), +]) + +export type ApplyPatchOperation = z.infer + +const inputSchema = z + .object({ + operation: operationSchema.describe( + 'The file operation to perform. type is one of create_file, update_file, or delete_file.', + ), + }) + .describe('Apply a file operation (create, update, or delete).') + +const description = ` +Use this tool to apply file operations using Codex-style apply_patch format. + +Each call performs a single operation on one file. + +Operation types: +- create_file: Create a new file. Requires path and diff (lines prefixed with +). +- update_file: Update an existing file. Requires path and diff (unified diff with @@ hunks). +- delete_file: Delete a file. Requires only path. + +Example (create): +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + operation: { + type: 'create_file', + path: 'hello.txt', + diff: '@@\n+Hello world\n', + }, + }, + endsAgentStep, +})} + +Example (update): +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + operation: { + type: 'update_file', + path: 'lib/fib.py', + diff: '@@\n-def fib(n):\n+def fibonacci(n):\n if n <= 1:\n return n\n- return fib(n-1) + fib(n-2)\n+ return fibonacci(n-1) + fibonacci(n-2)\n', + }, + }, + endsAgentStep, +})} + +Example (delete): +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + operation: { + type: 'delete_file', + path: 'old-file.txt', + }, + }, + endsAgentStep, +})} +`.trim() + +export const applyPatchParams = { + toolName, + endsAgentStep, + description, + inputSchema, + outputSchema: jsonToolResultSchema(applyPatchResultSchema), +} satisfies $ToolParams diff --git a/common/src/tools/params/tool/ask-user.ts b/common/src/tools/params/tool/ask-user.ts index a87e7d7fdf..56948e4364 100644 --- a/common/src/tools/params/tool/ask-user.ts +++ b/common/src/tools/params/tool/ask-user.ts @@ -1,6 +1,6 @@ import z from 'zod/v4' -import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' +import { $getNativeToolCallExampleString, coerceToArray, jsonToolResultSchema } from '../utils' import type { $ToolParams } from '../../constants' @@ -15,17 +15,21 @@ export const questionSchema = z.object({ 'Short label (max 12 chars) displayed as a chip/tag. Example: "Auth method"', ), options: z - .object({ - label: z.string().describe('The display text for this option'), - description: z - .string() - .optional() - .describe('Explanation shown when option is focused'), - }) - .array() - .refine((opts) => opts.length >= 2, { - message: 'Each question must have at least 2 options', - }) + .preprocess( + coerceToArray, + z + .object({ + label: z.string().describe('The display text for this option'), + description: z + .string() + .optional() + .describe('Explanation shown when option is focused'), + }) + .array() + .refine((opts) => opts.length >= 2, { + message: 'Each question must have at least 2 options', + }), + ) .describe('Array of answer options with label and optional description.'), multiSelect: z @@ -64,8 +68,12 @@ const endsAgentStep = true const inputSchema = z .object({ questions: z - .array(questionSchema) - .min(1, 'Must provide at least one question') + .preprocess( + coerceToArray, + z + .array(questionSchema) + .min(1, 'Must provide at least one question'), + ) .describe('List of multiple choice questions to ask the user'), }) .describe( @@ -110,6 +118,8 @@ The user can either: - Type a custom answer in the "Other" text field - Skip the questions to provide different instructions instead +IMPORTANT: Do NOT include options like "Custom", "Other", "None of the above", or similar catch-all options. The UI automatically provides a "Custom" text input field for users to type their own answer. Including such options would be redundant and confusing. + Single-select example: ${$getNativeToolCallExampleString({ toolName, diff --git a/common/src/tools/params/tool/gravity-index.ts b/common/src/tools/params/tool/gravity-index.ts new file mode 100644 index 0000000000..e63b0d021a --- /dev/null +++ b/common/src/tools/params/tool/gravity-index.ts @@ -0,0 +1,91 @@ +import z from 'zod/v4' + +import { gravityIndexInputSchema } from '../../../types/gravity-index' +import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' +import { jsonObjectSchema } from '../../../types/json' + +import type { $ToolParams } from '../../constants' + +const toolName = 'gravity_index' +const endsAgentStep = true + +const description = ` +Purpose: Use the Gravity Index to discover, inspect, and report integrations for third-party developer services such as databases, auth, payments, hosting, email, cache, monitoring, analytics, AI, storage, CMS, search, realtime, background jobs, infrastructure, CRM, support, productivity, commerce, video, webhooks, and SMS. + +Choose the action: +- \`search\`: Use when the user asks for a recommendation or when you need to choose a provider before integrating it. Returns a reasoned recommendation with install guidance, env vars, and a setup/conversion URL. Include stack and constraints in \`query\`. Pass \`search_id\` from a previous search for follow-up questions. +- \`browse\`: Use to list catalog services by \`category\` and/or keyword \`q\`. Good when the user wants options or a category-scoped picker. +- \`list_categories\`: Use to see available categories and service counts. +- \`get_service\`: Use when you already know a service slug and need full detail, env vars, website, docs URL, and install metadata. +- \`report_integration\`: Use after you have actually completed and verified an integration from a previous search. Pass the original \`search_id\` and the service slug as \`integrated_slug\`. + +Important setup-link behavior: +- Search results include \`conversion_url\`, the setup link the user should visit to create an account and get API credentials. +- Always show this link prominently as "Get your {service.name} API key" when credentials are needed. +- If the user is at the step of integrating a new service and credentials are the next required action, use \`render_ui\` when available to show a primary button labeled "Get your {service.name} API key" with \`conversion_url\` as the link. +- Do not replace it with the vendor homepage and do not auto-follow it. +- Ask the user to paste the resulting credentials back so you can finish setup. + +Implementation guidance: +- Gravity can help select a provider and identify required env vars, but install steps may be high-level. Use the returned \`docs_url\`, existing codebase conventions, and package/docs research to perform the actual integration. +- For browsing results, use \`get_service\` on promising slugs before making a final recommendation if details matter. + +Examples: +${$getNativeToolCallExampleString({ + toolName, + inputSchema: gravityIndexInputSchema, + input: { + action: 'search', + query: + 'transactional email API with a generous free tier for a Next.js app', + }, + endsAgentStep, +})} + +${$getNativeToolCallExampleString({ + toolName, + inputSchema: gravityIndexInputSchema, + input: { + action: 'browse', + category: 'Email', + q: 'send', + }, + endsAgentStep, +})} + +${$getNativeToolCallExampleString({ + toolName, + inputSchema: gravityIndexInputSchema, + input: { + action: 'get_service', + slug: 'sendgrid', + }, + endsAgentStep, +})} + +${$getNativeToolCallExampleString({ + toolName, + inputSchema: gravityIndexInputSchema, + input: { + action: 'report_integration', + search_id: 'search_id_from_previous_search', + integrated_slug: 'sendgrid', + }, + endsAgentStep, +})} +`.trim() + +export const gravityIndexParams = { + toolName, + endsAgentStep, + description, + inputSchema: gravityIndexInputSchema, + outputSchema: jsonToolResultSchema( + z.union([ + jsonObjectSchema, + z.object({ + errorMessage: z.string(), + }), + ]), + ), +} satisfies $ToolParams diff --git a/common/src/tools/params/tool/propose-str-replace.ts b/common/src/tools/params/tool/propose-str-replace.ts index 15915e7c34..ab86885d7a 100644 --- a/common/src/tools/params/tool/propose-str-replace.ts +++ b/common/src/tools/params/tool/propose-str-replace.ts @@ -1,6 +1,11 @@ import z from 'zod/v4' -import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' +import { + $getNativeToolCallExampleString, + coerceToArray, + jsonToolResultSchema, + normalizeReplacementAliases, +} from '../utils' import type { $ToolParams } from '../../constants' @@ -25,34 +30,43 @@ const inputSchema = z .min(1, 'Path cannot be empty') .describe(`The path to the file to edit.`), replacements: z - .array( + .preprocess( + coerceToArray, z - .object({ - old: z - .string() - .min(1, 'Old cannot be empty') - .describe( - `The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation.`, - ), - new: z - .string() - .describe( - `The string to replace the corresponding old string with. Can be empty to delete.`, - ), - allowMultiple: z - .boolean() - .optional() - .default(false) - .describe( - 'Whether to allow multiple replacements of old string.', - ), - }) - .describe('Pair of old and new strings.'), + .array( + z + .preprocess( + normalizeReplacementAliases, + z.object({ + oldString: z + .string() + .min(1, 'oldString cannot be empty') + .describe( + `The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation.`, + ), + newString: z + .string() + .describe( + `The string to replace the corresponding oldString with. Can be empty to delete.`, + ), + allowMultiple: z + .boolean() + .optional() + .default(false) + .describe( + 'Whether to allow multiple replacements of oldString.', + ), + }), + ) + .describe('Pair of oldString and newString values.'), + ) + .min(1, 'Replacements cannot be empty'), ) - .min(1, 'Replacements cannot be empty') .describe('Array of replacements to make.'), }) - .describe(`Propose string replacements in a file without actually applying them.`) + .describe( + `Propose string replacements in a file without actually applying them.`, + ) const description = ` Propose edits to a file without actually applying them. Use this tool when you want to draft changes that will be reviewed before being applied. @@ -65,10 +79,13 @@ ${$getNativeToolCallExampleString({ input: { path: 'path/to/file', replacements: [ - { old: 'This is the old string', new: 'This is the new string' }, { - old: '\nfoo:', - new: '\nbar:', + oldString: 'This is the old string', + newString: 'This is the new string', + }, + { + oldString: '\nfoo:', + newString: '\nbar:', allowMultiple: true, }, ], diff --git a/common/src/tools/params/tool/read-files.ts b/common/src/tools/params/tool/read-files.ts index 3f757aa9bc..23814bc0e1 100644 --- a/common/src/tools/params/tool/read-files.ts +++ b/common/src/tools/params/tool/read-files.ts @@ -1,6 +1,6 @@ import z from 'zod/v4' -import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' +import { $getNativeToolCallExampleString, coerceToArray, jsonToolResultSchema } from '../utils' import type { $ToolParams } from '../../constants' @@ -21,13 +21,16 @@ const endsAgentStep = true const inputSchema = z .object({ paths: z - .array( - z - .string() - .min(1, 'Paths cannot be empty') - .describe( - `File path to read relative to the **project root**. Absolute file paths will not work.`, - ), + .preprocess( + coerceToArray, + z.array( + z + .string() + .min(1, 'Paths cannot be empty') + .describe( + `File path to read. Prefer paths relative to the **project root**; absolute paths inside the project are accepted, but paths outside the project will not work.`, + ), + ), ) .describe('List of file paths to read.'), }) diff --git a/common/src/tools/params/tool/read-subtree.ts b/common/src/tools/params/tool/read-subtree.ts index ab6df242af..a88358e5f8 100644 --- a/common/src/tools/params/tool/read-subtree.ts +++ b/common/src/tools/params/tool/read-subtree.ts @@ -1,6 +1,6 @@ import z from 'zod/v4' -import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' +import { $getNativeToolCallExampleString, coerceToArray, jsonToolResultSchema } from '../utils' import type { $ToolParams } from '../../constants' @@ -9,7 +9,7 @@ const endsAgentStep = true const inputSchema = z .object({ paths: z - .array(z.string()) + .preprocess(coerceToArray, z.array(z.string())) .optional() .describe( `List of paths to directories or files. Relative to the project root. If omitted, the entire project tree is used.`, diff --git a/common/src/tools/params/tool/read-url.ts b/common/src/tools/params/tool/read-url.ts new file mode 100644 index 0000000000..fc7069d65a --- /dev/null +++ b/common/src/tools/params/tool/read-url.ts @@ -0,0 +1,81 @@ +import z from 'zod/v4' + +import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' + +import type { $ToolParams } from '../../constants' + +const toolName = 'read_url' +const endsAgentStep = true +const inputSchema = z + .object({ + url: z + .url() + .refine((value) => { + try { + const parsedUrl = new URL(value) + return ( + parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:' + ) + } catch { + return false + } + }, 'URL must use http:// or https://') + .describe( + 'The full http:// or https:// URL to fetch and extract readable text from.', + ), + max_chars: z + .number() + .int() + .min(1_000) + .max(50_000) + .default(20_000) + .optional() + .describe( + 'Maximum number of extracted text characters to return. Defaults to 20000.', + ), + }) + .describe('Fetch a URL and extract readable text from the page.') + +const description = ` +Purpose: Fetch a URL returned by web_search and extract the readable page text so you can answer with source-backed evidence. + +Use this after web_search when snippets are not enough. Prefer authoritative, relevant pages from the search results. The tool follows redirects, extracts titles and metadata, strips scripts/styles/navigation boilerplate from HTML, and returns normalized readable text. + +Do not use run_terminal_command with curl just to inspect web pages; use read_url instead. If read_url reports unsupported content or extraction failure, then choose a different search result or explain the limitation. + +Example: +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + url: 'https://react.dev/reference/react/useActionState', + max_chars: 12000, + }, + endsAgentStep, +})} +`.trim() + +export const readUrlParams = { + toolName, + endsAgentStep, + description, + inputSchema, + outputSchema: jsonToolResultSchema( + z.union([ + z.object({ + url: z.string(), + finalUrl: z.string(), + status: z.number(), + contentType: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + text: z.string(), + truncated: z.boolean(), + }), + z.object({ + url: z.string().optional(), + errorMessage: z.string(), + }), + ]), + ), +} satisfies $ToolParams diff --git a/common/src/tools/params/tool/render-ui.ts b/common/src/tools/params/tool/render-ui.ts new file mode 100644 index 0000000000..183d3ab090 --- /dev/null +++ b/common/src/tools/params/tool/render-ui.ts @@ -0,0 +1,97 @@ +import z from 'zod/v4' + +import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' + +import type { $ToolParams } from '../../constants' + +const toolName = 'render_ui' +const endsAgentStep = false + +const buttonLinkSchema = z + .string() + .url() + .refine( + (value) => { + try { + const url = new URL(value) + return url.protocol === 'https:' || url.protocol === 'http:' + } catch { + return false + } + }, + { message: 'Button links must use http:// or https://' }, + ) + +const buttonWidgetSchema = z.object({ + type: z + .literal('button') + .describe('Widget type. Currently, the only supported widget is button.'), + text: z + .string() + .min(1) + .max(80) + .describe('Short button label shown to the user.'), + link: buttonLinkSchema.describe( + 'The http:// or https:// URL to open when the user clicks the button.', + ), + variant: z + .enum(['primary', 'secondary']) + .optional() + .default('primary') + .describe( + 'Theme-aware color treatment. Use primary for the main action and secondary for lower-emphasis actions.', + ), +}) + +export type RenderUIButtonWidget = z.infer + +const widgetSchema = z.discriminatedUnion('type', [buttonWidgetSchema]) + +const inputSchema = z + .object({ + widget: widgetSchema.describe('The UI widget to render.'), + }) + .describe( + 'Render a small interactive UI widget in the Codebuff CLI. Currently supports a button that opens a link.', + ) + +const outputSchema = z.object({ + message: z.string(), +}) + +const description = ` +Render a small interactive UI widget in the Codebuff CLI. + +Currently supported widgets: +- button: renders a clickable button with text and an http(s) link. + +Use this when the user should click a clear action, such as opening a generated report, documentation page, checkout page, deployment URL, preview, or dashboard. + +Color variants: +- primary: the main action +- secondary: a lower-emphasis action + +Keep button text short and action-oriented. + +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + widget: { + type: 'button', + text: 'Open preview', + link: 'https://example.com/preview', + variant: 'primary', + }, + }, + endsAgentStep, +})} +`.trim() + +export const renderUIParams = { + toolName, + endsAgentStep, + description, + inputSchema, + outputSchema: jsonToolResultSchema(outputSchema), +} satisfies $ToolParams diff --git a/common/src/tools/params/tool/set-output.ts b/common/src/tools/params/tool/set-output.ts index d9a69ea5da..1171f63dc3 100644 --- a/common/src/tools/params/tool/set-output.ts +++ b/common/src/tools/params/tool/set-output.ts @@ -6,6 +6,21 @@ import type { $ToolParams } from '../../constants' const toolName = 'set_output' const endsAgentStep = false + +// WHY `data` EXISTS IN THE INPUT SCHEMA: +// Subagents inherit their parent's tool definitions, and because of prompt caching +// we cannot modify or add tools mid-conversation. OpenAI models enforce the tool's +// input schema strictly, so we need a permissive shape that any model can call. +// An empty schema or `z.object({}).passthrough()` would be rejected by OpenAI's +// strict schema enforcement. The `data: z.record(...)` field is a deliberately +// vague shape that satisfies OpenAI while allowing us to inject the real +// outputSchema later in the conversation (in the instructions prompt). +// +// At runtime, the handler (`packages/agent-runtime/src/tools/handlers/tool/set-output.ts`) +// tries parsing against the real outputSchema in two ways: +// 1. Parse the raw output (agent passed fields at top level) +// 2. Fallback: parse `output.data` (agent wrapped fields in `data`) +// This means both `{ results: [...] }` and `{ data: { results: [...] } }` are accepted. const inputSchema = z .looseObject({ data: z.record(z.string(), z.any()).optional(), diff --git a/common/src/tools/params/tool/skill.ts b/common/src/tools/params/tool/skill.ts new file mode 100644 index 0000000000..a8640d6481 --- /dev/null +++ b/common/src/tools/params/tool/skill.ts @@ -0,0 +1,59 @@ +import z from 'zod/v4' + +import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' + +import type { $ToolParams } from '../../constants' + +const toolName = 'skill' +const endsAgentStep = true + +const inputSchema = z + .object({ + name: z + .string() + .min(1) + .describe('The name of the skill to load'), + }) + .describe( + 'Load a skill by name to get its full instructions. Skills provide reusable behaviors and instructions.', + ) + +const outputValueSchema = z.object({ + name: z.string(), + description: z.string(), + content: z.string(), + license: z.string().optional(), +}) + +/** + * Placeholder marker that will be replaced with the actual available skills XML. + * This is replaced at runtime when generating tool prompts. + */ +export const AVAILABLE_SKILLS_PLACEHOLDER = '{{AVAILABLE_SKILLS}}' + +// Base description - the full description with available skills is generated dynamically +const baseDescription = `Load a skill by name to get its full instructions. Skills provide reusable behaviors and domain-specific knowledge that you can use to complete tasks. + +The following are the pre-loaded skills available at session start: +${AVAILABLE_SKILLS_PLACEHOLDER} + +Note: You can also load any skill that was created during this session by specifying its name. The skill will be loaded dynamically from disk. + +Example: +${$getNativeToolCallExampleString({ + toolName, + inputSchema, + input: { + name: 'git-release', + }, + endsAgentStep, +})} +` + +export const skillParams = { + toolName, + endsAgentStep, + description: baseDescription.trim(), + inputSchema, + outputSchema: jsonToolResultSchema(outputValueSchema), +} satisfies $ToolParams diff --git a/common/src/tools/params/tool/spawn-agent-inline.ts b/common/src/tools/params/tool/spawn-agent-inline.ts index 60e2345943..f52cc2f92f 100644 --- a/common/src/tools/params/tool/spawn-agent-inline.ts +++ b/common/src/tools/params/tool/spawn-agent-inline.ts @@ -2,6 +2,7 @@ import z from 'zod/v4' import { $getNativeToolCallExampleString, + coerceToObject, textToolResultSchema, } from '../utils' @@ -14,7 +15,7 @@ const inputSchema = z agent_type: z.string().describe('Agent to spawn'), prompt: z.string().optional().describe('Prompt to send to the agent'), params: z - .record(z.string(), z.any()) + .preprocess(coerceToObject, z.record(z.string(), z.any())) .optional() .describe('Parameters object for the agent (if any)'), }) diff --git a/common/src/tools/params/tool/spawn-agents.ts b/common/src/tools/params/tool/spawn-agents.ts index 90e1965e0f..5c4d1436ca 100644 --- a/common/src/tools/params/tool/spawn-agents.ts +++ b/common/src/tools/params/tool/spawn-agents.ts @@ -1,7 +1,12 @@ import z from 'zod/v4' import { jsonObjectSchema } from '../../../types/json' -import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' +import { + $getNativeToolCallExampleString, + coerceToArray, + coerceToObject, + jsonToolResultSchema, +} from '../utils' import type { $ToolParams } from '../../constants' @@ -16,16 +21,88 @@ const toolName = 'spawn_agents' const endsAgentStep = true const inputSchema = z .object({ - agents: z - .object({ - agent_type: z.string().describe('Agent to spawn'), - prompt: z.string().optional().describe('Prompt to send to the agent'), - params: z - .record(z.string(), z.any()) - .optional() - .describe('Parameters object for the agent (if any)'), - }) - .array(), + agents: z.preprocess( + coerceToArray, + z + .object({ + agent_type: z.string().describe('Agent to spawn'), + prompt: z.string().optional().describe('Prompt to send to the agent'), + params: z + .preprocess( + coerceToObject, + z + .object({ + // Common agent fields (all optional hints — each agent validates its own required fields) + command: z + .string() + .optional() + .describe('Terminal command to run (basher, tmux-cli)'), + what_to_summarize: z + .string() + .optional() + .describe( + 'What information from the command output is desired (basher)', + ), + timeout_seconds: z + .number() + .optional() + .describe( + 'Timeout for command. Set to -1 for no timeout. Default 30 (basher)', + ), + searchQueries: z + .array( + z.object({ + pattern: z + .string() + .describe('The pattern to search for'), + flags: z + .string() + .optional() + .describe( + 'Optional ripgrep flags (e.g., "-i", "-g *.ts")', + ), + cwd: z + .string() + .optional() + .describe( + 'Optional working directory relative to project root', + ), + maxResults: z + .number() + .optional() + .describe('Max results per file. Default 15'), + }), + ) + .optional() + .describe('Array of code search queries (code-searcher)'), + filePaths: z + .array(z.string()) + .optional() + .describe( + 'Relevant file paths to read (opus-agent, gpt-5-agent)', + ), + directories: z + .array(z.string()) + .optional() + .describe('Directories to search within (file-picker)'), + url: z + .string() + .optional() + .describe('Starting URL to navigate to (browser-use)'), + prompts: z + .array(z.string()) + .optional() + .describe( + 'Array of strategy prompts (editor-multi-prompt, code-reviewer-multi-prompt)', + ), + }) + .catchall(z.any()), + ) + .optional() + .describe('Parameters object for the agent'), + }) + .array(), + ), }) .describe( `Spawn multiple agents and send a prompt and/or parameters to each of them. These agents will run in parallel. Note that that means they will run independently. If you need to run agents sequentially, use spawn_agents with one agent at a time instead.`, @@ -37,9 +114,11 @@ The prompt field is a simple string, while params is a JSON object that gets val Each agent available is already defined as another tool, or, dynamically defined later in the conversation. -You can call agents either as direct tool calls (e.g., \`example-agent\`) or use \`spawn_agents\`. Both formats work, but **prefer using spawn_agents** because it allows you to spawn multiple agents in parallel for better performance. Both use the same schema with nested \`prompt\` and \`params\` fields. +**IMPORTANT**: \`agent_type\` must be an actual agent name (e.g., \`basher\`, \`code-searcher\`, \`opus-agent\`), NOT a tool name like \`read_files\`, \`str_replace\`, \`code_search\`, etc. If you need to call a tool, use it directly as a tool call instead of wrapping it in spawn_agents. -**IMPORTANT**: Many agents have REQUIRED fields in their params schema. Check the agent's schema before spawning - if params has required fields, you MUST include them in the params object. For example, code-searcher requires \`searchQueries\`, commander requires \`command\`. +You can call agents either as direct tool calls (using the listed tool name, e.g. \`example_agent\`) or use \`spawn_agents\` with the canonical agent name in \`agent_type\` (e.g. \`example-agent\`). Both formats work, but **prefer using spawn_agents** because it allows you to spawn multiple agents in parallel for better performance. Both use the same schema with nested \`prompt\` and \`params\` fields. + +**IMPORTANT**: Many agents have REQUIRED fields in their params schema. Check the agent's schema before spawning - if params has required fields, you MUST include them in the params object. For example, code-searcher requires \`searchQueries\`, basher requires \`command\`. Example: ${$getNativeToolCallExampleString({ @@ -48,7 +127,7 @@ ${$getNativeToolCallExampleString({ input: { agents: [ { - agent_type: 'commander', + agent_type: 'basher', prompt: 'Check if tests pass', params: { command: 'npm test', diff --git a/common/src/tools/params/tool/str-replace.ts b/common/src/tools/params/tool/str-replace.ts index b02ce1e81c..1c697913c9 100644 --- a/common/src/tools/params/tool/str-replace.ts +++ b/common/src/tools/params/tool/str-replace.ts @@ -1,6 +1,11 @@ import z from 'zod/v4' -import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' +import { + $getNativeToolCallExampleString, + coerceToArray, + jsonToolResultSchema, + normalizeReplacementAliases, +} from '../utils' import type { $ToolParams } from '../../constants' @@ -8,7 +13,6 @@ export const updateFileResultSchema = z.union([ z.object({ file: z.string(), message: z.string(), - unifiedDiff: z.string(), }), z.object({ file: z.string(), @@ -26,36 +30,43 @@ const inputSchema = z .min(1, 'Path cannot be empty') .describe(`The path to the file to edit.`), replacements: z - .array( + .preprocess( + coerceToArray, z - .object({ - old: z - .string() - .min(1, 'Old cannot be empty') - .describe( - `The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation.`, - ), - new: z - .string() - .describe( - `The string to replace the corresponding old string with. Can be empty to delete.`, - ), - allowMultiple: z - .boolean() - .optional() - .default(false) - .describe( - 'Whether to allow multiple replacements of old string.', - ), - }) - .describe('Pair of old and new strings.'), + .array( + z + .preprocess( + normalizeReplacementAliases, + z.object({ + oldString: z + .string() + .min(1, 'oldString cannot be empty') + .describe( + `The string to replace. This must be an *exact match* of the string you want to replace, including whitespace and punctuation.`, + ), + newString: z + .string() + .describe( + `The string to replace the corresponding oldString with. Can be empty to delete.`, + ), + allowMultiple: z + .boolean() + .optional() + .default(false) + .describe( + 'Whether to allow multiple replacements of oldString.', + ), + }), + ) + .describe('Pair of oldString and newString values.'), + ) + .min(1, 'Replacements cannot be empty'), ) - .min(1, 'Replacements cannot be empty') .describe('Array of replacements to make.'), }) .describe(`Replace strings in a file with new strings.`) const description = ` -Use this tool to make edits within existing files. Prefer this tool over the write_file tool for existing files, unless you need to make major changes throughout the file, in which case use write_file. +Use this tool to make edits within existing files. Important: If you are making multiple edits in a row to a file, use only one str_replace call with multiple replacements instead of multiple str_replace tool calls. @@ -67,14 +78,18 @@ ${$getNativeToolCallExampleString({ input: { path: 'path/to/file', replacements: [ - { old: 'This is the old string', new: 'This is the new string' }, { - old: '\n\t\t// @codebuff delete this log line please\n\t\tconsole.log("Hello, world!");\n', - new: '\n', + oldString: 'This is the old string', + newString: 'This is the new string', }, { - old: '\nfoo:', - new: '\nbar:', + oldString: + '\n\t\t// @codebuff delete this log line please\n\t\tconsole.log("Hello, world!");\n', + newString: '\n', + }, + { + oldString: '\nfoo:', + newString: '\nbar:', allowMultiple: true, }, ], diff --git a/common/src/tools/params/tool/suggest-followups.ts b/common/src/tools/params/tool/suggest-followups.ts index 5a03cff1c0..23bcb3ac0e 100644 --- a/common/src/tools/params/tool/suggest-followups.ts +++ b/common/src/tools/params/tool/suggest-followups.ts @@ -1,6 +1,6 @@ import z from 'zod/v4' -import { $getNativeToolCallExampleString, jsonToolResultSchema } from '../utils' +import { $getNativeToolCallExampleString, coerceToArray, jsonToolResultSchema } from '../utils' import type { $ToolParams } from '../../constants' @@ -24,8 +24,12 @@ export type SuggestFollowup = z.infer const inputSchema = z .object({ followups: z - .array(followupSchema) - .min(1, 'Must provide at least one followup') + .preprocess( + coerceToArray, + z + .array(followupSchema) + .min(1, 'Must provide at least one followup'), + ) .describe( 'List of suggested followup prompts the user can click to send', ), diff --git a/common/src/tools/params/tool/web-search.ts b/common/src/tools/params/tool/web-search.ts index e87c8f2715..ba705295c0 100644 --- a/common/src/tools/params/tool/web-search.ts +++ b/common/src/tools/params/tool/web-search.ts @@ -20,9 +20,9 @@ const inputSchema = z `Search depth - 'standard' for quick results, 'deep' for more comprehensive search. Default is 'standard'.`, ), }) - .describe(`Search the web for current information using Linkup API.`) + .describe(`Search the web for current information using Serper API.`) const description = ` -Purpose: Search the web for current, up-to-date information on any topic. This tool uses Linkup's web search API to find relevant content from across the internet. +Purpose: Search the web for current, up-to-date information on any topic. This tool uses Serper's Google Search API to find relevant content from across the internet. Use cases: - Finding current information about technologies, libraries, or frameworks @@ -31,7 +31,7 @@ Use cases: - Finding examples and tutorials - Checking current status of services or APIs -The tool will return search results with titles, URLs, and content snippets. +The tool will return JSON search results with titles, URLs, content snippets, and other available SERP fields such as answer boxes or related questions. Example: ${$getNativeToolCallExampleString({ diff --git a/common/src/tools/params/tool/write-file.ts b/common/src/tools/params/tool/write-file.ts index cf50fee058..c2867c6ab0 100644 --- a/common/src/tools/params/tool/write-file.ts +++ b/common/src/tools/params/tool/write-file.ts @@ -16,24 +16,16 @@ const inputSchema = z instructions: z .string() .describe('What the change is intended to do in only one sentence.'), - content: z.string().describe(`Edit snippet to apply to the file.`), + content: z.string().describe(`Complete file content to write to the file.`), }) - .describe(`Create or edit a file with the given content.`) + .describe(`Create or overwrite a file with the given content.`) const description = ` Create or replace a file with the given content. -#### Edit Snippet - -Format the \`content\` parameter with the entire content of the file or as an edit snippet that describes how you would like to modify the provided existing code. - -You may abbreviate any sections of the code in your response that will remain the same with placeholder comments: "// ... existing code ...". Abbreviate as much as possible to save the user credits! - -If you don't use any placeholder comments, the entire file will be replaced. E.g. don't write out a single function without using placeholder comments unless you want to replace the entire file with that function. +Format the \`content\` parameter with the entire content of the file. #### Additional Info -Prefer str_replace to write_file for most edits, including small-to-medium edits to a file, for deletions, or for editing large files (>1000 lines). Otherwise, prefer write_file for major edits throughout a file, or for creating new files. - Do not use this tool to delete or rename a file. Instead run a terminal command for that. Examples: @@ -50,28 +42,21 @@ ${$getNativeToolCallExampleString({ endsAgentStep, })} -Example 2 - Editing with placeholder comments: +Example 2 - Overwriting a file: ${$getNativeToolCallExampleString({ toolName, inputSchema, input: { path: 'foo.ts', - instructions: 'Update foo and remove console.log', - content: `// ... existing code ... - -function foo() { - console.log('foo'); - for (let i = 0; i < 10; i++) { - console.log(i); - } - doSomething(); - - // Delete the console.log line from here - - doSomethingElse(); + instructions: 'Update foo function', + content: `function foo() { + doSomethingNew(); } - -// ... existing code ...`, + +function bar() { + doSomethingOld(); +} +`, }, endsAgentStep, })} diff --git a/common/src/tools/params/tool/write-todos.ts b/common/src/tools/params/tool/write-todos.ts index 0a40200fe5..ba0f4a34e3 100644 --- a/common/src/tools/params/tool/write-todos.ts +++ b/common/src/tools/params/tool/write-todos.ts @@ -1,6 +1,6 @@ import z from 'zod/v4' -import { $getNativeToolCallExampleString } from '../utils' +import { $getNativeToolCallExampleString, coerceToArray } from '../utils' import type { $ToolParams } from '../../constants' @@ -9,11 +9,14 @@ const endsAgentStep = false const inputSchema = z .object({ todos: z - .array( - z.object({ - task: z.string().describe('Description of the task'), - completed: z.boolean().describe('Whether the task is completed'), - }), + .preprocess( + coerceToArray, + z.array( + z.object({ + task: z.string().describe('Description of the task'), + completed: z.boolean().describe('Whether the task is completed'), + }), + ), ) .describe( "List of todos with their completion status. Add ALL of the applicable tasks to the list, so you don't forget to do anything. Try to order the todos the same way you will complete them. Do not mark todos as completed if you have not completed them yet!", diff --git a/common/src/tools/params/utils.ts b/common/src/tools/params/utils.ts index 1c27d0097d..cf6dcf8a3e 100644 --- a/common/src/tools/params/utils.ts +++ b/common/src/tools/params/utils.ts @@ -10,6 +10,78 @@ import { import type { JSONValue } from '../../types/json' import type { ToolResultOutput } from '../../types/messages/content-part' +/** + * Coerces a value into an array if it isn't one already. + * Handles common LLM mistakes: + * - Single object/string passed instead of an array → wraps in array + * - Stringified JSON array passed as a string → parses it + * - Already an array → passes through + * - null/undefined → passes through (let Zod handle it) + */ +export function coerceToArray(val: unknown): unknown { + if (Array.isArray(val)) return val + if (typeof val === 'string') { + try { + const parsed = JSON.parse(val) + if (Array.isArray(parsed)) return parsed + } catch { + // Not valid JSON — fall through to wrap + } + } + if (val != null) return [val] + return val +} + +/** + * Coerces a stringified JSON object into an object. + * This is intentionally narrow so malformed values still fail validation. + */ +export function coerceToObject(val: unknown): unknown { + if (typeof val !== 'string') { + return val + } + + try { + const parsed = JSON.parse(val) + if ( + parsed != null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ) { + return parsed + } + } catch { + // Leave the original value untouched so schema validation can reject it. + } + + return val +} + +/** + * Handles common replacement-key aliases emitted by some models while keeping + * the documented schema stable. + */ +export function normalizeReplacementAliases(val: unknown): unknown { + if (val === null || typeof val !== 'object' || Array.isArray(val)) { + return val + } + + const replacement = { ...(val as Record) } + for (const [target, aliases] of [ + ['oldString', ['old', 'old_str', 'old_string']], + ['newString', ['new', 'new_str', 'new_string']], + ] as const) { + if (replacement[target] !== undefined) { + continue + } + const alias = aliases.find((key) => typeof replacement[key] === 'string') + if (alias) { + replacement[target] = replacement[alias] + } + } + return replacement +} + /** Only used for generating tool call strings before all tools are defined. * * @param toolName - The name of the tool to call diff --git a/common/src/types/contracts/agent-runtime.ts b/common/src/types/contracts/agent-runtime.ts index dca59fa171..1cffe133dd 100644 --- a/common/src/types/contracts/agent-runtime.ts +++ b/common/src/types/contracts/agent-runtime.ts @@ -1,6 +1,5 @@ import type { TrackEventFn } from './analytics' import type { ConsumeCreditsWithFallbackFn } from './billing' -import type { ClientEnv, CiEnv } from './env' import type { HandleStepsLogChunkFn, RequestFilesFn, @@ -18,6 +17,7 @@ import type { GetUserInfoFromApiKeyFn, StartAgentRunFn, } from './database' +import type { ClientEnv, CiEnv } from './env' import type { PromptAiSdkFn, PromptAiSdkStreamFn, diff --git a/common/src/types/contracts/analytics.ts b/common/src/types/contracts/analytics.ts index bf4b5b38ed..cc042dbb30 100644 --- a/common/src/types/contracts/analytics.ts +++ b/common/src/types/contracts/analytics.ts @@ -1,5 +1,5 @@ -import type { AnalyticsEvent } from '../../constants/analytics-events' import type { Logger } from './logger' +import type { AnalyticsEvent } from '../../constants/analytics-events' export type TrackEventFn = (params: { event: AnalyticsEvent diff --git a/common/src/types/contracts/bigquery.ts b/common/src/types/contracts/bigquery.ts index c996995bdb..36f6c896dc 100644 --- a/common/src/types/contracts/bigquery.ts +++ b/common/src/types/contracts/bigquery.ts @@ -21,3 +21,35 @@ export type InsertMessageBigqueryFn = (params: { dataset?: string logger: Logger }) => Promise + +export type ChatCompletionTraceRow = { + id: string + user_id: string + client_id?: string | null + trace_session_id: string + trace_lineage_id: string + run_id: string + agent_id: string + created_at: Date + model: string + cost_mode?: string | null + request: unknown + message_count: number + message_start_index: number + message_delta_count: number + previous_message_count?: number | null + common_prefix_length: number + cache_hit: boolean + full_snapshot: boolean + messages: unknown[] + delta_message_hashes: string[] + tool_count: number + tools?: unknown[] | null + tools_omitted: boolean +} + +export type InsertChatCompletionTraceBigqueryFn = (params: { + row: ChatCompletionTraceRow + dataset?: string + logger: Logger +}) => Promise diff --git a/common/src/types/contracts/billing.ts b/common/src/types/contracts/billing.ts index dca0e740c8..af0cc028ec 100644 --- a/common/src/types/contracts/billing.ts +++ b/common/src/types/contracts/billing.ts @@ -4,6 +4,7 @@ import type { ErrorOr } from '../../util/error' export type GetUserUsageDataFn = (params: { userId: string logger: Logger + includeSubscriptionCredits?: boolean }) => Promise<{ usageThisCycle: number balance: { @@ -11,6 +12,7 @@ export type GetUserUsageDataFn = (params: { totalDebt: number netBalance: number breakdown: Record + principals: Record } nextQuotaReset: string autoTopupTriggered?: boolean diff --git a/common/src/types/contracts/database.ts b/common/src/types/contracts/database.ts index c7250c3470..bcb29b74aa 100644 --- a/common/src/types/contracts/database.ts +++ b/common/src/types/contracts/database.ts @@ -5,17 +5,17 @@ type User = { id: string email: string discord_id: string | null - referral_code: string | null stripe_customer_id: string | null banned: boolean + created_at: Date } export const userColumns = [ 'id', 'email', 'discord_id', - 'referral_code', 'stripe_customer_id', 'banned', + 'created_at', ] as const export type UserColumn = keyof User export type GetUserInfoFromApiKeyInput = { @@ -35,6 +35,7 @@ export type GetUserInfoFromApiKeyFn = ( type AgentRun = { agent_id: string + ancestor_run_ids: string[] status: 'running' | 'completed' | 'failed' | 'cancelled' } export type AgentRunColumn = keyof AgentRun diff --git a/common/src/types/contracts/llm.ts b/common/src/types/contracts/llm.ts index 19b9e1abc2..11c5a5ba0c 100644 --- a/common/src/types/contracts/llm.ts +++ b/common/src/types/contracts/llm.ts @@ -1,11 +1,11 @@ import type { TrackEventFn } from './analytics' import type { SendActionFn } from './client' -import type { OpenRouterProviderRoutingOptions } from '../agent-template' +import type { OpenRouterProviderRoutingOptions , AgentTemplate } from '../agent-template' import type { ParamsExcluding } from '../function-params' import type { Logger } from './logger' import type { Model } from '../../old-constants' import type { Message } from '../messages/codebuff-message' -import type { AgentTemplate } from '../agent-template' +import type { PromptResult } from '../../util/error' import type { generateText, streamText, ToolCallPart } from 'ai' import type z from 'zod/v4' @@ -25,6 +25,13 @@ export type StreamChunk = > | { type: 'error'; message: string } +export type CacheDebugUsageData = { + inputTokens: number + outputTokens: number + cachedInputTokens: number + totalTokens: number +} + export type PromptAiSdkStreamFn = ( params: { apiKey: string @@ -40,18 +47,31 @@ export type PromptAiSdkStreamFn = ( agentId?: string maxRetries?: number onCostCalculated?: (credits: number) => Promise + onCacheDebugProviderRequestBuilt?: (params: { + provider: string + rawBody: unknown + normalizedBody?: unknown + }) => void + onCacheDebugUsageReceived?: (usage: CacheDebugUsageData) => void includeCacheControl?: boolean + cacheDebugCorrelation?: string agentProviderOptions?: OpenRouterProviderRoutingOptions /** List of agents that can be spawned - used to transform agent tool calls */ spawnableAgents?: string[] /** Map of locally available agent templates - used to transform agent tool calls */ localAgentTemplates?: Record + /** Cost mode - 'free' mode means 0 credits charged for all agents */ + costMode?: string + /** Extra key/values merged into the request's `codebuff_metadata` field. + * Used to forward client-scoped identifiers (e.g. `freebuff_instance_id`) + * that server-side gates read from the chat-completions body. */ + extraCodebuffMetadata?: Record sendAction: SendActionFn logger: Logger trackEvent: TrackEventFn signal: AbortSignal } & ParamsExcluding, -) => AsyncGenerator +) => AsyncGenerator> export type PromptAiSdkFn = ( params: { @@ -66,16 +86,25 @@ export type PromptAiSdkFn = ( chargeUser?: boolean agentId?: string onCostCalculated?: (credits: number) => Promise + onCacheDebugProviderRequestBuilt?: (params: { + provider: string + rawBody: unknown + normalizedBody?: unknown + }) => void + onCacheDebugUsageReceived?: (usage: CacheDebugUsageData) => void includeCacheControl?: boolean + cacheDebugCorrelation?: string agentProviderOptions?: OpenRouterProviderRoutingOptions maxRetries?: number + /** Cost mode - 'free' mode means 0 credits charged for all agents */ + costMode?: string sendAction: SendActionFn logger: Logger trackEvent: TrackEventFn n?: number signal: AbortSignal } & ParamsExcluding, -) => Promise +) => Promise> export type PromptAiSdkStructuredInput = { apiKey: string @@ -93,7 +122,14 @@ export type PromptAiSdkStructuredInput = { chargeUser?: boolean agentId?: string onCostCalculated?: (credits: number) => Promise + onCacheDebugProviderRequestBuilt?: (params: { + provider: string + rawBody: unknown + normalizedBody?: unknown + }) => void + onCacheDebugUsageReceived?: (usage: CacheDebugUsageData) => void includeCacheControl?: boolean + cacheDebugCorrelation?: string agentProviderOptions?: OpenRouterProviderRoutingOptions maxRetries?: number sendAction: SendActionFn @@ -101,7 +137,7 @@ export type PromptAiSdkStructuredInput = { trackEvent: TrackEventFn signal: AbortSignal } -export type PromptAiSdkStructuredOutput = Promise +export type PromptAiSdkStructuredOutput = Promise> export type PromptAiSdkStructuredFn = ( params: PromptAiSdkStructuredInput, ) => PromptAiSdkStructuredOutput diff --git a/common/src/types/filesystem.ts b/common/src/types/filesystem.ts index be662fd60e..6fa64e1168 100644 --- a/common/src/types/filesystem.ts +++ b/common/src/types/filesystem.ts @@ -6,5 +6,5 @@ import type fs from 'fs' */ export type CodebuffFileSystem = Pick< typeof fs.promises, - 'mkdir' | 'readdir' | 'readFile' | 'stat' | 'writeFile' + 'mkdir' | 'readdir' | 'readFile' | 'stat' | 'unlink' | 'writeFile' > diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts new file mode 100644 index 0000000000..9263b9ae5c --- /dev/null +++ b/common/src/types/freebuff-session.ts @@ -0,0 +1,247 @@ +import type { FreebuffAccessTier } from '../constants/freebuff-models' + +/** + * Wire-level shapes returned by `/api/v1/freebuff/session`. Source of truth + * for the CLI (which deserializes these) and the server (which serializes + * them) — keep both in sync by importing this module from either side. + * + * The CLI uses these shapes directly; there are no client-only states. + */ + +/** + * Usage counter surfaced to the CLI so the waiting-room UI can render + * "N of M sessions used" alongside queue/active state. Present when the + * joined model consumes premium Freebuff sessions. `recentCount` is the + * rounded session units since the last midnight Pacific reset at the time + * the response was produced — see also the standalone `rate_limited` status + * for the reject path. + */ +export interface FreebuffSessionRateLimit { + model: string + limit: number + period: 'pacific_day' + resetTimeZone: string + resetAt: string + /** Deprecated wire field kept for older clients. Premium usage now resets + * at midnight Pacific time rather than using a rolling window. */ + windowHours: number + recentCount: number +} + +export type FreebuffSessionRateLimitByModel = Record< + string, + FreebuffSessionRateLimit +> + +/** Pull the per-model premium quota snapshot off whichever session statuses + * carry it (queued, active, ended, none). Returns undefined for terminal / + * pre-join states that have no quota field. The parameter is intentionally + * loose so the CLI can pass its `FreebuffSessionResponse` (which adds the + * client-only `takeover_prompt` variant) without a discriminated-union + * ceremony at every call site. */ +export const getRateLimitsByModel = ( + session: { status: string } | null | undefined, +): FreebuffSessionRateLimitByModel | undefined => + session && 'rateLimitsByModel' in session + ? (session as { rateLimitsByModel?: FreebuffSessionRateLimitByModel }) + .rateLimitsByModel + : undefined + +export type FreebuffCountryBlockReason = + | 'country_not_allowed' + | 'anonymized_or_unknown_country' + | 'anonymous_network' + | 'missing_client_ip' + | 'unresolved_client_ip' + | 'ip_privacy_lookup_failed' + +export type FreebuffIpPrivacySignal = + | 'anonymous' + | 'vpn' + | 'proxy' + | 'tor' + | 'relay' + | 'res_proxy' + | 'hosting' + | 'service' + +export type FreebuffSpurStatus = + | 'not_checked' + | 'clean' + | 'suspicious' + | 'failed' + +export type FreebuffPrivacyDecision = + | 'allowed_clean' + | 'ipinfo_suspicious_spur_clean' + | 'corroborated_block' + | 'cloudflare_tor_block' + | 'spur_failed_limited' + | 'ipinfo_failed_limited' + | 'limited_other' + +export type FreebuffPrivacyProviderDecision = + | 'not_checked' + | 'cloudflare_tor' + | 'ipinfo_clean' + | 'ipinfo_failed' + | 'ipinfo_only' + | 'spur_failed' + | 'corroborated_soft' + | 'corroborated_hard' + +export interface FreebuffLimitedModeReason { + /** Present for limited access so the model picker can explain why the + * reduced model set is shown without re-running geo/IP logic locally. */ + countryCode?: string | null + countryBlockReason?: FreebuffCountryBlockReason | null + ipPrivacySignals?: FreebuffIpPrivacySignal[] | null +} + +export type FreebuffSessionServerResponse = + | { + /** Waiting room is globally off; free-mode requests flow through + * unchanged. Client should treat this as "admitted forever". */ + status: 'disabled' + } + | ({ + /** User has no session row. CLI must POST to (re-)queue. Also returned + * when `getSessionState` notices the user has been swept past the + * grace window. */ + status: 'none' + accessTier?: FreebuffAccessTier + message?: string + /** Snapshot of every model's queue depth at GET time. The picker no + * longer renders this (queues effectively never form at current + * traffic), but it's still surfaced for diagnostics and future use. + * Present on GET responses; not returned from POST (POST never + * produces `none`). */ + queueDepthByModel?: Record + /** Current quota snapshots for premium models, keyed by model id. Lets + * the picker show today's premium-session usage before the user commits + * to a queue. */ + rateLimitsByModel?: FreebuffSessionRateLimitByModel + } & FreebuffLimitedModeReason) + | ({ + status: 'queued' + accessTier: FreebuffAccessTier + instanceId: string + /** Model the user is queued for. Each model has its own queue. */ + model: string + /** 1-indexed position in the queue for `model`. */ + position: number + queueDepth: number + /** Current depth of every model's queue. Retained for diagnostics — + * the CLI no longer renders per-row queue hints. Models with no + * queued rows at snapshot time may be absent; treat a missing entry + * as 0. */ + queueDepthByModel: Record + estimatedWaitMs: number + queuedAt: string + /** Premium-session quota for this model. Absent for unlimited models. */ + rateLimit?: FreebuffSessionRateLimit + rateLimitsByModel?: FreebuffSessionRateLimitByModel + } & FreebuffLimitedModeReason) + | ({ + status: 'active' + accessTier: FreebuffAccessTier + instanceId: string + /** Model the active session is bound to — cannot change mid-session. */ + model: string + admittedAt: string + expiresAt: string + remainingMs: number + /** Premium-session quota for this model. Absent for unlimited models. */ + rateLimit?: FreebuffSessionRateLimit + rateLimitsByModel?: FreebuffSessionRateLimitByModel + } & FreebuffLimitedModeReason) + | ({ + /** Session is over. While `instanceId` is present we're inside the + * server-side grace window — chat requests still go through so the + * agent can finish, but the CLI must not accept new prompts. Once + * `instanceId` is absent the session is fully gone and the user must + * rejoin via POST. + * + * Server-supplied form (in-grace) carries the timing fields; the + * client may also synthesize a no-grace `{ status: 'ended' }` when a + * poll reveals the row was swept. Both render the same UI. */ + status: 'ended' + accessTier?: FreebuffAccessTier + instanceId?: string + admittedAt?: string + expiresAt?: string + gracePeriodEndsAt?: string + gracePeriodRemainingMs?: number + /** Snapshot of the user's premium-session quota at the moment the + * session ended. Lets the post-session banner show "N of M premium + * sessions used today" without an extra round-trip. */ + rateLimitsByModel?: FreebuffSessionRateLimitByModel + } & FreebuffLimitedModeReason) + | { + /** Another CLI on the same account rotated our instance id. Polling + * stops and the UI shows a "close the other CLI" screen. The server + * returns this from GET /session when the caller's instance id + * doesn't match the stored one; the chat-completions gate also + * surfaces it as a 409 for fast in-flight feedback. */ + status: 'superseded' + } + | { + /** Request originated outside the free-mode allowlist, or from an + * unknown/anonymized location that cannot be trusted for free mode. + * Returned before queue admission so users don't wait through the + * room only to be rejected on their first chat request. Terminal — + * CLI stops polling and shows a "not available in your country" + * screen. `countryCode` is the resolved country, or UNKNOWN. */ + status: 'country_blocked' + message?: string + countryCode: string + countryBlockReason?: FreebuffCountryBlockReason + ipPrivacySignals?: FreebuffIpPrivacySignal[] + } + | { + /** User has an active session bound to a different model. Returned + * from POST /session when they pick a new model without ending their + * current session first. The CLI shows a confirmation prompt: "End + * your active DeepSeek session to switch?" → on confirm, DELETE then + * re-POST with the new model. */ + status: 'model_locked' + accessTier?: FreebuffAccessTier + currentModel: string + requestedModel: string + } + | { + /** Requested model is valid but not selectable right now. */ + status: 'model_unavailable' + accessTier?: FreebuffAccessTier + requestedModel: string + availableHours: string + } + | { + /** Account is banned. Returned from every endpoint so banned bots can't + * join the queue at all (otherwise they inflate `queueDepth` until the + * 15s admission tick's `evictBanned` sweeps them). Terminal — CLI + * stops polling and shows a banned message. */ + status: 'banned' + } + | { + /** User has used up their shared premium-session quota for the current + * Pacific day. Returned from POST /session before the user is placed in + * the queue. `retryAfterMs` is the time until the next midnight Pacific + * reset. Terminal for the CLI's current poll session; the user can exit + * and come back later. */ + status: 'rate_limited' + accessTier?: FreebuffAccessTier + /** The freebuff model the user tried to join. */ + model: string + /** Max premium session units permitted per Pacific day (e.g. 5). */ + limit: number + period: 'pacific_day' + resetTimeZone: string + resetAt: string + /** Deprecated wire field kept for older clients. */ + windowHours: number + /** Premium session units since today's Pacific reset — will be ≥ limit. */ + recentCount: number + /** Milliseconds from now until the next Pacific midnight reset. */ + retryAfterMs: number + } diff --git a/common/src/types/grant.ts b/common/src/types/grant.ts index 93d708cb6c..7c056f34a1 100644 --- a/common/src/types/grant.ts +++ b/common/src/types/grant.ts @@ -1,6 +1,8 @@ export type GrantType = | 'free' | 'referral' + | 'referral_legacy' + | 'subscription' | 'purchase' | 'admin' | 'organization' @@ -9,6 +11,8 @@ export type GrantType = export const GrantTypeValues = [ 'free', 'referral', + 'referral_legacy', + 'subscription', 'purchase', 'admin', 'organization', diff --git a/common/src/types/gravity-index.ts b/common/src/types/gravity-index.ts new file mode 100644 index 0000000000..f0d8c2aeba --- /dev/null +++ b/common/src/types/gravity-index.ts @@ -0,0 +1,75 @@ +import z from 'zod/v4' + +import { jsonObjectSchema } from './json' + +export const gravityIndexInputSchema = z + .discriminatedUnion('action', [ + z.object({ + action: z.literal('search').describe('Search for the best service.'), + query: z + .string() + .min(1, 'Query cannot be empty') + .max(1000, 'Query cannot exceed 1000 characters') + .describe( + `What the user needs, including stack, constraints, and required capabilities when known. Example: "serverless database with branching for a Next.js app".`, + ), + search_id: z + .string() + .optional() + .describe('Continue a previous Gravity Index search as a follow-up.'), + context: jsonObjectSchema + .optional() + .describe( + 'Optional structured JSON context about the project, stack, or constraints.', + ), + }), + z.object({ + action: z + .literal('browse') + .describe('Browse catalog services by category and/or keyword.'), + category: z + .string() + .optional() + .describe( + 'Optional category filter, e.g. Database, Auth, Payments, Hosting, Email, Cache, Monitoring, Analytics, AI, Storage, CMS, Search, Realtime, Background Jobs, Infrastructure, CRM, Support, Productivity, Commerce, Video, Webhooks, SMS.', + ), + q: z + .string() + .optional() + .describe('Optional keyword filter, e.g. sendgrid or postgres.'), + }), + z.object({ + action: z + .literal('list_categories') + .describe('List every category with service counts.'), + }), + z.object({ + action: z + .literal('get_service') + .describe('Fetch full detail for a single service by slug.'), + slug: z + .string() + .min(1, 'Slug cannot be empty') + .describe('Service slug, e.g. supabase, stripe, sendgrid.'), + }), + z.object({ + action: z + .literal('report_integration') + .describe('Report that an integration from a prior search was done.'), + search_id: z + .string() + .min(1, 'search_id cannot be empty') + .describe('search_id from the earlier search result.'), + integrated_slug: z + .string() + .min(1, 'integrated_slug cannot be empty') + .describe('Slug of the service that was actually integrated.'), + }), + ]) + .describe(`Use the Gravity Index catalog and conversion API.`) + +export type GravityIndexInput = z.infer + +export const gravityIndexActionRequiresApiKey = ( + action: GravityIndexInput['action'], +) => action === 'search' || action === 'report_integration' diff --git a/common/src/types/session-state.ts b/common/src/types/session-state.ts index 40e9707e4a..a116a5cdeb 100644 --- a/common/src/types/session-state.ts +++ b/common/src/types/session-state.ts @@ -68,6 +68,10 @@ export const AgentOutputSchema = z.discriminatedUnion('type', [ type: z.literal('error'), message: z.string(), statusCode: z.number().optional(), + error: z.string().optional(), + countryCode: z.string().optional(), + countryBlockReason: z.string().optional(), + ipPrivacySignals: z.array(z.string()).optional(), }), ]) export type AgentOutput = z.infer @@ -75,7 +79,7 @@ export type AgentOutput = z.infer export const AgentTemplateTypeList = [ // Base agents 'base', - 'base_lite', + 'base_free', 'base_max', 'base_experimental', 'claude4_gemini_thinking', diff --git a/common/src/types/skill.ts b/common/src/types/skill.ts new file mode 100644 index 0000000000..c89a24cb94 --- /dev/null +++ b/common/src/types/skill.ts @@ -0,0 +1,56 @@ +import { z } from 'zod/v4' + +import { + SKILL_NAME_MAX_LENGTH, + SKILL_NAME_REGEX, + SKILL_DESCRIPTION_MAX_LENGTH, +} from '../constants/skills' + +/** + * Zod schema for skill frontmatter metadata. + */ +export const SkillMetadataSchema = z.record(z.string(), z.string()) + +/** + * Zod schema for skill frontmatter (parsed from YAML). + */ +export const SkillFrontmatterSchema = z.object({ + name: z + .string() + .min(1) + .max(SKILL_NAME_MAX_LENGTH) + .regex( + SKILL_NAME_REGEX, + 'Name must be lowercase alphanumeric with single hyphen separators', + ), + description: z.string().min(1).max(SKILL_DESCRIPTION_MAX_LENGTH), + license: z.string().optional(), + metadata: SkillMetadataSchema.optional(), +}) + +export type SkillFrontmatter = z.infer + +/** + * Full skill definition including content and source path. + */ +export const SkillDefinitionSchema = z.object({ + /** Skill name (must match directory name) */ + name: z.string(), + /** Short description for agent discovery */ + description: z.string(), + /** Optional license */ + license: z.string().optional(), + /** Optional key-value metadata */ + metadata: SkillMetadataSchema.optional(), + /** Full SKILL.md content (including frontmatter) */ + content: z.string(), + /** Source file path */ + filePath: z.string(), +}) + +export type SkillDefinition = z.infer + +/** + * Collection of skills keyed by skill name. + */ +export type SkillsMap = Record diff --git a/common/src/types/subscription.ts b/common/src/types/subscription.ts new file mode 100644 index 0000000000..714bdf24ec --- /dev/null +++ b/common/src/types/subscription.ts @@ -0,0 +1,67 @@ +/** + * Core subscription information for an active subscription. + */ +export interface SubscriptionInfo { + id: string + status: string + billingPeriodEnd: string + cancelAtPeriodEnd: boolean + canceledAt: string | null + tier: number + scheduledTier?: number | null +} + +/** + * Rate limit information for subscription usage. + */ +export interface SubscriptionRateLimit { + limited: boolean + reason?: 'block_exhausted' | 'weekly_limit' + canStartNewBlock: boolean + blockUsed?: number + blockLimit?: number + blockResetsAt?: string + weeklyUsed: number + weeklyLimit: number + weeklyResetsAt: string + weeklyPercentUsed: number +} + +/** + * Subscription limits configuration. + */ +export interface SubscriptionLimits { + creditsPerBlock: number + blockDurationHours: number + weeklyCreditsLimit: number +} + +/** + * Response when user has no active subscription. + */ +export interface NoSubscriptionResponse { + hasSubscription: false + /** Whether user prefers to fallback to a-la-carte credits when subscription limits are reached */ + fallbackToALaCarte: boolean +} + +/** + * Response when user has an active subscription. + * All fields are required - no invalid states possible. + */ +export interface ActiveSubscriptionResponse { + hasSubscription: true + displayName: string + subscription: SubscriptionInfo + rateLimit: SubscriptionRateLimit + limits: SubscriptionLimits + + /** Whether user prefers to fallback to a-la-carte credits when subscription limits are reached */ + fallbackToALaCarte: boolean +} + +/** + * Discriminated union for subscription API response. + * Use `hasSubscription` to narrow the type. + */ +export type SubscriptionResponse = NoSubscriptionResponse | ActiveSubscriptionResponse diff --git a/common/src/util/__tests__/analytics-sampling.test.ts b/common/src/util/__tests__/analytics-sampling.test.ts new file mode 100644 index 0000000000..9fcb8fc6c3 --- /dev/null +++ b/common/src/util/__tests__/analytics-sampling.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it } from 'bun:test' + +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' + +import { + isFullTelemetryEnabled, + shouldTrackAnalyticsEvent, + summarizeAnalyticsValue, +} from '../analytics-sampling' + +const ORIGINAL_ENV = { + CODEBUFF_FULL_TELEMETRY: process.env.CODEBUFF_FULL_TELEMETRY, + CODEBUFF_FULL_TELEMETRY_IDS: process.env.CODEBUFF_FULL_TELEMETRY_IDS, + CODEBUFF_FULL_TELEMETRY_USER_IDS: + process.env.CODEBUFF_FULL_TELEMETRY_USER_IDS, +} + +function restoreEnv() { + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +} + +describe('analytics sampling', () => { + afterEach(() => { + restoreEnv() + }) + + it('always tracks core CLI lifecycle events', () => { + expect( + shouldTrackAnalyticsEvent({ + event: AnalyticsEvent.APP_LAUNCHED, + distinctId: 'user-1', + }), + ).toBe(true) + expect( + shouldTrackAnalyticsEvent({ + event: AnalyticsEvent.USER_INPUT_COMPLETE, + distinctId: 'user-1', + }), + ).toBe(true) + }) + + it('always tracks CLI error logs', () => { + expect( + shouldTrackAnalyticsEvent({ + event: AnalyticsEvent.CLI_LOG, + distinctId: 'user-1', + properties: { level: 'error' }, + }), + ).toBe(true) + }) + + it('samples high-volume events deterministically', () => { + const first = shouldTrackAnalyticsEvent({ + event: AnalyticsEvent.TOOL_USE, + distinctId: 'user-1', + }) + const second = shouldTrackAnalyticsEvent({ + event: AnalyticsEvent.TOOL_USE, + distinctId: 'user-1', + }) + const otherEvent = shouldTrackAnalyticsEvent({ + event: AnalyticsEvent.AGENT_STEP, + distinctId: 'user-1', + }) + + expect(second).toBe(first) + expect(typeof otherEvent).toBe('boolean') + }) + + it('honors full telemetry env flags and allowlists', () => { + process.env.CODEBUFF_FULL_TELEMETRY = 'true' + expect( + isFullTelemetryEnabled({ + distinctId: 'anyone', + }), + ).toBe(true) + + delete process.env.CODEBUFF_FULL_TELEMETRY + process.env.CODEBUFF_FULL_TELEMETRY_IDS = 'user-2,person@example.com' + + expect( + isFullTelemetryEnabled({ + distinctId: 'user-2', + }), + ).toBe(true) + expect( + isFullTelemetryEnabled({ + properties: { userEmail: 'person@example.com' }, + }), + ).toBe(true) + expect( + isFullTelemetryEnabled({ + distinctId: 'user-3', + }), + ).toBe(false) + }) + + it('summarizes values without retaining raw contents', () => { + expect(summarizeAnalyticsValue('secret text')).toEqual({ + kind: 'string', + length: 11, + }) + expect(summarizeAnalyticsValue(['a', 'b'])).toEqual({ + kind: 'array', + length: 2, + }) + expect(summarizeAnalyticsValue({ prompt: 'secret', count: 1 })).toEqual({ + kind: 'object', + keyCount: 2, + keys: ['prompt', 'count'], + }) + }) +}) diff --git a/common/src/util/__tests__/error-abort.test.ts b/common/src/util/__tests__/error-abort.test.ts new file mode 100644 index 0000000000..59b1423250 --- /dev/null +++ b/common/src/util/__tests__/error-abort.test.ts @@ -0,0 +1,774 @@ +import { describe, expect, it } from 'bun:test' + +import { + ABORT_ERROR_MESSAGE, + AbortError, + isAbortError, + promptAborted, + promptSuccess, + unwrapPromptResult, + type PromptResult, +} from '../error' + +describe('AbortError class', () => { + describe('constructor', () => { + it('creates error without reason', () => { + const error = new AbortError() + expect(error.message).toBe(ABORT_ERROR_MESSAGE) + expect(error.name).toBe('AbortError') + }) + + it('creates error with reason', () => { + const error = new AbortError('User cancelled') + expect(error.message).toBe(`${ABORT_ERROR_MESSAGE}: User cancelled`) + expect(error.name).toBe('AbortError') + }) + + it('creates error with empty string reason', () => { + const error = new AbortError('') + // Empty string is falsy, so no reason appended + expect(error.message).toBe(ABORT_ERROR_MESSAGE) + }) + + it('is instanceof Error', () => { + const error = new AbortError() + expect(error instanceof Error).toBe(true) + expect(error instanceof AbortError).toBe(true) + }) + + it('has stack trace', () => { + const error = new AbortError('test') + expect(error.stack).toBeDefined() + expect(error.stack).toContain('AbortError') + }) + }) + + describe('message format', () => { + it('reason is appended after colon and space', () => { + const error = new AbortError('timeout') + expect(error.message).toBe('Request aborted: timeout') + }) + + it('preserves special characters in reason', () => { + const error = new AbortError('User pressed Ctrl+C') + expect(error.message).toBe('Request aborted: User pressed Ctrl+C') + }) + + it('handles multi-line reason', () => { + const error = new AbortError('First line\nSecond line') + expect(error.message).toBe('Request aborted: First line\nSecond line') + }) + }) +}) + +describe('isAbortError edge cases', () => { + describe('message matching with startsWith', () => { + it('returns true for exact ABORT_ERROR_MESSAGE', () => { + const error = new Error(ABORT_ERROR_MESSAGE) + expect(isAbortError(error)).toBe(true) + }) + + it('returns true for message with suffix after ABORT_ERROR_MESSAGE (like AbortError with reason)', () => { + // This is the format AbortError uses: 'Request aborted: reason' + const error = new Error(`${ABORT_ERROR_MESSAGE}: timeout`) + expect(isAbortError(error)).toBe(true) + }) + + it('returns false for message with non-colon suffix after ABORT_ERROR_MESSAGE', () => { + // Only 'Request aborted' or 'Request aborted: ' should match + // Other patterns like 'Request aborted by user' should NOT match + const error = new Error(`${ABORT_ERROR_MESSAGE} due to user action`) + expect(isAbortError(error)).toBe(false) + }) + + it('returns false for message containing ABORT_ERROR_MESSAGE as substring (not prefix)', () => { + const error = new Error(`Error: ${ABORT_ERROR_MESSAGE} by system`) + expect(isAbortError(error)).toBe(false) + }) + + it('returns false for message with prefix before ABORT_ERROR_MESSAGE', () => { + const error = new Error(`Something failed: ${ABORT_ERROR_MESSAGE}`) + expect(isAbortError(error)).toBe(false) + }) + }) + + describe('case sensitivity', () => { + it('returns false for lowercase version of message', () => { + const error = new Error('request aborted') + expect(isAbortError(error)).toBe(false) + }) + + it('returns false for uppercase version of message', () => { + const error = new Error('REQUEST ABORTED') + expect(isAbortError(error)).toBe(false) + }) + + it('returns false for mixed case version of message', () => { + const error = new Error('Request Aborted') + expect(isAbortError(error)).toBe(false) + }) + }) + + describe('AbortError name detection', () => { + it('returns true for Error with name set to AbortError', () => { + const error = new Error('Some other message') + error.name = 'AbortError' + expect(isAbortError(error)).toBe(true) + }) + + it('returns false for name containing AbortError as substring', () => { + const error = new Error('test') + error.name = 'MyAbortErrorClass' + expect(isAbortError(error)).toBe(false) + }) + + it('returns false for lowercase aborterror name', () => { + const error = new Error('test') + error.name = 'aborterror' + expect(isAbortError(error)).toBe(false) + }) + }) + + describe('DOMException handling', () => { + it('returns true for DOMException with name AbortError', () => { + const error = new DOMException('The operation was aborted', 'AbortError') + expect(isAbortError(error)).toBe(true) + }) + + it('returns true for DOMException with signal abort message', () => { + const error = new DOMException( + 'signal is aborted without reason', + 'AbortError', + ) + expect(isAbortError(error)).toBe(true) + }) + + it('returns false for DOMException with different name', () => { + const error = new DOMException('test', 'NotFoundError') + expect(isAbortError(error)).toBe(false) + }) + }) + + describe('Error subclasses', () => { + it('returns true for AbortError instance', () => { + const error = new AbortError('test reason') + expect(isAbortError(error)).toBe(true) + }) + + it('returns true for TypeError with AbortError name', () => { + const error = new TypeError('test') + error.name = 'AbortError' + expect(isAbortError(error)).toBe(true) + }) + + it('returns false for custom error class without AbortError characteristics', () => { + class CustomError extends Error { + constructor(message: string) { + super(message) + this.name = 'CustomError' + } + } + // Note: Using a message that's similar but NOT exact match to ABORT_ERROR_MESSAGE + const error = new CustomError('Request was aborted by user') + expect(isAbortError(error)).toBe(false) + }) + + it('returns true for custom error class with AbortError name', () => { + class MyAbortError extends Error { + constructor() { + super('custom message') + this.name = 'AbortError' + } + } + const error = new MyAbortError() + expect(isAbortError(error)).toBe(true) + }) + }) + + describe('non-Error types', () => { + it('returns false for string', () => { + expect(isAbortError(ABORT_ERROR_MESSAGE)).toBe(false) + }) + + it('returns false for object with message property', () => { + expect(isAbortError({ message: ABORT_ERROR_MESSAGE })).toBe(false) + }) + + it('returns false for object with name property', () => { + expect(isAbortError({ name: 'AbortError' })).toBe(false) + }) + + it('returns false for null', () => { + expect(isAbortError(null)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isAbortError(undefined)).toBe(false) + }) + + it('returns false for number', () => { + expect(isAbortError(42)).toBe(false) + }) + + it('returns false for array', () => { + expect(isAbortError([ABORT_ERROR_MESSAGE])).toBe(false) + }) + + it('returns false for function', () => { + expect(isAbortError(() => ABORT_ERROR_MESSAGE)).toBe(false) + }) + }) +}) + +describe('unwrapPromptResult with AbortError', () => { + describe('successful results', () => { + it('returns value for successful result', () => { + const result = promptSuccess('test value') + expect(unwrapPromptResult(result)).toBe('test value') + }) + + it('returns null for successful null result', () => { + const result = promptSuccess(null) + expect(unwrapPromptResult(result)).toBeNull() + }) + + it('returns undefined for successful undefined result', () => { + const result = promptSuccess(undefined) + expect(unwrapPromptResult(result)).toBeUndefined() + }) + + it('returns complex object for successful result', () => { + const value = { nested: { array: [1, 2, 3] } } + const result = promptSuccess(value) + expect(unwrapPromptResult(result)).toEqual(value) + }) + }) + + describe('aborted results throw AbortError', () => { + it('throws AbortError instance', () => { + const result = promptAborted() + try { + unwrapPromptResult(result) + expect(true).toBe(false) // Should not reach here + } catch (error) { + expect(error instanceof AbortError).toBe(true) + } + }) + + it('thrown error has name AbortError', () => { + const result = promptAborted() + try { + unwrapPromptResult(result) + expect(true).toBe(false) + } catch (error) { + expect((error as Error).name).toBe('AbortError') + } + }) + + it('thrown error includes reason in message', () => { + const result = promptAborted('User cancelled') + try { + unwrapPromptResult(result) + expect(true).toBe(false) + } catch (error) { + expect((error as Error).message).toBe('Request aborted: User cancelled') + } + }) + + it('thrown error is detectable with isAbortError', () => { + const result = promptAborted() + try { + unwrapPromptResult(result) + expect(true).toBe(false) + } catch (error) { + expect(isAbortError(error)).toBe(true) + } + }) + + it('thrown error with reason is detectable with isAbortError', () => { + const result = promptAborted('timeout') + try { + unwrapPromptResult(result) + expect(true).toBe(false) + } catch (error) { + expect(isAbortError(error)).toBe(true) + } + }) + }) +}) + +describe('PromptResult integration patterns', () => { + describe('early return pattern', () => { + async function mockLlmCall(shouldAbort: boolean): Promise> { + if (shouldAbort) { + return promptAborted('User cancelled') + } + return promptSuccess('LLM response') + } + + async function callerWithEarlyReturn(shouldAbort: boolean): Promise { + const result = await mockLlmCall(shouldAbort) + if (result.aborted) { + return null + } + return result.value.toUpperCase() + } + + it('returns transformed value on success', async () => { + const result = await callerWithEarlyReturn(false) + expect(result).toBe('LLM RESPONSE') + }) + + it('returns null on abort', async () => { + const result = await callerWithEarlyReturn(true) + expect(result).toBeNull() + }) + }) + + describe('unwrap with try/catch pattern', () => { + async function mockLlmCall(shouldAbort: boolean): Promise> { + if (shouldAbort) { + return promptAborted('Signal triggered') + } + return promptSuccess('Success response') + } + + async function callerWithUnwrap(shouldAbort: boolean): Promise { + return unwrapPromptResult(await mockLlmCall(shouldAbort)) + } + + async function outerCaller(shouldAbort: boolean): Promise<{ result: string; wasAborted: boolean }> { + try { + const result = await callerWithUnwrap(shouldAbort) + return { result, wasAborted: false } + } catch (error) { + if (isAbortError(error)) { + return { result: '', wasAborted: true } + } + throw error // Rethrow non-abort errors + } + } + + it('returns result on success', async () => { + const { result, wasAborted } = await outerCaller(false) + expect(result).toBe('Success response') + expect(wasAborted).toBe(false) + }) + + it('catches and identifies abort', async () => { + const { result, wasAborted } = await outerCaller(true) + expect(result).toBe('') + expect(wasAborted).toBe(true) + }) + }) + + describe('nested function abort propagation', () => { + async function deepestCall(signal: { aborted: boolean }): Promise> { + if (signal.aborted) { + return promptAborted('Aborted at deepest level') + } + return promptSuccess(42) + } + + async function middleCall(signal: { aborted: boolean }): Promise> { + const result = await deepestCall(signal) + if (result.aborted) { + return result // Propagate abort + } + return promptSuccess(`Value: ${result.value}`) + } + + async function topCall(signal: { aborted: boolean }): Promise> { + const result = await middleCall(signal) + if (result.aborted) { + return result // Propagate abort + } + return promptSuccess([result.value, 'additional']) + } + + it('propagates success through all levels', async () => { + const signal = { aborted: false } + const result = await topCall(signal) + expect(result.aborted).toBe(false) + if (!result.aborted) { + expect(result.value).toEqual(['Value: 42', 'additional']) + } + }) + + it('propagates abort from deepest level', async () => { + const signal = { aborted: true } + const result = await topCall(signal) + expect(result.aborted).toBe(true) + if (result.aborted) { + expect(result.reason).toBe('Aborted at deepest level') + } + }) + }) + + describe('mixed pattern with fallback', () => { + async function primaryProvider(signal: { aborted: boolean }): Promise> { + if (signal.aborted) { + return promptAborted() + } + // Simulate primary provider failure + throw new Error('Primary provider unavailable') + } + + async function fallbackProvider(signal: { aborted: boolean }): Promise> { + if (signal.aborted) { + return promptAborted() + } + return promptSuccess('Fallback result') + } + + async function callWithFallback(signal: { aborted: boolean }): Promise> { + try { + const result = await primaryProvider(signal) + // If aborted, don't try fallback + if (result.aborted) { + return result + } + return result + } catch (error) { + // Don't fall back on abort errors + if (isAbortError(error)) { + throw error + } + // Try fallback for other errors + return fallbackProvider(signal) + } + } + + it('uses fallback on non-abort error', async () => { + const signal = { aborted: false } + const result = await callWithFallback(signal) + expect(result.aborted).toBe(false) + if (!result.aborted) { + expect(result.value).toBe('Fallback result') + } + }) + + it('does not use fallback on abort', async () => { + const signal = { aborted: true } + const result = await callWithFallback(signal) + expect(result.aborted).toBe(true) + }) + }) + + describe('abort during async iteration', () => { + async function* generateValues(signal: { aborted: boolean }): AsyncGenerator> { + for (let i = 0; i < 5; i++) { + if (signal.aborted) { + yield promptAborted(`Aborted at iteration ${i}`) + return + } + yield promptSuccess(i) + } + } + + async function collectValues(signal: { aborted: boolean }): Promise<{ values: number[]; abortedAt?: string }> { + const values: number[] = [] + for await (const result of generateValues(signal)) { + if (result.aborted) { + return { values, abortedAt: result.reason } + } + values.push(result.value) + } + return { values } + } + + it('collects all values when not aborted', async () => { + const signal = { aborted: false } + const { values, abortedAt } = await collectValues(signal) + expect(values).toEqual([0, 1, 2, 3, 4]) + expect(abortedAt).toBeUndefined() + }) + + it('stops iteration on abort', async () => { + const signal = { aborted: false } + // Simulate abort after first value + const generator = generateValues(signal) + const results: number[] = [] + + for await (const result of generator) { + if (result.aborted) break + results.push(result.value) + if (results.length === 2) { + signal.aborted = true + } + } + + expect(results).toEqual([0, 1]) + }) + }) + + describe('rethrow pattern in catch blocks', () => { + async function innerOperation(): Promise> { + return promptAborted('Inner abort') + } + + async function middleOperation(): Promise { + const result = await innerOperation() + return unwrapPromptResult(result) + } + + async function outerOperationBad(): Promise { + try { + return await middleOperation() + } catch (error) { + // BAD: swallows abort error + return 'default value' + } + } + + async function outerOperationGood(): Promise { + try { + return await middleOperation() + } catch (error) { + // GOOD: rethrows abort error + if (isAbortError(error)) { + throw error + } + return 'default value' + } + } + + it('bad pattern swallows abort', async () => { + const result = await outerOperationBad() + // This shows the anti-pattern - abort was swallowed + expect(result).toBe('default value') + }) + + it('good pattern propagates abort', async () => { + await expect(outerOperationGood()).rejects.toThrow(ABORT_ERROR_MESSAGE) + }) + + it('good pattern rethrows AbortError that can be detected', async () => { + try { + await outerOperationGood() + expect(true).toBe(false) // Should not reach + } catch (error) { + expect(isAbortError(error)).toBe(true) + } + }) + }) +}) + +describe('ABORT_ERROR_MESSAGE constant', () => { + it('has expected value', () => { + expect(ABORT_ERROR_MESSAGE).toBe('Request aborted') + }) + + it('is used by AbortError class', () => { + const error = new AbortError() + expect(error.message).toBe(ABORT_ERROR_MESSAGE) + }) + + it('is detected by isAbortError', () => { + const error = new Error(ABORT_ERROR_MESSAGE) + expect(isAbortError(error)).toBe(true) + }) +}) + +describe('AbortController integration', () => { + describe('signal.aborted check pattern', () => { + async function mockLlmCallWithSignal(signal: AbortSignal): Promise> { + if (signal.aborted) { + return promptAborted('Signal was already aborted') + } + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 0)) + if (signal.aborted) { + return promptAborted('Signal aborted during operation') + } + return promptSuccess('Operation completed') + } + + it('returns success when signal is not aborted', async () => { + const controller = new AbortController() + const result = await mockLlmCallWithSignal(controller.signal) + expect(result.aborted).toBe(false) + if (!result.aborted) { + expect(result.value).toBe('Operation completed') + } + }) + + it('returns aborted when signal is pre-aborted', async () => { + const controller = new AbortController() + controller.abort() + const result = await mockLlmCallWithSignal(controller.signal) + expect(result.aborted).toBe(true) + if (result.aborted) { + expect(result.reason).toBe('Signal was already aborted') + } + }) + }) + + describe('sequential operations with abort', () => { + const callLog: string[] = [] + + async function step1(signal: AbortSignal): Promise> { + callLog.push('step1') + if (signal.aborted) return promptAborted('step1 aborted') + return promptSuccess('step1 result') + } + + async function step2(signal: AbortSignal): Promise> { + callLog.push('step2') + if (signal.aborted) return promptAborted('step2 aborted') + return promptSuccess('step2 result') + } + + async function step3(signal: AbortSignal): Promise> { + callLog.push('step3') + if (signal.aborted) return promptAborted('step3 aborted') + return promptSuccess('step3 result') + } + + async function runSequentialSteps(signal: AbortSignal): Promise> { + const results: string[] = [] + + const r1 = await step1(signal) + if (r1.aborted) return r1 + results.push(r1.value) + + const r2 = await step2(signal) + if (r2.aborted) return r2 + results.push(r2.value) + + const r3 = await step3(signal) + if (r3.aborted) return r3 + results.push(r3.value) + + return promptSuccess(results) + } + + it('completes all steps when not aborted', async () => { + callLog.length = 0 + const controller = new AbortController() + const result = await runSequentialSteps(controller.signal) + expect(result.aborted).toBe(false) + if (!result.aborted) { + expect(result.value).toEqual(['step1 result', 'step2 result', 'step3 result']) + } + expect(callLog).toEqual(['step1', 'step2', 'step3']) + }) + + it('stops at first step when pre-aborted', async () => { + callLog.length = 0 + const controller = new AbortController() + controller.abort() + const result = await runSequentialSteps(controller.signal) + expect(result.aborted).toBe(true) + // Only step1 should be called, and it should return aborted immediately + expect(callLog).toEqual(['step1']) + }) + }) + + describe('fallback should NOT occur on abort (user intent)', () => { + let fallbackCalled = false + + async function primaryModel(signal: AbortSignal): Promise> { + if (signal.aborted) { + return promptAborted('User cancelled') + } + return promptSuccess('Primary model response') + } + + async function fallbackModel(signal: AbortSignal): Promise> { + fallbackCalled = true + if (signal.aborted) { + return promptAborted('User cancelled') + } + return promptSuccess('Fallback model response') + } + + async function callWithFallbackOnError( + signal: AbortSignal, + primaryShouldThrowError: boolean, + primaryShouldAbort: boolean, + ): Promise> { + try { + if (primaryShouldThrowError) { + throw new Error('Primary provider unavailable') + } + const primaryResult = primaryShouldAbort + ? promptAborted('User cancelled primary') + : await primaryModel(signal) + + // Key pattern: if aborted, do NOT fall back - abort represents user intent + if (primaryResult.aborted) { + return primaryResult + } + return primaryResult + } catch (error) { + // Don't fall back on abort errors + if (isAbortError(error)) { + throw error + } + // Try fallback for other errors + return fallbackModel(signal) + } + } + + it('returns primary result when not aborted', async () => { + fallbackCalled = false + const controller = new AbortController() + const result = await callWithFallbackOnError(controller.signal, false, false) + expect(result.aborted).toBe(false) + if (!result.aborted) { + expect(result.value).toBe('Primary model response') + } + expect(fallbackCalled).toBe(false) + }) + + it('propagates abort without fallback (respects user intent)', async () => { + fallbackCalled = false + const controller = new AbortController() + const result = await callWithFallbackOnError(controller.signal, false, true) + expect(result.aborted).toBe(true) + // Verify fallback was never called - abort means user wants to stop, not retry + expect(fallbackCalled).toBe(false) + }) + + it('uses fallback on non-abort error', async () => { + fallbackCalled = false + const controller = new AbortController() + const result = await callWithFallbackOnError(controller.signal, true, false) + expect(result.aborted).toBe(false) + if (!result.aborted) { + expect(result.value).toBe('Fallback model response') + } + // Verify fallback WAS called for non-abort error + expect(fallbackCalled).toBe(true) + }) + }) + + describe('DOMException from AbortController', () => { + it('native abort reason is detected by isAbortError', () => { + const controller = new AbortController() + controller.abort() + // When you call controller.abort(), signal.reason becomes a DOMException + // with name 'AbortError' + const reason = controller.signal.reason + expect(reason).toBeInstanceOf(DOMException) + expect(isAbortError(reason)).toBe(true) + }) + + it('custom abort reason string is not detected as AbortError', () => { + const controller = new AbortController() + controller.abort('custom reason string') + // When you provide a reason, signal.reason is that value, not a DOMException + const reason = controller.signal.reason + expect(isAbortError(reason)).toBe(false) // string is not an Error + }) + + it('custom abort reason Error with AbortError name is detected', () => { + const controller = new AbortController() + const customAbortError = new AbortError('custom abort') + controller.abort(customAbortError) + const reason = controller.signal.reason + expect(isAbortError(reason)).toBe(true) + }) + }) +}) diff --git a/common/src/util/__tests__/error-api-details.test.ts b/common/src/util/__tests__/error-api-details.test.ts new file mode 100644 index 0000000000..0e0312275b --- /dev/null +++ b/common/src/util/__tests__/error-api-details.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test' + +import { extractApiErrorDetails } from '../error' + +describe('extractApiErrorDetails', () => { + it('extracts structured details from nested retry errors', () => { + const apiError = new Error('Conflict') as Error & { + statusCode: number + responseBody: string + } + apiError.statusCode = 409 + apiError.responseBody = JSON.stringify({ + error: 'session_superseded', + message: + 'Another instance of freebuff has taken over this session. Only one instance per account is allowed.', + }) + + const retryError = new Error( + 'Failed after 4 attempts. Last error: Conflict', + ) as Error & { + lastError: unknown + errors: unknown[] + } + retryError.name = 'AI_RetryError' + retryError.lastError = apiError + retryError.errors = [apiError] + + expect(extractApiErrorDetails(retryError)).toEqual({ + statusCode: 409, + errorCode: 'session_superseded', + message: + 'Another instance of freebuff has taken over this session. Only one instance per account is allowed.', + }) + }) +}) diff --git a/common/src/util/__tests__/format-code-search.test.ts b/common/src/util/__tests__/format-code-search.test.ts new file mode 100644 index 0000000000..f52e65af17 --- /dev/null +++ b/common/src/util/__tests__/format-code-search.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'bun:test' + +import { formatCodeSearchOutput } from '../format-code-search' + +describe('formatCodeSearchOutput', () => { + it('adds a match count and line labels', () => { + const output = formatCodeSearchOutput( + [ + 'src/a.ts:12:const alpha = true', + 'src/a.ts:18:return alpha', + 'src/b.ts:3:export const beta = false', + ].join('\n'), + { matchCount: 3 }, + ) + + expect(output).toBe( + [ + 'Found 3 matches', + 'src/a.ts:', + ' Line 12: const alpha = true', + ' Line 18: return alpha', + '', + 'src/b.ts:', + ' Line 3: export const beta = false', + ].join('\n'), + ) + }) + + it('uses the provided match count instead of counting context lines', () => { + const output = formatCodeSearchOutput( + [ + 'src/a.ts:10:const before = true', + 'src/a.ts:11:const match = true', + 'src/a.ts:12:const after = true', + ].join('\n'), + { matchCount: 1 }, + ) + + expect(output).toContain('Found 1 matches') + expect(output).toContain(' Line 10: const before = true') + expect(output).toContain(' Line 11: const match = true') + expect(output).toContain(' Line 12: const after = true') + }) + + it('does not count native ripgrep context lines as matches', () => { + const output = formatCodeSearchOutput( + [ + 'src/a.ts-10-const before = true', + 'src/a.ts:11:const match = true', + 'src/a.ts-12-const after = true', + ].join('\n'), + ) + + expect(output).toContain('Found 1 matches') + }) + + it('reports zero matches for empty output', () => { + expect(formatCodeSearchOutput('')).toBe('Found 0 matches') + }) +}) diff --git a/common/src/util/__tests__/messages.test.ts b/common/src/util/__tests__/messages.test.ts index 2c1cb5ad35..873d638246 100644 --- a/common/src/util/__tests__/messages.test.ts +++ b/common/src/util/__tests__/messages.test.ts @@ -13,21 +13,28 @@ import { } from '../messages' import type { Message } from '../../types/messages/codebuff-message' -import type { AssistantModelMessage, ToolResultPart } from 'ai' +import type { ToolResultPart } from 'ai' + +// Test helper types for provider options with cache control +type CacheControlValue = { type: string } +type ProviderWithCacheControl = Record & { + cache_control?: CacheControlValue +} describe('withCacheControl', () => { it('should add cache control to object without providerOptions', () => { - const obj: { providerOptions?: any } = {} + const obj = {} as Parameters[0] const result = withCacheControl(obj) expect(result.providerOptions).toBeDefined() - expect(result.providerOptions?.anthropic?.cache_control).toEqual({ + const resultOptions = result.providerOptions as Record + expect(resultOptions.anthropic?.cache_control).toEqual({ type: 'ephemeral', }) - expect(result.providerOptions?.openrouter?.cache_control).toEqual({ + expect(resultOptions.openrouter?.cache_control).toEqual({ type: 'ephemeral', }) - expect(result.providerOptions?.openaiCompatible?.cache_control).toEqual({ + expect(resultOptions.openaiCompatible?.cache_control).toEqual({ type: 'ephemeral', }) }) @@ -35,21 +42,22 @@ describe('withCacheControl', () => { it('should add cache control to existing providerOptions', () => { const obj = { providerOptions: { - anthropic: { someOtherOption: 'value' } as any, + anthropic: { someOtherOption: 'value' }, }, - } + } as Parameters[0] const result = withCacheControl(obj) - expect((result.providerOptions?.anthropic as any)?.cache_control).toEqual({ + const resultAnthropicOptions = result.providerOptions?.anthropic as ProviderWithCacheControl + expect(resultAnthropicOptions.cache_control).toEqual({ type: 'ephemeral', }) - expect((result.providerOptions?.anthropic as any)?.someOtherOption).toBe( + expect(resultAnthropicOptions.someOtherOption).toBe( 'value', ) }) it('should not mutate original object', () => { - const original: { providerOptions?: any } = {} + const original = {} as Parameters[0] const result = withCacheControl(original) expect(original.providerOptions).toBeUndefined() @@ -57,18 +65,13 @@ describe('withCacheControl', () => { }) it('should handle all three providers', () => { - const obj: { providerOptions?: any } = {} + const obj = {} as Parameters[0] const result = withCacheControl(obj) - expect( - (result.providerOptions?.anthropic as any)?.cache_control?.type, - ).toBe('ephemeral') - expect( - (result.providerOptions?.openrouter as any)?.cache_control?.type, - ).toBe('ephemeral') - expect( - (result.providerOptions?.openaiCompatible as any)?.cache_control?.type, - ).toBe('ephemeral') + const resultOptions = result.providerOptions as Record + expect(resultOptions.anthropic?.cache_control?.type).toBe('ephemeral') + expect(resultOptions.openrouter?.cache_control?.type).toBe('ephemeral') + expect(resultOptions.openaiCompatible?.cache_control?.type).toBe('ephemeral') }) }) @@ -117,7 +120,7 @@ describe('withoutCacheControl', () => { }) it('should handle object with no cache control', () => { - const obj: { providerOptions?: any } = {} + const obj = {} as Parameters[0] const result = withoutCacheControl(obj) expect(result.providerOptions).toBeUndefined() @@ -249,6 +252,38 @@ describe('convertCbToModelMessages', () => { ]) }) + it('should convert tool messages with empty content', () => { + const messages: Message[] = [ + { + role: 'tool', + toolName: 'scraper_page_to_markdown', + toolCallId: 'call_empty', + content: [], + }, + ] + + const result = convertCbToModelMessages({ + messages, + includeCacheControl: false, + }) + + expect(result).toEqual([ + expect.objectContaining({ + role: 'tool', + toolCallId: 'call_empty', + toolName: 'scraper_page_to_markdown', + content: [ + expect.objectContaining({ + type: 'tool-result', + toolCallId: 'call_empty', + toolName: 'scraper_page_to_markdown', + output: { type: 'json', value: '' }, + } satisfies ToolResultPart), + ], + }), + ]) + }) + it('should handle multiple tool outputs', () => { const messages: Message[] = [ { @@ -482,9 +517,9 @@ describe('convertCbToModelMessages', () => { typeof result[2].content !== 'string' && result[2].content.length > 0 ) { - const lastContentPart = result[2].content[result[2].content.length - 1] + const lastContentPart = result[2].content[result[2].content.length - 1] as { providerOptions?: Record } expect( - (lastContentPart as any).providerOptions?.anthropic?.cache_control, + lastContentPart.providerOptions?.anthropic?.cache_control, ).toEqual({ type: 'ephemeral', }) @@ -843,9 +878,10 @@ describe('convertCbToModelMessages', () => { includeCacheControl: false, }) - expect((result[0] as any).tags).toEqual(['custom_tag']) - expect((result[0] as any).timeToLive).toBe('agentStep') - expect((result[0].providerOptions?.anthropic as any)?.someOption).toBe( + const resultMessage = result[0] as { tags?: string[]; timeToLive?: string; providerOptions?: Record } + expect(resultMessage.tags).toEqual(['custom_tag']) + expect(resultMessage.timeToLive).toBe('agentStep') + expect((resultMessage.providerOptions?.anthropic as ProviderWithCacheControl)?.someOption).toBe( 'value', ) }) diff --git a/common/src/util/__tests__/partial-json-delta.test.ts b/common/src/util/__tests__/partial-json-delta.test.ts index 4460c83268..3305cddfbe 100644 --- a/common/src/util/__tests__/partial-json-delta.test.ts +++ b/common/src/util/__tests__/partial-json-delta.test.ts @@ -108,6 +108,38 @@ describe('parsePartialJsonObjectSingle', () => { }) }) + describe('comma search optimization', () => { + it('should efficiently find last valid comma in deeply nested incomplete JSON', () => { + // This tests the O(n) backward comma search optimization + const input = '{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5, "incomplete":' + const result = parsePartialJsonObjectSingle(input) + expect(result).toEqual({ + lastParamComplete: true, + params: { a: 1, b: 2, c: 3, d: 4, e: 5 }, + }) + }) + + it('should handle comma inside string value when searching backwards', () => { + // Comma inside a string should not be treated as a separator + const input = '{"message": "Hello, world", "incomplete":' + const result = parsePartialJsonObjectSingle(input) + expect(result).toEqual({ + lastParamComplete: true, + params: { message: 'Hello, world' }, + }) + }) + + it('should find valid comma after skipping invalid parse attempts', () => { + // Multiple commas, need to find the right one + const input = '{"x": [1, 2, 3], "y": {"a": 1, "b": 2}, "z":' + const result = parsePartialJsonObjectSingle(input) + expect(result).toEqual({ + lastParamComplete: true, + params: { x: [1, 2, 3], y: { a: 1, b: 2 } }, + }) + }) + }) + describe('edge cases', () => { it('should return empty object for empty string', () => { const input = '' diff --git a/common/src/util/__tests__/string.test.ts b/common/src/util/__tests__/string.test.ts index 7fe0ef0b56..3a141ca6b6 100644 --- a/common/src/util/__tests__/string.test.ts +++ b/common/src/util/__tests__/string.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'bun:test' -import { EXISTING_CODE_MARKER } from '../../old-constants' -import { pluralize, replaceNonStandardPlaceholderComments } from '../string' +import { pluralize } from '../string' describe('pluralize', () => { it('should handle singular and plural cases correctly', () => { @@ -238,90 +237,3 @@ describe('pluralize', () => { }) }) -describe('replaceNonStandardPlaceholderComments', () => { - it('should replace C-style comments', () => { - const input = ` -function example() { - // ... some code ... - console.log('Hello'); - // ... rest of the function ... -} -` - const expected = ` -function example() { - ${EXISTING_CODE_MARKER} - console.log('Hello'); - ${EXISTING_CODE_MARKER} -} -` - expect( - replaceNonStandardPlaceholderComments(input, EXISTING_CODE_MARKER), - ).toBe(expected) - }) - - it('should replace multi-line C-style comments', () => { - const input = ` -function example() { - /* ... some code ... */ - console.log('Hello'); - /* ... rest of the function ... */ -} -` - const expected = ` -function example() { - ${EXISTING_CODE_MARKER} - console.log('Hello'); - ${EXISTING_CODE_MARKER} -} -` - expect( - replaceNonStandardPlaceholderComments(input, EXISTING_CODE_MARKER), - ).toBe(expected) - }) - - it('should replace Python-style comments', () => { - const input = ` -def example(): - # ... some code ... - print('Hello') - # ... rest of the function ... -` - const expected = ` -def example(): - ${EXISTING_CODE_MARKER} - print('Hello') - ${EXISTING_CODE_MARKER} -` - expect( - replaceNonStandardPlaceholderComments(input, EXISTING_CODE_MARKER), - ).toBe(expected) - }) - - it('should replace JSX comments', () => { - const input = ` -function Example() { - return ( -
- {/* ... existing code ... */} -

Hello, World!

- {/* ...rest of component... */} -
- ); -} -` - const expected = ` -function Example() { - return ( -
- ${EXISTING_CODE_MARKER} -

Hello, World!

- ${EXISTING_CODE_MARKER} -
- ); -} -` - expect( - replaceNonStandardPlaceholderComments(input, EXISTING_CODE_MARKER), - ).toBe(expected) - }) -}) diff --git a/common/src/util/__tests__/zoned-time.test.ts b/common/src/util/__tests__/zoned-time.test.ts new file mode 100644 index 0000000000..84a0233bd4 --- /dev/null +++ b/common/src/util/__tests__/zoned-time.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test' + +import { getZonedDayBounds } from '../zoned-time' + +describe('getZonedDayBounds', () => { + test('returns the current Pacific day bounds on a normal day', () => { + const bounds = getZonedDayBounds( + new Date('2026-04-17T16:00:00Z'), + 'America/Los_Angeles', + ) + + expect(bounds.startsAt.toISOString()).toBe('2026-04-17T07:00:00.000Z') + expect(bounds.resetsAt.toISOString()).toBe('2026-04-18T07:00:00.000Z') + }) + + test('handles the shorter spring-forward Pacific day', () => { + const bounds = getZonedDayBounds( + new Date('2026-03-08T09:00:00Z'), + 'America/Los_Angeles', + ) + + expect(bounds.startsAt.toISOString()).toBe('2026-03-08T08:00:00.000Z') + expect(bounds.resetsAt.toISOString()).toBe('2026-03-09T07:00:00.000Z') + }) + + test('handles the longer fall-back Pacific day', () => { + const bounds = getZonedDayBounds( + new Date('2026-11-01T09:00:00Z'), + 'America/Los_Angeles', + ) + + expect(bounds.startsAt.toISOString()).toBe('2026-11-01T07:00:00.000Z') + expect(bounds.resetsAt.toISOString()).toBe('2026-11-02T08:00:00.000Z') + }) +}) diff --git a/common/src/util/agent-id-parsing.ts b/common/src/util/agent-id-parsing.ts index dd64bc9832..2a494ad990 100644 --- a/common/src/util/agent-id-parsing.ts +++ b/common/src/util/agent-id-parsing.ts @@ -99,3 +99,38 @@ export function parsePublishedAgentId(fullAgentId: string): { version, } } + +/** + * Normalizes an agent ID for lookup by accepting underscores as aliases for + * hyphens in the agent-name segment. Publisher IDs and version strings are + * preserved as written. + */ +export function normalizeAgentIdForLookup(fullAgentId: string): string { + const parts = fullAgentId.split('/') + if (parts.length > 2) { + return fullAgentId + } + + const normalizeNameWithVersion = (agentNameWithVersion: string) => { + const versionStart = agentNameWithVersion.indexOf('@') + const agentName = + versionStart === -1 + ? agentNameWithVersion + : agentNameWithVersion.slice(0, versionStart) + const version = + versionStart === -1 ? '' : agentNameWithVersion.slice(versionStart) + + return `${agentName.replace(/_/g, '-')}${version}` + } + + if (parts.length === 1) { + return normalizeNameWithVersion(fullAgentId) + } + + const [publisherId, agentNameWithVersion] = parts + if (!publisherId || !agentNameWithVersion) { + return fullAgentId + } + + return `${publisherId}/${normalizeNameWithVersion(agentNameWithVersion)}` +} diff --git a/common/src/util/analytics-dispatcher.ts b/common/src/util/analytics-dispatcher.ts index 43fb5261af..0171c1049c 100644 --- a/common/src/util/analytics-dispatcher.ts +++ b/common/src/util/analytics-dispatcher.ts @@ -1,4 +1,3 @@ -import type { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { getAnalyticsEventId, diff --git a/common/src/util/analytics-sampling.ts b/common/src/util/analytics-sampling.ts new file mode 100644 index 0000000000..4e225bcb96 --- /dev/null +++ b/common/src/util/analytics-sampling.ts @@ -0,0 +1,200 @@ +import { AnalyticsEvent } from '../constants/analytics-events' + +const DEFAULT_SAMPLED_RATE = 0.01 + +const SAMPLED_EVENT_RATES: Partial> = { + [AnalyticsEvent.AGENT_STEP]: DEFAULT_SAMPLED_RATE, + [AnalyticsEvent.CHATGPT_OAUTH_REQUEST]: DEFAULT_SAMPLED_RATE, + [AnalyticsEvent.CLI_LOG]: DEFAULT_SAMPLED_RATE, + [AnalyticsEvent.FEEDBACK_BUTTON_HOVERED]: DEFAULT_SAMPLED_RATE, + [AnalyticsEvent.FOLLOWUP_CLICKED]: DEFAULT_SAMPLED_RATE, + [AnalyticsEvent.SLASH_COMMAND_USED]: DEFAULT_SAMPLED_RATE, + [AnalyticsEvent.SLASH_MENU_ACTIVATED]: DEFAULT_SAMPLED_RATE, + [AnalyticsEvent.TOOL_USE]: DEFAULT_SAMPLED_RATE, +} + +const ALWAYS_TRACK_EVENTS = new Set([ + AnalyticsEvent.APP_LAUNCHED, + AnalyticsEvent.CHANGE_DIRECTORY, + AnalyticsEvent.CHATGPT_OAUTH_AUTH_ERROR, + AnalyticsEvent.CHATGPT_OAUTH_RATE_LIMITED, + AnalyticsEvent.FINGERPRINT_GENERATED, + AnalyticsEvent.INVALID_COMMAND, + AnalyticsEvent.KNOWLEDGE_FILE_UPDATED, + AnalyticsEvent.LOGIN, + AnalyticsEvent.TERMINAL_COMMAND_COMPLETED, + AnalyticsEvent.UPDATE_CODEBUFF_FAILED, + AnalyticsEvent.USER_INPUT, + AnalyticsEvent.USER_INPUT_COMPLETE, +]) + +type AnalyticsProperties = Record | undefined + +function getStringProperty( + properties: AnalyticsProperties, + key: string, +): string | undefined { + const value = properties?.[key] + return typeof value === 'string' && value.trim() ? value : undefined +} + +function getPropertyUserId(properties: AnalyticsProperties): string | undefined { + const direct = + getStringProperty(properties, 'userId') ?? + getStringProperty(properties, 'user_id') ?? + getStringProperty(properties, 'distinct_id') + if (direct) { + return direct + } + + const user = properties?.user + if (user && typeof user === 'object') { + const id = (user as { id?: unknown }).id + return typeof id === 'string' && id.trim() ? id : undefined + } + + return undefined +} + +function splitEnvList(value: string | undefined): Set { + return new Set( + (value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ) +} + +function isTruthyEnv(value: string | undefined): boolean { + return value === '1' || value === 'true' || value === 'yes' +} + +export function isFullTelemetryEnabled(params: { + distinctId?: string + properties?: AnalyticsProperties +}): boolean { + if (isTruthyEnv(process.env.CODEBUFF_FULL_TELEMETRY)) { + return true + } + + const ids = splitEnvList( + process.env.CODEBUFF_FULL_TELEMETRY_IDS ?? + process.env.CODEBUFF_FULL_TELEMETRY_USER_IDS, + ) + if (ids.size === 0) { + return false + } + + const candidates = [ + params.distinctId, + getPropertyUserId(params.properties), + getStringProperty(params.properties, 'userEmail'), + getStringProperty(params.properties, 'email'), + ].filter( + (value): value is string => + typeof value === 'string' && value.length > 0, + ) + + return candidates.some((candidate) => ids.has(candidate)) +} + +function getEventSampleRate( + event: AnalyticsEvent, + properties: AnalyticsProperties, +): number { + const level = getStringProperty(properties, 'level')?.toLowerCase() + if ( + event === AnalyticsEvent.CLI_LOG && + (level === 'error' || level === 'fatal') + ) { + return 1 + } + + if (ALWAYS_TRACK_EVENTS.has(event)) { + return 1 + } + + return SAMPLED_EVENT_RATES[event] ?? 1 +} + +function hashString(input: string): number { + let hash = 2166136261 + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return hash >>> 0 +} + +function getSamplingKey(params: { + event: AnalyticsEvent + distinctId?: string + properties?: AnalyticsProperties +}): string { + return ( + params.distinctId ?? + getPropertyUserId(params.properties) ?? + getStringProperty(params.properties, 'clientSessionId') ?? + getStringProperty(params.properties, 'userInputId') ?? + params.event + ) +} + +export function shouldTrackAnalyticsEvent(params: { + event: AnalyticsEvent + distinctId?: string + properties?: AnalyticsProperties +}): boolean { + if (isFullTelemetryEnabled(params)) { + return true + } + + const rate = getEventSampleRate(params.event, params.properties) + if (rate >= 1) { + return true + } + if (rate <= 0) { + return false + } + + const bucket = + hashString(`${params.event}:${getSamplingKey(params)}`) / 0xffffffff + return bucket < rate +} + +function valueKind(value: unknown): string { + if (Array.isArray(value)) { + return 'array' + } + if (value === null) { + return 'null' + } + return typeof value +} + +export function summarizeAnalyticsValue( + value: unknown, +): Record { + if (value === null || value === undefined) { + return { kind: valueKind(value) } + } + + if (typeof value === 'string') { + return { kind: 'string', length: value.length } + } + + if (Array.isArray(value)) { + return { kind: 'array', length: value.length } + } + + if (typeof value === 'object') { + const keys = Object.keys(value as Record) + return { + kind: 'object', + keyCount: keys.length, + keys: keys.slice(0, 25), + } + } + + return { kind: valueKind(value) } +} diff --git a/common/src/util/cache-debug.ts b/common/src/util/cache-debug.ts new file mode 100644 index 0000000000..0189f4b3a9 --- /dev/null +++ b/common/src/util/cache-debug.ts @@ -0,0 +1,168 @@ +import type { JSONValue } from '../types/json' + +type SerializableValue = JSONValue + +type SerializableRecord = Record + +export type CacheDebugCorrelation = { + projectRoot: string + filename: string + snapshotId: string +} + +function normalizeForJson(value: unknown): SerializableValue { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value + } + + if (value instanceof URL) { + return value.toString() + } + + if (value instanceof Uint8Array) { + return { + type: 'Uint8Array', + byteLength: value.byteLength, + } + } + + if (Array.isArray(value)) { + return value.map((item) => normalizeForJson(item)) + } + + if (typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entryValue]) => [ + key, + normalizeForJson(entryValue), + ]), + ) + } + + return String(value) +} + +function summarizeDataUrl(value: string): SerializableValue { + const firstComma = value.indexOf(',') + const header = firstComma >= 0 ? value.slice(0, firstComma) : value + const payload = firstComma >= 0 ? value.slice(firstComma + 1) : '' + return { + type: 'data-url', + mediaType: header.slice(5).split(';')[0] || 'unknown', + payloadLength: payload.length, + preview: payload.slice(0, 32), + } +} + +function summarizeLargeValue(value: SerializableValue): SerializableValue { + if (Array.isArray(value)) { + return value.map((item) => summarizeLargeValue(item)) + } + + if (!value || typeof value !== 'object') { + if (typeof value === 'string' && value.startsWith('data:')) { + return summarizeDataUrl(value) + } + return value + } + + if ('url' in value && typeof value.url === 'string' && value.url.startsWith('data:')) { + return { + ...value, + url: summarizeDataUrl(value.url), + } + } + + return Object.fromEntries( + Object.entries(value).map(([key, entryValue]) => { + if (key === 'file_data' && typeof entryValue === 'string' && entryValue.startsWith('data:')) { + return [key, summarizeDataUrl(entryValue)] + } + if (key === 'arguments' && typeof entryValue === 'string') { + return [key, entryValue] + } + return [key, summarizeLargeValue(entryValue)] + }), + ) +} + +function parseRequestBody(body: unknown): unknown { + if (typeof body !== 'string') { + return body + } + + try { + return JSON.parse(body) + } catch { + return body + } +} + +export function serializeCacheDebugCorrelation( + correlation: CacheDebugCorrelation, +): string { + return JSON.stringify(correlation) +} + +export function parseCacheDebugCorrelation( + value: unknown, +): CacheDebugCorrelation | undefined { + if (typeof value !== 'string') { + return undefined + } + + try { + const parsed = JSON.parse(value) as Partial + if ( + typeof parsed.projectRoot === 'string' && + typeof parsed.filename === 'string' && + typeof parsed.snapshotId === 'string' + ) { + return { + projectRoot: parsed.projectRoot, + filename: parsed.filename, + snapshotId: parsed.snapshotId, + } + } + } catch { + return undefined + } + + return undefined +} + +export function normalizeProviderRequestBodyForCacheDebug(params: { + provider: string + body: unknown +}): SerializableValue { + const parsed = parseRequestBody(params.body) + const body = normalizeForJson(parsed) + + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return body + } + + const record = body as SerializableRecord + const normalized: SerializableRecord = {} + + for (const key of ['model', 'messages', 'tools', 'tool_choice', 'response_format', 'reasoning', 'reasoning_effort', 'verbosity', 'provider']) { + if (key in record) { + normalized[key] = summarizeLargeValue(record[key]) + } + } + + if (params.provider === 'openrouter') { + for (const key of ['models', 'plugins', 'web_search_options', 'include_reasoning']) { + if (key in record) { + normalized[key] = summarizeLargeValue(record[key]) + } + } + } + + return normalized +} diff --git a/common/src/util/dates.ts b/common/src/util/dates.ts index 6c75b68c19..57096e324a 100644 --- a/common/src/util/dates.ts +++ b/common/src/util/dates.ts @@ -15,3 +15,67 @@ export const getNextQuotaReset = (referenceDate: Date | null): Date => { } return nextMonth } + +export interface FormatTimeUntilOptions { + /** + * What to return when the date is in the past or invalid. + * @default 'now' + */ + fallback?: string + /** + * Whether to include the smaller unit (hours in "Xd Yh", minutes in "Xh Ym"). + * @default true + */ + includeSubUnit?: boolean +} + +/** + * Format the time until a future date in a human-readable string. + * + * @param date - The target date (Date object or ISO string) + * @param options - Formatting options + * @returns Human-readable string like "4d 7h", "2h 30m", or "45m" + * + * @example + * // Date 2 days and 5 hours in the future + * formatTimeUntil(futureDate) // "2d 5h" + * formatTimeUntil(futureDate, { includeSubUnit: false }) // "2d" + * + * // Date 3 hours and 20 minutes in the future + * formatTimeUntil(futureDate) // "3h 20m" + * + * // Date in the past + * formatTimeUntil(pastDate) // "now" + * formatTimeUntil(pastDate, { fallback: '0h' }) // "0h" + */ +export const formatTimeUntil = ( + date: Date | string | null, + options: FormatTimeUntilOptions = {}, +): string => { + const { fallback = 'now', includeSubUnit = true } = options + + if (!date) return fallback + + const target = typeof date === 'string' ? new Date(date) : date + const diffMs = target.getTime() - Date.now() + + if (isNaN(diffMs) || diffMs <= 0) return fallback + + const diffMins = Math.floor(diffMs / (1000 * 60)) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + const remainingHours = diffHours % 24 + const remainingMins = diffMins % 60 + + if (diffDays > 0) { + return includeSubUnit && remainingHours > 0 + ? `${diffDays}d ${remainingHours}h` + : `${diffDays}d` + } + if (diffHours > 0) { + return includeSubUnit && remainingMins > 0 + ? `${diffHours}h ${remainingMins}m` + : `${diffHours}h` + } + return `${diffMins}m` +} diff --git a/common/src/util/error.ts b/common/src/util/error.ts index 788009e04f..0e96665fe2 100644 --- a/common/src/util/error.ts +++ b/common/src/util/error.ts @@ -12,6 +12,60 @@ export type Failure = { error: E } +/** + * Result type for prompt functions that can be aborted. + * Provides rich semantics to distinguish between successful completion and user abort. + * + * ## When to use `PromptResult` vs `ErrorOr` + * + * Use `PromptResult` when: + * - The operation can be cancelled by the user (via AbortSignal) + * - An abort is an expected outcome, not an error + * - You need to distinguish between errors (which might trigger fallbacks) and + * user-initiated aborts (which should propagate immediately) + * + * Use `ErrorOr` when: + * - The operation can fail with an error that should be handled + * - There's no concept of user-initiated abort + * - You want to return error details rather than throw + * + * ## Abort handling patterns + * + * 1. **Check and return early** - For graceful handling where abort means "stop, no error": + * ```ts + * const result = await promptAiSdk({ ... }) + * if (result.aborted) return // or return null, false, etc. + * doSomething(result.value) + * ``` + * + * 2. **Unwrap and throw** - For propagating aborts as exceptions: + * ```ts + * const value = unwrapPromptResult(await promptAiSdk({ ... })) + * // Throws if aborted, callers should use isAbortError() in catch blocks + * ``` + * + * 3. **Rethrow in catch blocks** - Prevent swallowing abort errors: + * ```ts + * try { + * await someOperation() + * } catch (error) { + * if (isAbortError(error)) throw error // Don't swallow aborts + * // Handle other errors + * } + * ``` + */ +export type PromptResult = PromptSuccess | PromptAborted + +export type PromptSuccess = { + aborted: false + value: T +} + +export type PromptAborted = { + aborted: true + reason?: string +} + export type ErrorObject = { name: string message: string @@ -24,6 +78,16 @@ export type ErrorObject = { code?: string /** Optional raw error object */ rawError?: string + /** Response body from API errors (AI SDK APICallError) */ + responseBody?: string + /** URL that was called (API errors) */ + url?: string + /** Whether the error is retryable (API errors) */ + isRetryable?: boolean + /** Request body values that were sent (API errors) - stringified for safety */ + requestBodyValues?: string + /** Cause of the error, if nested */ + cause?: ErrorObject } export function success(value: T): Success { @@ -33,32 +97,336 @@ export function success(value: T): Success { } } -export function failure(error: any): Failure { +export function failure(error: unknown): Failure { return { success: false, error: getErrorObject(error), } } +/** + * Create a successful prompt result. + */ +export function promptSuccess(value: T): PromptSuccess { + return { + aborted: false, + value, + } +} + +/** + * Create an aborted prompt result. + */ +export function promptAborted(reason?: string): PromptAborted { + return { + aborted: true, + ...(reason !== undefined && { reason }), + } +} + +/** + * Standard error message for aborted requests. + * Use this constant when throwing abort errors to ensure consistency. + */ +export const ABORT_ERROR_MESSAGE = 'Request aborted' + +/** + * Custom error class for abort errors. + * Use this class instead of generic Error for abort errors to ensure + * robust detection via isAbortError() (checks error.name === 'AbortError'). + */ +export class AbortError extends Error { + constructor(reason?: string) { + super(reason ? `${ABORT_ERROR_MESSAGE}: ${reason}` : ABORT_ERROR_MESSAGE) + this.name = 'AbortError' + } +} + +/** + * Check if an error is an abort error. + * Use this helper to detect abort errors in catch blocks. + * + * Detects both: + * - Errors with message starting with 'Request aborted' (thrown by our code via AbortError) + * - Native AbortError (thrown by fetch/AI SDK when AbortSignal is triggered) + */ +export function isAbortError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + // Check for our custom abort error message: + // - Exact match: 'Request aborted' + // - With reason: 'Request aborted: ' (from AbortError class) + if ( + error.message === ABORT_ERROR_MESSAGE || + error.message.startsWith(`${ABORT_ERROR_MESSAGE}: `) + ) { + return true + } + // Check for native AbortError (DOMException or Error with name 'AbortError') + // This is thrown by fetch, AI SDK, and other web APIs when AbortSignal is triggered + if (error.name === 'AbortError') { + return true + } + return false +} + +/** + * Unwrap a PromptResult, returning the value if successful or throwing if aborted. + * + * Use this helper for consistent abort handling when you want aborts to propagate + * as exceptions. Callers should use `isAbortError()` in catch blocks to detect + * and handle abort errors appropriately (e.g., rethrow instead of logging as errors). + * + * @throws {AbortError} When result.aborted is true. + */ +export function unwrapPromptResult(result: PromptResult): T { + if (result.aborted) { + throw new AbortError(result.reason) + } + return result.value +} + +/** + * Parses a JSON response body string from an API error to extract structured error details. + * Used to extract machine-readable error codes and human-readable messages from API responses + * (e.g., AI SDK's APICallError includes a responseBody with the server's JSON response). + * + * Returns extracted fields, or an empty object if the responseBody is not a valid JSON string + * with the expected shape. + */ +export function parseApiErrorResponseBody(responseBody: unknown): { + errorCode?: string + message?: string + countryCode?: string + countryBlockReason?: string + ipPrivacySignals?: string[] +} { + if (typeof responseBody !== 'string') return {} + try { + const parsed: unknown = JSON.parse(responseBody) + if (!parsed || typeof parsed !== 'object') return {} + const result: { + errorCode?: string + message?: string + countryCode?: string + countryBlockReason?: string + ipPrivacySignals?: string[] + } = {} + if ( + 'error' in parsed && + typeof (parsed as { error: unknown }).error === 'string' + ) { + result.errorCode = (parsed as { error: string }).error + } + if ( + 'message' in parsed && + typeof (parsed as { message: unknown }).message === 'string' + ) { + result.message = (parsed as { message: string }).message + } + if ( + 'countryCode' in parsed && + typeof (parsed as { countryCode: unknown }).countryCode === 'string' + ) { + result.countryCode = (parsed as { countryCode: string }).countryCode + } + if ( + 'countryBlockReason' in parsed && + typeof (parsed as { countryBlockReason: unknown }).countryBlockReason === + 'string' + ) { + result.countryBlockReason = ( + parsed as { countryBlockReason: string } + ).countryBlockReason + } + if ('ipPrivacySignals' in parsed) { + const signals = (parsed as { ipPrivacySignals: unknown }).ipPrivacySignals + if (Array.isArray(signals)) { + result.ipPrivacySignals = signals.filter( + (signal): signal is string => typeof signal === 'string', + ) + } + } + return result + } catch { + return {} + } +} + +export type ApiErrorDetails = ReturnType & { + statusCode?: number +} + +function getApiErrorCandidates( + error: unknown, + seen = new Set(), +): unknown[] { + if (!error || typeof error !== 'object') return [error] + if (seen.has(error)) return [] + seen.add(error) + + const candidates: unknown[] = [error] + const errorWithNested = error as { + lastError?: unknown + errors?: unknown[] + cause?: unknown + } + + candidates.push(...getApiErrorCandidates(errorWithNested.lastError, seen)) + + if (Array.isArray(errorWithNested.errors)) { + for (const nestedError of [...errorWithNested.errors].reverse()) { + candidates.push(...getApiErrorCandidates(nestedError, seen)) + } + } + + candidates.push(...getApiErrorCandidates(errorWithNested.cause, seen)) + + return candidates +} + +function getApiErrorStatusCode(error: unknown): number | undefined { + if (!error || typeof error !== 'object') return undefined + + if ('statusCode' in error) { + const statusCode = (error as { statusCode: unknown }).statusCode + if (typeof statusCode === 'number') return statusCode + } + + if ('status' in error) { + const status = (error as { status: unknown }).status + if (typeof status === 'number') return status + } + + return undefined +} + +function getApiErrorResponseBody(error: unknown): unknown { + if (!error || typeof error !== 'object') return undefined + if (!('responseBody' in error)) return undefined + return (error as { responseBody: unknown }).responseBody +} + +function hasParsedApiErrorDetails( + details: ReturnType, +): boolean { + return ( + details.errorCode !== undefined || + details.message !== undefined || + details.countryCode !== undefined || + details.countryBlockReason !== undefined || + details.ipPrivacySignals !== undefined + ) +} + +/** + * Extracts HTTP status and structured server error fields from API errors, + * including AI SDK RetryError wrappers whose useful APICallError is nested in + * `lastError` / `errors`. + */ +export function extractApiErrorDetails(error: unknown): ApiErrorDetails { + for (const candidate of getApiErrorCandidates(error)) { + const statusCode = getApiErrorStatusCode(candidate) + const parsed = parseApiErrorResponseBody(getApiErrorResponseBody(candidate)) + + if (statusCode !== undefined || hasParsedApiErrorDetails(parsed)) { + return { + ...parsed, + ...(statusCode !== undefined && { statusCode }), + } + } + } + + return {} +} + +// Extended error properties that various libraries add to Error objects +interface ExtendedErrorProperties { + status?: number + statusCode?: number + code?: string + // API error properties (AI SDK APICallError, etc.) + responseBody?: string + url?: string + isRetryable?: boolean + requestBodyValues?: Record + cause?: unknown +} + +/** + * Safely stringify an object, handling circular references and large objects. + */ +function safeStringify(value: unknown, maxLength = 10000): string | undefined { + if (value === undefined || value === null) return undefined + if (typeof value === 'string') return value.slice(0, maxLength) + try { + const seen = new WeakSet() + const str = JSON.stringify( + value, + (_, val) => { + if (typeof val === 'object' && val !== null) { + if (seen.has(val)) return '[Circular]' + seen.add(val) + } + return val + }, + 2, + ) + return str?.slice(0, maxLength) + } catch { + return '[Unable to stringify]' + } +} + export function getErrorObject( - error: any, + error: unknown, options: { includeRawError?: boolean } = {}, ): ErrorObject { if (error instanceof Error) { - const anyError = error as any + const extError = error as Error & Partial + + // Extract responseBody - could be string or object + let responseBody: string | undefined + if (extError.responseBody !== undefined) { + responseBody = safeStringify(extError.responseBody) + } + + // Extract requestBodyValues - typically an object, stringify for logging + let requestBodyValues: string | undefined + if ( + extError.requestBodyValues !== undefined && + typeof extError.requestBodyValues === 'object' + ) { + requestBodyValues = safeStringify(extError.requestBodyValues) + } + + // Extract cause - recursively convert to ErrorObject if present + let cause: ErrorObject | undefined + if (extError.cause !== undefined) { + cause = getErrorObject(extError.cause, options) + } + return { name: error.name, message: error.message, stack: error.stack, - status: typeof anyError.status === 'number' ? anyError.status : undefined, + status: typeof extError.status === 'number' ? extError.status : undefined, statusCode: - typeof anyError.statusCode === 'number' - ? anyError.statusCode + typeof extError.statusCode === 'number' + ? extError.statusCode + : undefined, + code: typeof extError.code === 'string' ? extError.code : undefined, + rawError: options.includeRawError ? safeStringify(error) : undefined, + // API error fields + responseBody, + url: typeof extError.url === 'string' ? extError.url : undefined, + isRetryable: + typeof extError.isRetryable === 'boolean' + ? extError.isRetryable : undefined, - code: typeof anyError.code === 'string' ? anyError.code : undefined, - rawError: options.includeRawError - ? JSON.stringify(error, null, 2) - : undefined, + requestBodyValues, + cause, } } diff --git a/common/src/util/file.ts b/common/src/util/file.ts index a31350a38e..733081c24d 100644 --- a/common/src/util/file.ts +++ b/common/src/util/file.ts @@ -4,6 +4,7 @@ import * as path from 'path' import { z } from 'zod/v4' import type { CodebuffFileSystem } from '../types/filesystem' +import type { SkillsMap } from '../types/skill' export const FileTreeNodeSchema: z.ZodType = z.object({ name: z.string(), @@ -67,6 +68,7 @@ export const ProjectFileContextSchema = z.object({ userKnowledgeFiles: z.record(z.string(), z.string()).optional(), agentTemplates: z.record(z.string(), z.any()).default(() => ({})), customToolDefinitions: customToolDefinitionsSchema, + skills: z.record(z.string(), z.any()).optional(), gitChanges: z.object({ status: z.string(), diff: z.string(), @@ -82,6 +84,7 @@ export const ProjectFileContextSchema = z.object({ arch: z.string(), homedir: z.string(), cpus: z.number(), + chromeAvailable: z.boolean(), }), }) @@ -95,6 +98,7 @@ export type ProjectFileContext = { userKnowledgeFiles?: Record agentTemplates: Record customToolDefinitions: CustomToolDefinitions + skills?: SkillsMap gitChanges: { status: string diff: string @@ -110,6 +114,7 @@ export type ProjectFileContext = { arch: string homedir: string cpus: number + chromeAvailable: boolean } } @@ -138,6 +143,7 @@ export const getStubProjectFileContext = (): ProjectFileContext => ({ userKnowledgeFiles: {}, agentTemplates: {}, customToolDefinitions: {}, + skills: {}, gitChanges: { status: '', diff: '', @@ -153,6 +159,7 @@ export const getStubProjectFileContext = (): ProjectFileContext => ({ arch: '', homedir: '', cpus: 0, + chromeAvailable: false, }, }) diff --git a/common/src/util/format-code-search.ts b/common/src/util/format-code-search.ts index 5b98edec31..8a89a7897e 100644 --- a/common/src/util/format-code-search.ts +++ b/common/src/util/format-code-search.ts @@ -1,24 +1,31 @@ /** * Formats code search output to group matches by file. * - * Input format: ./file.ts:line content + * Input format: ./file.ts:line:content * Output format: + * Found 3 matches * ./file.ts: - * line content - * another line content - * yet another line content + * Line 1: content + * Line 2: another line content + * Line 3: yet another line content * * (double newline between distinct files) * * @param stdout The raw stdout from ripgrep + * @param options.matchCount The number of actual matches, excluding context lines * @returns Formatted output with matches grouped by file */ -export function formatCodeSearchOutput(stdout: string): string { +export function formatCodeSearchOutput( + stdout: string, + options: { matchCount?: number } = {}, +): string { if (!stdout) { - return 'No results' + return 'Found 0 matches' } const lines = stdout.split('\n') - const formatted: string[] = [] + const formatted: string[] = [ + `Found ${options.matchCount ?? countFormattedMatches(lines)} matches`, + ] let currentFile: string | null = null for (const line of lines) { @@ -38,30 +45,13 @@ export function formatCodeSearchOutput(stdout: string): string { // Use regex to find the pattern: separator + digits + separator // This handles filenames with hyphens/colons by matching the line number pattern - let separatorIndex = -1 - let filePath = '' + const parsedLine = parseRipgrepLine(line) - // Try match line pattern: filename:digits:content - const matchLinePattern = /(.*?):(\d+):(.*)$/ - const matchLineMatch = line.match(matchLinePattern) - if (matchLineMatch) { - filePath = matchLineMatch[1] - separatorIndex = matchLineMatch[1].length - } else { - // Try context line pattern: filename-digits-content - const contextLinePattern = /(.*?)-(\d+)-(.*)$/ - const contextLineMatch = line.match(contextLinePattern) - if (contextLineMatch) { - filePath = contextLineMatch[1] - separatorIndex = contextLineMatch[1].length - } - } - - if (separatorIndex === -1) { + if (!parsedLine) { formatted.push(line) continue } - const content = line.substring(separatorIndex) + const { filePath, lineNumber, content } = parsedLine // Check if this is a new file (file paths don't start with whitespace) if (filePath && !filePath.startsWith(' ') && !filePath.startsWith('\t')) { @@ -73,11 +63,9 @@ export function formatCodeSearchOutput(stdout: string): string { currentFile = filePath // Show file path with colon on its own line formatted.push(filePath + ':') - // Show content without leading separator on next line - formatted.push(content.substring(1)) + formatted.push(` Line ${lineNumber}: ${content}`) } else { - // Same file - just show content without leading separator - formatted.push(content.substring(1)) + formatted.push(` Line ${lineNumber}: ${content}`) } } else { // Line doesn't match expected format, keep as-is @@ -87,3 +75,41 @@ export function formatCodeSearchOutput(stdout: string): string { return formatted.join('\n') } + +function parseRipgrepLine(line: string): { + filePath: string + lineNumber: string + content: string + isContext: boolean +} | null { + // Try match line pattern: filename:digits:content + const matchLineMatch = line.match(/(.*?):(\d+):(.*)$/) + if (matchLineMatch) { + return { + filePath: matchLineMatch[1], + lineNumber: matchLineMatch[2], + content: matchLineMatch[3], + isContext: false, + } + } + + // Try context line pattern: filename-digits-content + const contextLineMatch = line.match(/(.*?)-(\d+)-(.*)$/) + if (contextLineMatch) { + return { + filePath: contextLineMatch[1], + lineNumber: contextLineMatch[2], + content: contextLineMatch[3], + isContext: true, + } + } + + return null +} + +function countFormattedMatches(lines: string[]): number { + return lines.filter((line) => { + const parsedLine = parseRipgrepLine(line) + return parsedLine && !parsedLine.isContext + }).length +} diff --git a/common/src/util/freebuff-privacy.ts b/common/src/util/freebuff-privacy.ts new file mode 100644 index 0000000000..a559f8b897 --- /dev/null +++ b/common/src/util/freebuff-privacy.ts @@ -0,0 +1,55 @@ +import type { FreebuffIpPrivacySignal } from '../types/freebuff-session' + +export const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS = [ + 'vpn', + 'proxy', + 'tor', + 'res_proxy', +] as const satisfies readonly FreebuffIpPrivacySignal[] + +type FreebuffHardBlockedPrivacySignal = + (typeof FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS)[number] + +const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_SET = + new Set(FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS) + +const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_LABELS: Record< + FreebuffHardBlockedPrivacySignal, + string +> = { + vpn: 'VPN', + proxy: 'proxy', + res_proxy: 'proxy', + tor: 'Tor', +} + +export function isFreebuffHardBlockedPrivacySignal( + signal: FreebuffIpPrivacySignal, +): signal is FreebuffHardBlockedPrivacySignal { + return FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_SET.has(signal) +} + +export function formatFreebuffHardBlockedPrivacySignals( + signals: readonly FreebuffIpPrivacySignal[] | null | undefined, +): string { + const labels = Array.from( + new Set( + (signals ?? []).flatMap((signal): string[] => { + if (!isFreebuffHardBlockedPrivacySignal(signal)) return [] + return [FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_LABELS[signal]] + }), + ), + ) + + if (labels.length === 0) return 'VPN, proxy, or Tor' + if (labels.length === 1) return labels[0] + return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}` +} + +export function formatFreebuffHardBlockedMessage( + signals: readonly FreebuffIpPrivacySignal[] | null | undefined, +): string { + return `Freebuff cannot be used from ${formatFreebuffHardBlockedPrivacySignals( + signals, + )} traffic. Please disable it and try again.` +} diff --git a/common/src/util/messages.ts b/common/src/util/messages.ts index 59f1702496..e69e8e22b6 100644 --- a/common/src/util/messages.ts +++ b/common/src/util/messages.ts @@ -1,5 +1,7 @@ +import { modelMessageSchema } from 'ai' import { cloneDeep, has, isEqual } from 'lodash' +import type { Logger } from '../types/contracts/logger' import type { JSONValue } from '../types/json' import type { AssistantMessage, @@ -11,7 +13,6 @@ import type { } from '../types/messages/codebuff-message' import type { ToolResultOutput } from '../types/messages/content-part' import type { ProviderMetadata } from '../types/messages/provider-metadata' -import { modelMessageSchema } from 'ai' import type { AssistantModelMessage, ModelMessage, @@ -19,12 +20,16 @@ import type { ToolModelMessage, UserModelMessage, } from 'ai' -import { Logger } from '../types/contracts/logger' + export function toContentString(msg: ModelMessage): string { const { content } = msg if (typeof content === 'string') return content - return content.map((item) => (item as any)?.text ?? '').join('\n') + return content + .map((item) => + item && 'text' in item && typeof item.text === 'string' ? item.text : '', + ) + .join('\n') } export function withCacheControl< @@ -121,6 +126,21 @@ function assistantToCodebuffMessage( function convertToolResultMessage( message: ToolMessage, ): ModelMessageWithAuxiliaryData[] { + if (message.content.length === 0) { + return [ + cloneDeep({ + ...message, + role: 'tool', + content: [ + { + ...message, + output: { type: 'json', value: '' }, + type: 'tool-result', + }, + ], + }), + ] + } return message.content.map((c) => { if (c.type === 'json') { return cloneDeep({ @@ -137,8 +157,9 @@ function convertToolResultMessage( }) } c satisfies never - const cAny = c as any - throw new Error(`Invalid tool output type: ${cAny.type}`) + throw new Error( + `Invalid tool output type: ${(c as { type: unknown }).type}`, + ) }) } @@ -174,8 +195,9 @@ function convertToolMessage(message: Message): ModelMessageWithAuxiliaryData[] { return convertToolResultMessage(message) } message satisfies never - const messageAny = message as any - throw new Error(`Invalid message role: ${messageAny.role}`) + throw new Error( + `Invalid message role: ${(message as { role: unknown }).role}`, + ) } function convertToolMessages( @@ -319,8 +341,8 @@ export function convertCbToModelMessages({ } throw new Error( `convertCbToModelMessages: Message at index ${i} failed schema validation.\n` + - `Role: ${message.role}\n` + - `Message:\n${result.error.message}`, + `Role: ${message.role}\n` + + `Message:\n${result.error.message}`, ) } } @@ -349,8 +371,8 @@ export function systemMessage( params: | SystemContent | ({ - content: SystemContent - } & Omit), + content: SystemContent + } & Omit), ): SystemMessage { if (typeof params === 'object' && 'content' in params) { return { @@ -383,8 +405,8 @@ export function userMessage( params: | UserContent | ({ - content: UserContent - } & Omit), + content: UserContent + } & Omit), ): UserMessage { if (typeof params === 'object' && 'content' in params) { return { @@ -421,8 +443,8 @@ export function assistantMessage( params: | AssistantContent | ({ - content: AssistantContent - } & Omit), + content: AssistantContent + } & Omit), ): AssistantMessage { if (typeof params === 'object' && 'content' in params) { return { @@ -442,10 +464,10 @@ export function assistantMessage( export function jsonToolResult( value: T, ): [ - Extract & { - value: T - }, -] { + Extract & { + value: T + }, + ] { return [ { type: 'json', diff --git a/common/src/util/model-utils.ts b/common/src/util/model-utils.ts index 00277dd065..17d1f388e5 100644 --- a/common/src/util/model-utils.ts +++ b/common/src/util/model-utils.ts @@ -8,11 +8,8 @@ function getExplicitlyDefinedModels(): Set { if (explicitlyDefinedModels === null) { // NOTE: Inline require() avoids circular dependency - old-constants imports this // module, so a top-level import would create a circular reference - const { models, shouldCacheModels } = require('../old-constants') - explicitlyDefinedModels = new Set([ - ...(Object.values(models) as string[]), - ...(Object.values(shouldCacheModels) as string[]), - ]) + const { models } = require('../old-constants') + explicitlyDefinedModels = new Set(Object.values(models) as string[]) } return explicitlyDefinedModels } diff --git a/common/src/util/object.ts b/common/src/util/object.ts index 3232adcb3d..0fc0be4dff 100644 --- a/common/src/util/object.ts +++ b/common/src/util/object.ts @@ -1,41 +1,48 @@ import { isEqual, mapValues, union } from 'lodash' +type RemoveUndefined = { + [K in keyof T as T[K] extends undefined ? never : K]: Exclude +} + export const removeUndefinedProps = ( obj: T, -): { - [K in keyof T as T[K] extends undefined ? never : K]: Exclude -} => { - const newObj: any = {} +): RemoveUndefined => { + const newObj: Record = {} for (const key of Object.keys(obj)) { - if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key] + const value = obj[key as keyof T] + if (value !== undefined) { + newObj[key] = value + } } - return newObj + return newObj as RemoveUndefined } export const removeNullOrUndefinedProps = ( obj: T, exceptions?: string[], ): T => { - const newObj: any = {} + const newObj: Record = {} for (const key of Object.keys(obj)) { + const value = obj[key as keyof T] if ( - ((obj as any)[key] !== undefined && (obj as any)[key] !== null) || + (value !== undefined && value !== null) || (exceptions ?? []).includes(key) - ) - newObj[key] = (obj as any)[key] + ) { + newObj[key] = value + } } - return newObj + return newObj as T } export const addObjects = ( obj1: T, obj2: T, -) => { +): T => { const keys = union(Object.keys(obj1), Object.keys(obj2)) - const newObj = {} as any + const newObj: { [key: string]: number } = {} for (const key of keys) { newObj[key] = (obj1[key] ?? 0) + (obj2[key] ?? 0) @@ -47,9 +54,9 @@ export const addObjects = ( export const subtractObjects = ( obj1: T, obj2: T, -) => { +): T => { const keys = union(Object.keys(obj1), Object.keys(obj2)) - const newObj = {} as any + const newObj: { [key: string]: number } = {} for (const key of keys) { newObj[key] = (obj1[key] ?? 0) - (obj2[key] ?? 0) diff --git a/common/src/util/partial-json-delta.ts b/common/src/util/partial-json-delta.ts index b7a774cae2..a2dfb1814f 100644 --- a/common/src/util/partial-json-delta.ts +++ b/common/src/util/partial-json-delta.ts @@ -1,4 +1,3 @@ -// TODO: optimize this to not be O(n^2) export function parsePartialJsonObjectSingle(content: string): { lastParamComplete: boolean params: any @@ -26,16 +25,14 @@ export function parsePartialJsonObjectSingle(content: string): { } catch {} } - let lastIndex = content.lastIndexOf(',') - while (lastIndex > 0) { + let commaPos = content.length + while ((commaPos = content.lastIndexOf(',', commaPos - 1)) !== -1) { try { return { lastParamComplete: true, - params: JSON.parse(content.slice(0, lastIndex) + '}'), + params: JSON.parse(content.slice(0, commaPos) + '}'), } } catch {} - - lastIndex = content.lastIndexOf(',', lastIndex - 1) } return { lastParamComplete: true, params: {} } diff --git a/common/src/util/referral.ts b/common/src/util/referral.ts deleted file mode 100644 index 940ba4a10f..0000000000 --- a/common/src/util/referral.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { env } from '@codebuff/common/env' - -export const getReferralLink = (referralCode: string): string => - `${env.NEXT_PUBLIC_CODEBUFF_APP_URL}/referrals/${referralCode}` diff --git a/common/src/util/skills.ts b/common/src/util/skills.ts new file mode 100644 index 0000000000..9f92dd82ab --- /dev/null +++ b/common/src/util/skills.ts @@ -0,0 +1,32 @@ +import type { SkillsMap } from '../types/skill' + +/** + * Escapes special XML characters in a string. + */ +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * Formats available skills as XML for inclusion in tool descriptions. + */ +export function formatAvailableSkillsXml(skills: SkillsMap): string { + const skillEntries = Object.values(skills) + if (skillEntries.length === 0) { + return '' + } + + const skillsXml = skillEntries + .map( + (skill) => + ` \n ${skill.name}\n ${escapeXml(skill.description)}\n `, + ) + .join('\n') + + return `\n${skillsXml}\n` +} diff --git a/common/src/util/string.ts b/common/src/util/string.ts index a41cc96665..506de962fd 100644 --- a/common/src/util/string.ts +++ b/common/src/util/string.ts @@ -45,63 +45,6 @@ export const truncateStringWithMessage = ({ */ export const isWhitespace = (character: string) => /\s/.test(character) -export const replaceNonStandardPlaceholderComments = ( - content: string, - replacement: string, -): string => { - const commentPatterns = [ - // JSX comments (match this first) - { - regex: - /{\s*\/\*\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\s*\.{3})?\s*\*\/\s*}/gi, - placeholder: replacement, - }, - // C-style comments (C, C++, Java, JavaScript, TypeScript, etc.) - { - regex: - /\/\/\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\s*\.{3})?/gi, - placeholder: replacement, - }, - { - regex: - /\/\*\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\s*\.{3})?\s*\*\//gi, - placeholder: replacement, - }, - // Python, Ruby, R comments - { - regex: - /#\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\s*\.{3})?/gi, - placeholder: replacement, - }, - // HTML-style comments - { - regex: - //gi, - placeholder: replacement, - }, - // SQL, Haskell, Lua comments - { - regex: - /--\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\s*\.{3})?/gi, - placeholder: replacement, - }, - // MATLAB comments - { - regex: - /%\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\s*\.{3})?/gi, - placeholder: replacement, - }, - ] - - let updatedContent = content - - for (const { regex, placeholder } of commentPatterns) { - updatedContent = updatedContent.replaceAll(regex, placeholder) - } - - return updatedContent -} - export const randBoolFromStr = (str: string) => { return sumBy(str.split(''), (char) => char.charCodeAt(0)) % 2 === 0 } @@ -352,37 +295,6 @@ export const safeReplace = ( return content.replace(searchStr, escapedReplaceStr) } -export const hasLazyEdit = (content: string) => { - const cleanedContent = content.toLowerCase().trim() - return ( - cleanedContent.includes('... existing code ...') || - cleanedContent.includes('// rest of the') || - cleanedContent.includes('# rest of the') || - // Match various comment styles with ellipsis and specific words - /\/\/\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\.{3})?/.test( - cleanedContent, - ) || // C-style single line - /\/\*\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\.{3})?\s*\*\//.test( - cleanedContent, - ) || // C-style multi-line - /#\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\.{3})?/.test( - cleanedContent, - ) || // Python/Ruby style - //.test( - cleanedContent, - ) || // HTML style - /--\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\.{3})?/.test( - cleanedContent, - ) || // SQL/Haskell style - /%\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\.{3})?/.test( - cleanedContent, - ) || // MATLAB style - /{\s*\/\*\s*\.{3}.*(?:rest|unchanged|keep|file|existing|some).*(?:\.{3})?\s*\*\/\s*}/.test( - cleanedContent, - ) // JSX style - ) -} - /** * Extracts a JSON field from a string, transforms it, and puts it back. * Handles both array and object JSON values. diff --git a/common/src/util/system-info.ts b/common/src/util/system-info.ts index 23d3005057..959f316ce9 100644 --- a/common/src/util/system-info.ts +++ b/common/src/util/system-info.ts @@ -1,3 +1,4 @@ +import fs from 'fs' import os from 'os' import path from 'path' import { platform } from 'process' @@ -6,15 +7,47 @@ import { getProcessEnv } from '../env-process' import type { ProcessEnv } from '../types/contracts/env' +const CHROME_PATHS: Record = { + darwin: [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', + '/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + ], + linux: [ + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome-beta', + '/usr/bin/google-chrome-unstable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + ], + win32: [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + `${process.env.LOCALAPPDATA ?? ''}\\Google\\Chrome\\Application\\chrome.exe`, + ], +} + +export const findChromeExecutable = (): string | null => { + const paths = CHROME_PATHS[platform] ?? [] + for (const p of paths) { + if (p && fs.existsSync(p)) return p + } + return null +} + export const getSystemInfo = (processEnv: ProcessEnv = getProcessEnv()) => { - const shell = processEnv.SHELL || processEnv.COMSPEC || 'unknown' return { platform, - shell: path.basename(shell), + shell: 'bash', nodeVersion: process.version, arch: process.arch, homedir: os.homedir(), cpus: os.cpus().length, + chromeAvailable: findChromeExecutable() !== null, } } diff --git a/common/src/util/zoned-time.ts b/common/src/util/zoned-time.ts new file mode 100644 index 0000000000..36e13387fc --- /dev/null +++ b/common/src/util/zoned-time.ts @@ -0,0 +1,98 @@ +export interface ZonedDateParts { + year: number + month: number + day: number + hour: number + minute: number +} + +export function getZonedParts(date: Date, timeZone: string): ZonedDateParts { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(date) + + const get = (type: string) => { + const value = parts.find((part) => part.type === type)?.value + if (!value) throw new Error(`Missing ${type} in ${timeZone} date parts`) + return Number(value) + } + + return { + year: get('year'), + month: get('month'), + day: get('day'), + hour: get('hour'), + minute: get('minute'), + } +} + +export function addDaysToYmd( + year: number, + month: number, + day: number, + days: number, +): Pick { + const next = new Date(Date.UTC(year, month - 1, day)) + next.setUTCDate(next.getUTCDate() + days) + return { + year: next.getUTCFullYear(), + month: next.getUTCMonth() + 1, + day: next.getUTCDate(), + } +} + +export function getUtcForZonedTime( + parts: Pick, + timeZone: string, + hour: number, + minute: number, +): Date { + let guess = new Date( + Date.UTC(parts.year, parts.month - 1, parts.day, hour, minute), + ) + + for (let i = 0; i < 3; i++) { + const actual = getZonedParts(guess, timeZone) + const desiredUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + hour, + minute, + ) + const actualUtc = Date.UTC( + actual.year, + actual.month - 1, + actual.day, + actual.hour, + actual.minute, + ) + guess = new Date(guess.getTime() + (desiredUtc - actualUtc)) + } + + return guess +} + +export function getZonedDayBounds( + now: Date, + timeZone: string, +): { startsAt: Date; resetsAt: Date } { + const nowParts = getZonedParts(now, timeZone) + const today = { + year: nowParts.year, + month: nowParts.month, + day: nowParts.day, + } + const tomorrow = addDaysToYmd(today.year, today.month, today.day, 1) + + return { + startsAt: getUtcForZonedTime(today, timeZone, 0, 0), + resetsAt: getUtcForZonedTime(tomorrow, timeZone, 0, 0), + } +} diff --git a/docs/agents-and-tools.md b/docs/agents-and-tools.md new file mode 100644 index 0000000000..4ea7475896 --- /dev/null +++ b/docs/agents-and-tools.md @@ -0,0 +1,21 @@ +# Agents and Tools + +## Agents + +- Prompt/programmatic agents live in `.agents/` (programmatic agents use `handleSteps` generators). +- Generator functions execute in a sandbox; agent templates define tool access and subagents. + +### Shell Shims + +Direct commands without `codebuff` prefix: + +```bash +codebuff shims install codebuff/base-lite@1.0.0 +eval "$(codebuff shims env)" +base-lite "fix this bug" +``` + +## Tools + +- Tool definitions live in `common/src/tools` and are executed via the SDK helpers + agent-runtime. + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000000..4c60d4ae22 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,244 @@ +# Architecture Overview + +Codebuff is a TypeScript monorepo (Bun workspaces) that provides an AI-powered coding assistant via a CLI, SDK, and web API. + +## Package Dependency Graph + +``` + ┌──────────┐ + │ cli/ │ TUI client (OpenTUI + React) + └────┬─────┘ + │ + ┌────▼─────┐ + ┌───────│ sdk/ │ JS/TS SDK + │ └────┬─────┘ + │ │ + ┌───────▼────────┐ │ + │ agent-runtime/ │◄──┘ Agent execution engine + └───────┬────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ + │ agents/ │ │ common/ │ │ internal/ │ + └───────────┘ └─────┬─────┘ └─────┬─────┘ + │ │ + ┌─────┼─────┐ ┌─────┼─────────┐ + │ │ │ │ │ │ + billing/ bigquery/ code-map/ web/ +``` + +## Packages + +### `cli/` — TUI Client + +The user-facing terminal UI, built with [OpenTUI](https://github.com/nickhudkins/opentui) (a React renderer for terminals) and React hooks. + +- **Entry point:** `src/index.tsx` → `src/app.tsx` → `src/chat.tsx` +- **Key responsibilities:** + - Renders the chat interface, agent output, tool call results, and status indicators + - Manages user input, slash commands (`/help`, `/usage`), and agent mode selection (DEFAULT, MAX, PLAN) + - Handles authentication (login polling, OAuth), session persistence, and chat history + - Calls `client.run()` from the SDK and processes streaming events +- **Depends on:** `sdk`, `common` + +### `sdk/` — JavaScript/TypeScript SDK + +The public SDK used by the CLI and available to external users via `@codebuff/sdk` on npm. + +- **Entry point:** `src/client.ts` (`CodebuffClient`) → `src/run.ts` (`run()`) +- **Key responsibilities:** + - Orchestrates agent runs: initializes session state, registers tool handlers, calls `callMainPrompt()` + - **Executes tool calls locally** on the user's machine (file edits, terminal commands, code search) + - Manages model provider selection: Claude OAuth, ChatGPT OAuth, or Codebuff backend + - Handles credentials, retry logic, and error transformation +- **Depends on:** `agent-runtime`, `common`, `internal` (for OpenAI-compatible provider) + +### `packages/agent-runtime/` — Agent Execution Engine + +The core agent loop that drives LLM inference, tool execution, and multi-step reasoning. + +- **Entry point:** `src/main-prompt.ts` → `src/run-agent-step.ts` (`loopAgentSteps()`) +- **Key responsibilities:** + - Runs the agent loop: LLM call → process response → execute tool calls → repeat + - Manages agent templates, system prompts, and tool definitions + - Handles subagent spawning, programmatic agent steps (`handleSteps` generators) + - Processes the AI SDK stream (`streamText()`) and routes tool calls to the SDK + - Manages context token counting, cache debugging, and cost tracking +- **Depends on:** `common`, `agents` (for agent templates) + +### `common/` — Shared Library + +Shared types, utilities, constants, and tool definitions used across the entire monorepo. + +- **Key areas:** + - `src/types/` — TypeScript types: `SessionState`, `AgentOutput`, `Message`, contracts for DI + - `src/tools/` — Tool parameter schemas (Zod), tool names, and tool call validation + - `src/constants/` — Model configs, agent IDs, OAuth settings, billing constants + - `src/util/` — Error handling (`ErrorOr`), message utilities, string helpers, XML parsing + - `src/templates/` — Agent definition types, initial `.agents/` directory template + - `src/testing/` — Mock factories for database, filesystem, analytics, fetch, timers +- **Depends on:** nothing (leaf package) + +### `agents/` — Agent Definitions + +Prompt-based and programmatic agent definitions that ship with Codebuff. + +- **Key agents:** + - `base2/` — The default agent (base2, base2-max, base2-free, base2-plan) + - `editor/` — Code editing specialist with best-of-N selection + - `file-explorer/` — File picker, code searcher, directory lister, glob matcher + - `thinker/` — Deep reasoning agent with best-of-N variants + - `reviewer/` — Code review agent with multi-prompt variant + - `researcher/` — Web search and docs search agents + - `general-agent/` — General-purpose agents (opus-agent, gpt-5-agent) + - `basher.ts` — Terminal command execution agent (id: 'basher', displayName: 'Basher') + - `context-pruner.ts` — Conversation summarization to manage context length +- **Depends on:** `common` (for agent definition types and tool params) + +### `web/` — Next.js Web Application + +The Codebuff web server, marketing site, and API. + +- **Key areas:** + - `src/app/api/v1/chat/completions/` — The main LLM proxy endpoint (routes to OpenRouter, Fireworks, OpenAI) + - `src/app/api/v1/` — REST API: agent runs, feedback, usage, web search, docs search, token count + - `src/app/api/auth/` — NextAuth.js authentication (GitHub OAuth) + - `src/app/api/stripe/` — Billing: credit purchases, subscriptions, webhooks + - `src/app/api/agents/` — Agent registry: publish, validate, fetch + - `src/app/api/orgs/` — Organization management: teams, billing, repos + - `src/app/` — Marketing pages, docs (MDX via contentlayer), user profile, pricing + - `src/llm-api/` — LLM provider integrations (OpenRouter, Fireworks, OpenAI, SiliconFlow, CanopyWave) +- **Depends on:** `common`, `internal`, `billing`, `bigquery` + +### `packages/internal/` — Internal Utilities + +Server-side utilities, database schema, and vendor forks shared between `web` and `sdk`. + +- **Key areas:** + - `src/db/` — Drizzle ORM schema (`schema.ts`), migrations, Docker Compose for local Postgres + - `src/env.ts` — Server environment variable validation (@t3-oss/env-nextjs) + - `src/loops/` — Loops email service integration (transactional emails) + - `src/openai-compatible/` — Forked OpenAI-compatible AI SDK provider (used by the SDK to call the Codebuff backend) + - `src/openrouter-ai-sdk/` — Forked OpenRouter AI SDK provider (used by the web server) + - `src/templates/` — Agent template fetching and validation +- **Depends on:** `common` + +### `packages/billing/` — Billing & Credits + +Credit management, subscription handling, and usage tracking. + +- **Key components:** + - `balance-calculator.ts` — Credit balance calculation (free, purchased, rollover, subscription grants) + - `subscription.ts` — Subscription plan management, block grants, weekly limits + - `grant-credits.ts` — Credit grant operations (referral, purchase, admin, free) + - `auto-topup.ts` — Automatic credit purchases when balance is low + - `usage-service.ts` — Usage data aggregation + - `credit-delegation.ts` — Organization credit delegation +- **Depends on:** `common` (for DB access, Stripe utils, types) + +### `packages/bigquery/` — Analytics Data + +Google BigQuery integration for storing agent interaction traces and usage analytics. + +- **Tables:** `traces` (agent interactions), `relabels` (fine-tuning relabeling data) +- **Trace types:** file selection calls, file trees, agent responses, training data, model grading +- **Depends on:** `common` + +### `packages/code-map/` — Code Parsing + +Tree-sitter based source code parser that extracts function/variable names for file tree display. + +- **Supports:** TypeScript, JavaScript, Python, Go, Rust, Java, C, C++, C#, Ruby, PHP +- **Used by:** The `read_subtree` tool to show parsed variable names alongside the file tree +- **Depends on:** nothing (leaf package) + +### `packages/build-tools/` — Build Utilities + +Custom build executors, currently just the Infisical secrets integration. + +### `.agents/` — Local Agent Templates + +Project-specific agent definitions for this repository. These are loaded automatically by the agent runtime. + +- CLI agent templates (claude-code-cli, codex-cli, gemini-cli, codebuff-local-cli) +- Notion query agents +- Skills (cleanup, meta, review) + +### `evals/` — Evaluation Framework + +BuffBench evaluation suite for measuring agent performance on real-world coding tasks. + +- **Workflow:** Pick commits → generate eval tasks → run agents → judge results → extract lessons +- **Runners:** Codebuff, Claude Code, Codex +- **Depends on:** `common`, `agent-runtime`, `sdk` + +### `freebuff/` — Free Tier Product + +A separate free-to-use version of Codebuff with its own CLI binary and web app. + +- `freebuff/cli/` — Standalone CLI binary and release scripts +- `freebuff/web/` — Minimal Next.js app for auth (login, onboarding) +- Uses ChatGPT OAuth for free LLM access (no Codebuff credits required) + +### `scripts/` — Development & Operations + +Developer tooling, analytics scripts, and service management. + +- `start-services.ts` / `stop-services.ts` / `status-services.ts` — Local dev environment management +- `tmux/` — tmux helper scripts for CLI E2E testing +- Analytics: DAU calculation, MRR, subscriber profitability, model usage +- Release: changelog generation, credit grants, worktree management + +## Key Architectural Patterns + +### Dependency Injection via Contracts + +The codebase avoids tight coupling between packages using contract types in `common/src/types/contracts/`: + +- `database.ts` — DB access functions (`GetUserInfoFromApiKeyFn`, `StartAgentRunFn`, etc.) +- `llm.ts` — LLM calling functions (`PromptAiSdkStreamFn`, `PromptAiSdkFn`) +- `analytics.ts` — Event tracking (`TrackEventFn`) +- `client.ts` — Client-server communication (`RequestToolCallFn`, `SendActionFn`) +- `env.ts` — Environment variable access (`BaseEnv`, `ClientEnv`, `CiEnv`) + +This allows the agent-runtime to be used by both the SDK (local execution) and the web server (if needed) without direct dependencies. + +### ErrorOr Pattern + +Prefer `ErrorOr` return values (`success(value)` / `failure(error)`) over throwing exceptions. Defined in `common/src/util/error.ts`. + +### Local Tool Execution + +Tool calls (file edits, terminal commands, code search) execute **on the user's machine** via the SDK, not on the server. The agent-runtime sends tool call requests through `requestToolCall`, which the SDK handles locally. + +### AI SDK Integration + +The project uses Vercel's [AI SDK](https://sdk.vercel.ai/) (`ai` package) for LLM interactions: + +- `streamText()` for streaming responses +- `generateText()` / `generateObject()` for non-streaming +- Custom `OpenAICompatibleChatLanguageModel` provider for the Codebuff backend +- `APICallError` for HTTP error handling (see [Error Schema](./error-schema.md)) + +### Agent Template System + +Agents are defined as templates with: + +- **Prompt agents** — System prompt + tool list + spawnable subagents +- **Programmatic agents** — `handleSteps` generator functions that run in a sandbox +- Templates live in `agents/` (shipped) and `.agents/` (project-local) +- Users can publish agents to the Codebuff registry + +## Development + +```bash +bun up # Start web server + database +bun start-cli # Start CLI (separate terminal) +bun ps # Check running services +bun down # Stop services +bun typecheck # Run all type checks +bun test # Run all tests +``` + +See the [Request Flow](./request-flow.md) doc for the detailed path a prompt takes through the system. diff --git a/authentication.knowledge.md b/docs/authentication.md similarity index 77% rename from authentication.knowledge.md rename to docs/authentication.md index c8fad1c88d..b0dcb4bbd5 100644 --- a/authentication.knowledge.md +++ b/docs/authentication.md @@ -13,10 +13,13 @@ sequenceDiagram participant DB as Database CLI->>Web: POST /api/auth/cli/code {fingerprintId} - Web->>Web: Generate auth code (1h expiry) - Web->>CLI: Return login URL + Web->>Web: Generate signed auth payload (1h expiry) + Web->>DB: Store payload behind opaque browser token + Web->>CLI: Return login URL with opaque token CLI->>CLI: Open browser Note over Web: User completes OAuth + Web->>DB: Resolve opaque token to signed payload + Web->>DB: Mark opaque token consumed Web->>DB: Check fingerprint ownership Web->>DB: Create/update session loop Every 5s @@ -64,11 +67,14 @@ sequenceDiagram ### 4. Failure: Invalid/Expired Code - Auth code validation fails or expired (1h limit) +- Opaque browser tokens resolve expired signed payloads before returning the expired-code error - Returns authentication error ## Security Features -- Auth codes expire after 1 hour +- Signed auth payloads expire after 1 hour +- Browser login URLs use opaque 43-character tokens instead of exposing the signed auth payload +- Opaque browser tokens are stored in `verificationToken` under `cli-login:` and atomically moved to `cli-login-consumed:` when onboarding resolves them; consumed markers scrub the signed auth payload from the `token` column - Fingerprint uniqueness: hardware info + 8 random bytes - Ownership conflicts blocked and logged - Sessions linked to fingerprint_id in database diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000000..34c8a7413b --- /dev/null +++ b/docs/development.md @@ -0,0 +1,60 @@ +# Development + +## Getting Started + +Start the web server first: + +```bash +bun up +``` + +Then start the CLI separately: + +```bash +bun start-cli +``` + +Other service commands: + +```bash +bun ps # check running services +bun down # stop services +``` + +## Worktrees + +To run multiple stacks on different ports, create `.env.development.local`: + +```bash +PORT=3001 +NEXT_PUBLIC_WEB_PORT=3001 +NEXT_PUBLIC_CODEBUFF_APP_URL=http://localhost:3001 +``` + +## Logs + +Logs are in `debug/console/` (`db.log`, `studio.log`, `sdk.log`, `web.log`). + +## Package Management + +- Use `bun install`, `bun run ...` (avoid `npm`). + +## Database Migrations + +Edit schema using Drizzle's TS DSL (don't hand-write migration SQL), then run the internal DB scripts to generate/apply migrations. + +## Running Scripts Against Prod + +Scripts in `scripts/` connect to whatever environment Infisical injects. To run a script against the production database and services, prefix it with `infisical run --env=prod`: + +```bash +infisical run --env=prod -- bun scripts/.ts +``` + +You can also inline a one-off query: + +```bash +infisical run --env=prod -- bun -e "import db from '@codebuff/internal/db'; /* ... */" +``` + +Add `--silent` to suppress the Infisical banner. Default env is `dev` — always pass `--env=prod` explicitly when you want prod. Prefer read-only queries; coordinate before running anything that writes. diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 0000000000..980272b6d9 --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,32 @@ +# Environment Variables + +## Quick Rules + +- Public client env: `NEXT_PUBLIC_*` only, validated in `common/src/env-schema.ts` (used via `@codebuff/common/env`). +- Server secrets: validated in `packages/internal/src/env-schema.ts` (used via `@codebuff/internal/env`). +- Runtime/OS env: pass typed snapshots instead of reading `process.env` throughout the codebase. +- `IPINFO_TOKEN` is required; free-mode country gating uses it to check IPinfo privacy signals for VPN/proxy/Tor/relay/hosting traffic. +- `SPUR_TOKEN` is required; hard VPN/proxy/Tor/residential-proxy free-mode blocks require Spur Context API corroboration. In allowlisted countries, a successful clean Spur result overrides IPinfo privacy signals back to full access, while a Spur lookup failure falls back to limited access. +- `CODEBUFF_FULL_TELEMETRY=true` or `CODEBUFF_FULL_TELEMETRY_IDS=user-id,email@example.com` + disables client analytics sampling for targeted debugging. Use sparingly because it can send full CLI log payloads. + +## Env DI Helpers + +- Base contracts: `common/src/types/contracts/env.ts` (`BaseEnv`, `BaseCiEnv`, `ClientEnv`, `CiEnv`) +- Helpers: `common/src/env-process.ts`, `common/src/env-ci.ts` +- Test helpers: `common/src/testing-env-process.ts`, `common/src/testing-env-ci.ts` +- CLI: `cli/src/utils/env.ts` (`getCliEnv`) +- CLI test helpers: `cli/src/testing/env.ts` (`createTestCliEnv`) +- SDK: `sdk/src/env.ts` (`getSdkEnv`) +- SDK test helpers: `sdk/src/testing/env.ts` (`createTestSdkEnv`) + +## Loading Order + +Bun loads (highest precedence last): + +- `.env.local` (Infisical-synced secrets, gitignored) +- `.env.development.local` (worktree overrides like ports, gitignored) + +## Releases + +Release scripts read `CODEBUFF_GITHUB_TOKEN`. diff --git a/docs/error-schema.md b/docs/error-schema.md new file mode 100644 index 0000000000..3301efb759 --- /dev/null +++ b/docs/error-schema.md @@ -0,0 +1,241 @@ +# Error Schema: Server Responses & Client Handling + +This document describes the error responses the Codebuff server sends, how the AI SDK transforms them, and how errors are ultimately displayed in the CLI. + +## Server Error Responses + +**Source:** `web/src/app/api/v1/chat/completions/_post.ts` + +The server returns JSON error responses with an HTTP status code. There are two shapes: + +### Simple errors (message only) + +```json +{ "message": "" } +``` + +Used for: + +| Status | Example message | +| ------ | --------------------------------------------------------------------------------------------------------- | +| 400 | `"Invalid JSON in request body"` | +| 400 | `"No runId found in request body"` | +| 401 | `"Unauthorized"` | +| 401 | `"Invalid Codebuff API key"` | +| 402 | `"Out of credits. Please add credits at https://codebuff.com/usage. Your free credits reset in 3 hours."` | + +### Typed errors (error code + message) + +```json +{ "error": "", "message": "" } +``` + +Used for errors that the client needs to identify programmatically: + +| Status | `error` code | Example `message` | +| ------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 403 | `account_suspended` | `"Your account has been suspended. Please contact support@codebuff.com if you did not expect this."` | +| 403 | `free_mode_unavailable` | `"Free mode is not available in your country."` (Freebuff: `"Freebuff is not available in your country."`) | +| 409 | `session_superseded` | `"Another instance of freebuff has taken over this session. Only one instance per account is allowed."` | +| 409 | `session_model_mismatch` | `"This session is bound to ; restart freebuff to switch models."` | +| 429 | `rate_limit_exceeded` | `"Subscription weekly limit reached. Your limit resets in 2 hours. Enable 'Continue with credits' in the CLI to use a-la-carte credits."` | + +### Catch-all server error + +```json +{ "error": "Failed to process request" } +``` + +The 500 catch-all uses `error` as a human-readable string (no `message` field). This does not follow the typed error pattern above — it's a legacy format. + +### Provider errors + +When the upstream LLM provider (OpenRouter, Fireworks, OpenAI, etc.) returns an error, the server passes it through via the provider's `.toJSON()` format, which varies by provider. + +## The AI SDK Transformation Problem + +The Codebuff backend is called through the AI SDK's `OpenAICompatibleChatLanguageModel`, which treats it as a standard OpenAI-compatible endpoint. When the server returns a non-2xx response, **the AI SDK wraps it** into an `APICallError`: + +``` +Server returns: HTTP 403 { "error": "free_mode_unavailable", "message": "Free mode is not available in your country." } + │ + ▼ +AI SDK creates: APICallError { + message: "Forbidden" ← HTTP status text (NOT the server's message) + statusCode: 403 + responseBody: "{\"error\":\"free_mode_unavailable\",\"message\":\"Free mode is not available in your country.\"}" ← original JSON as a string + } +``` + +The server's human-readable `message` and machine-readable `error` code are buried inside `responseBody` as a JSON string. The `APICallError.message` is often just the HTTP status text ("Forbidden", "Payment Required", "Conflict", etc.). + +Some statuses that the AI SDK considers retryable, including HTTP 409, can be retried and then wrapped in an `AI_RetryError`: + +``` +AI_RetryError { + message: "Failed after 4 attempts. Last error: Conflict", + lastError: APICallError { statusCode: 409, responseBody: "{\"error\":\"session_superseded\",...}" }, + errors: [APICallError, ...] +} +``` + +In this case the structured server response is no longer on the top-level error. It must be recovered from `lastError` or `errors`. + +## Client-Side Error Recovery + +To recover the server's structured error details, callers use `extractApiErrorDetails()` from `common/src/util/error.ts`: + +```typescript +export function extractApiErrorDetails(error: unknown): { + statusCode?: number + errorCode?: string + message?: string + countryCode?: string + countryBlockReason?: string + ipPrivacySignals?: string[] +} +``` + +`extractApiErrorDetails()` checks the top-level error and nested retry wrapper fields (`lastError`, `errors`, and `cause`). For each candidate it extracts `statusCode`/`status` and parses any API `responseBody` with `parseApiErrorResponseBody()`. + +This helper is called in two places: + +### 1. Agent Runtime catch block + +**File:** `packages/agent-runtime/src/run-agent-step.ts` (in `loopAgentSteps`) + +This is the **primary** error handler. Most API errors are caught here because the error occurs during `runAgentStep()` → `promptAiSdkStream()` → `streamText()`. + +```typescript +catch (error) { + const apiErrorDetails = extractApiErrorDetails(error) + // apiErrorDetails.errorCode = 'free_mode_unavailable' + // apiErrorDetails.message = 'Free mode is not available in your country.' + // apiErrorDetails.statusCode = 403 + // ... + return { + output: { + type: 'error', + message: hasServerMessage ? errorMessage : 'Agent run error: ' + errorMessage, + statusCode: apiErrorDetails.statusCode, + error: apiErrorDetails.errorCode, // ← machine-readable code for client matching + }, + } +} +``` + +### 2. SDK .catch() handler + +**File:** `sdk/src/run.ts` (in `callMainPrompt().catch()`) + +This is a **fallback** handler for errors that escape the agent runtime (e.g., errors during setup before the agent loop starts). + +It also calls `extractApiErrorDetails()` so retry-wrapped setup errors preserve the same `statusCode`, `error`, and `message` fields as agent-loop errors. + +## Error Output Schema + +**File:** `common/src/types/session-state.ts` + +The `AgentOutputSchema` defines the Zod schema for agent output. The error variant: + +```typescript +z.object({ + type: z.literal('error'), + message: z.string(), + statusCode: z.number().optional(), + error: z.string().optional(), // machine-readable error code +}) +``` + +All three fields flow through to the CLI. + +## CLI Error Handling + +**Files:** `cli/src/utils/error-handling.ts`, `cli/src/hooks/helpers/send-message.ts` + +The CLI checks the output for known error types: + +```typescript +// Checks statusCode === 402 +isOutOfCreditsError(output) → shows OUT_OF_CREDITS_MESSAGE + +// Checks statusCode === 403 && error === 'free_mode_unavailable' +isFreeModeUnavailableError(output) → shows FREE_MODE_UNAVAILABLE_MESSAGE + +// Freebuff only: checks statusCode === 429 after waiting-room errors +getFreebuffRateLimitErrorMessage(output) + → preserves typed quota messages or shows FREEBUFF_RATE_LIMIT_MESSAGE +``` + +For all other errors, the raw `output.message` is displayed in the `UserErrorBanner`. + +## Error Flow Diagram + +``` + Server AI SDK Agent Runtime SDK CLI + │ │ │ │ │ + │ HTTP 403 │ │ │ │ + │ { error, message } │ │ │ │ + │────────────────────────▶│ │ │ │ + │ │ APICallError or │ │ │ + │ │ AI_RetryError │ │ │ + │ │ .responseBody="{...}" │ │ │ + │ │ or .lastError │ │ │ + │ │────────────────────────▶│ │ │ + │ │ │ catch (error) │ │ + │ │ │ extractApiError...() │ │ + │ │ │ extract error code │ │ + │ │ │ extract message │ │ + │ │ │─────────────────────▶ │ │ + │ │ │ prompt-response │ │ + │ │ │ { type: 'error', │ │ + │ │ │ statusCode: 403, │ │ + │ │ │ error: '...', │ │ + │ │ │ message: '...' } │ │ + │ │ │ │─────────────────────▶│ + │ │ │ │ handleRunCompletion │ + │ │ │ │ isFreeModeUnavail.. │ + │ │ │ │ show friendly msg │ +``` + +## Adding a New Server Error Type + +To add a new error type that the CLI can identify and handle specially: + +1. **Server** (`web/src/app/api/v1/chat/completions/_post.ts`): Return a typed error: + + ```typescript + return NextResponse.json( + { error: 'your_error_code', message: 'User-friendly message.' }, + { status: 4xx }, + ) + ``` + +2. **CLI error detection** (`cli/src/utils/error-handling.ts`): Add a checker: + + ```typescript + export const isYourError = (error: unknown): boolean => { + if ( + error && + typeof error === 'object' && + 'statusCode' in error && + (error as { statusCode: unknown }).statusCode === 4xx && + 'error' in error && + (error as { error: unknown }).error === 'your_error_code' + ) { + return true + } + return false + } + ``` + +3. **CLI display** (`cli/src/hooks/helpers/send-message.ts`): Handle it in `handleRunCompletion`: + ```typescript + if (isYourError(output)) { + updater.setError(YOUR_ERROR_MESSAGE) + finalizeAfterError() + return + } + ``` + +No changes needed in the agent runtime or SDK — `extractApiErrorDetails()` automatically extracts any `error` and `message` fields from the server's response body, including when the API error is nested inside an AI SDK retry wrapper. diff --git a/docs/freebuff-waiting-room.md b/docs/freebuff-waiting-room.md new file mode 100644 index 0000000000..c0e38b3bf9 --- /dev/null +++ b/docs/freebuff-waiting-room.md @@ -0,0 +1,362 @@ +# Freebuff Waiting Room + +## Overview + +The waiting room is the admission control layer for **free-mode** requests against the freebuff Fireworks deployments. It has three jobs: + +1. **Drip-admit users per model** — each selectable freebuff model has its own FIFO queue. Admission runs one tick (default `ADMISSION_TICK_MS`, 15s) that tries to admit one user per model, so heavier models can sit cold without starving lighter ones. +2. **Gate on per-deployment health and hours** — a single fleet probe per tick (`getFleetHealth` in `web/src/server/free-session/fireworks-health.ts`) hits the Fireworks metrics endpoint and classifies each dedicated deployment as `healthy | degraded | unhealthy`. Only models whose deployment is `healthy` and currently available admit that tick; GLM 5.1 is available during 9am ET-5pm PT on weekdays, while MiniMax M2.7 is serverless and always available. +3. **One instance per account** — prevent a single user from running N concurrent freebuff CLIs to get N× throughput. + +Users who cannot be admitted immediately are placed in the queue for their chosen model and given an estimated wait time. Admitted users get a fixed-length session (default 1h) bound to the model they were admitted on; chat completions use that model for the life of the session. + +The entire system is gated by the env flag `FREEBUFF_WAITING_ROOM_ENABLED`. When `false`, the gate is a no-op and the admission ticker does not start; free-mode traffic flows through unchanged. + +## Kill Switch + +```bash +# Disable entirely (both the gate on chat/completions and the admission loop) +FREEBUFF_WAITING_ROOM_ENABLED=false + +# Other knob (only read when enabled) +FREEBUFF_SESSION_LENGTH_MS=3600000 # 1 hour +``` + +Flipping the flag is safe at runtime: existing rows stay in the DB and will be admitted / expired correctly whenever the flag is flipped back on. + +## Architecture + +```mermaid +flowchart LR + CLI[freebuff CLI] + SessionAPI["/api/v1/freebuff/session
(GET, POST, DELETE)"] + ChatAPI["/api/v1/chat/completions"] + Gate[checkSessionAdmissible] + Ticker["Admission Ticker
every ADMISSION_TICK_MS
(all pods, per-model locks)"] + Store[(free_session
Postgres)] + Probe["getFleetHealth
Fireworks metrics GET
(cached ~25s)"] + + CLI -- "POST on startup
(model + gets instance_id)" --> SessionAPI + CLI -- "GET to poll state" --> SessionAPI + CLI -- "chat requests
include instance_id" --> ChatAPI + SessionAPI --> Store + ChatAPI --> Gate + Gate --> Store + Ticker -- "per-model admit" --> Store + Ticker --> Probe +``` + +### Components + +- **`free_session` table** (Postgres) — single source of truth for queue + active-session state. One row per user (PK on `user_id`), with a `model` column recording which queue the row belongs to. +- **Model registry** (`common/src/constants/freebuff-models.ts`) — `FREEBUFF_MODELS` is the authoritative list of selectable models. Adding a new freebuff model means adding an entry here; the admission ticker iterates this list every tick. +- **Public API** (`web/src/server/free-session/public-api.ts`) — `requestSession`, `getSessionState`, `endUserSession`, `checkSessionAdmissible`. Pure business logic; DI-friendly. `requestSession` accepts the user's chosen `model` and can return `model_locked` when a session is already active on a different model. +- **Store** (`web/src/server/free-session/store.ts`) — all DB ops. Transaction boundaries and per-model advisory locks live here. +- **Fleet health probe** (`web/src/server/free-session/fireworks-health.ts`) — `getFleetHealth()` does a single HTTP GET against the Fireworks metrics endpoint and returns a `Record`. Cached ~25s (under the Fireworks 30s exporter cadence and 6 req/min rate limit). Models without a dedicated deployment in `FIREWORKS_DEPLOYMENT_MAP` (e.g. serverless) are absent from the map and treated as `healthy` at call sites. +- **Admission ticker** (`web/src/server/free-session/admission.ts`) — self-scheduling timer that runs every `ADMISSION_TICK_MS`. Each tick sweeps expired rows once, resolves fleet health once, then admits one queued user per model in parallel (each guarded by a model-keyed advisory lock). +- **HTTP routes** (`web/src/app/api/v1/freebuff/session/`) — thin wrappers that resolve the API key → `userId` and delegate to the public API. +- **Chat-completions gate** (`web/src/app/api/v1/chat/completions/_post.ts`) — for free-mode requests, calls `checkSessionAdmissible(userId, claimedInstanceId)` after the rate-limit check and rejects non-admissible requests with a structured error. The admitted session's `model` is what gets sent to the upstream. + +## Database Schema + +```sql +CREATE TYPE free_session_status AS ENUM ('queued', 'active'); + +CREATE TABLE free_session ( + user_id text PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE, + status free_session_status NOT NULL, + active_instance_id text NOT NULL, + model text NOT NULL, + country_code text, + cf_country text, + geoip_country text, + country_block_reason text, + ip_privacy_signals text[], + client_ip_hash text, + country_checked_at timestamptz, + queued_at timestamptz NOT NULL DEFAULT now(), + admitted_at timestamptz, + expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Per-model dequeue: WHERE status='queued' AND model=$1 ORDER BY queued_at +CREATE INDEX idx_free_session_queue ON free_session (status, model, queued_at); +CREATE INDEX idx_free_session_expiry ON free_session (expires_at); +``` + +Migrations: `packages/internal/src/db/migrations/0043_vengeful_boomer.sql` (initial table) and `0044_violet_stingray.sql` (added the `model` column and rebuilt the queue index). + +**Design notes** + +- **PK on `user_id`** is the structural enforcement of "one session per account". No app-logic race can produce two rows for one user. +- **`active_instance_id`** rotates on every `POST /session` call. This is how we enforce one-CLI-at-a-time (see [Single-instance enforcement](#single-instance-enforcement)). +- **`model` column.** Populated by the POST handler; determines which queue the row belongs to while queued and is fixed for the life of an active session. Switching models while an active session is live is rejected (`model_locked`, 409). +- **Country/privacy columns.** Populated from the POST `/session` country gate so active-session audits can see the resolved country, Cloudflare country header, GeoIP fallback country, IPinfo privacy signals, and a keyed hash of the client IP. Raw IPs are not stored. +- **All timestamps server-supplied.** The client never sends `queued_at`, `admitted_at`, or `expires_at` — they are either `DEFAULT now()` or computed server-side during admission. +- **FK CASCADE on user delete** keeps the table clean without a background job. + +## State Machine + +```mermaid +stateDiagram-v2 + [*] --> queued: POST /session
(first call) + queued --> active: admission tick
(capacity + healthy) + active --> ended: expires_at < now()
(grace window) + ended --> expired: expires_at + grace < now() + expired --> queued: POST /session
(re-queue at back) + queued --> [*]: DELETE /session + active --> [*]: DELETE /session
or admission sweep + ended --> [*]: DELETE /session
or admission sweep +``` + +Neither `ended` nor `expired` is a stored status — they are derived from `expires_at` versus `now()` and the grace window: + +- `expires_at > now()` → `active` (gate: `ok: 'active'`; wire: `active`) +- `expires_at <= now() < expires_at + grace` → `ended` on the wire (gate still admits with `ok: 'draining'`; client must stop accepting new prompts but can let an in-flight agent finish) +- `expires_at + grace <= now()` → `expired` (gate: `session_expired`; wire: `none` after sweep); swept by the admission ticker + +## Single-instance Enforcement + +The challenge: a user running two CLIs on the same account should not get 2× throughput. + +The PK on `user_id` gives us one session row per user, but both CLIs could share that row and double up their request rate (bounded only by the per-user rate limiter, which isn't ideal). + +The solution: `active_instance_id`. + +1. On startup, the CLI calls `POST /api/v1/freebuff/session`. The server generates a fresh UUID (`active_instance_id`), stores it, and returns it. +2. Every subsequent chat request includes that id in `codebuff_metadata.freebuff_instance_id`. +3. `checkSessionAdmissible` rejects the request with `session_superseded` (HTTP 409) if the claimed id doesn't match the stored one. +4. When the user starts a second CLI, it calls `POST /session`, which rotates `active_instance_id`. The first CLI's subsequent request hits 409, so only the latest CLI can actually make chat requests. + +The rotation is important: it happens even if the caller is already in the `active` state, so a second CLI always wins. Any other design (first-wins, take-over-requires-force-flag) would allow the attacker to keep the old CLI alive forever. + +### What this does NOT prevent + +- A single user manually syncing `instance_id` between two CLIs (e.g. editing a config file). This is possible but requires them to re-sync after every startup call, so it's high-friction. We accept this. +- A user creating multiple accounts. That is covered by other gates (MIN_ACCOUNT_AGE_FOR_PAID_MS, geo check) and the overall drip-admission rate. + +## Admission Loop + +All pods start a ticker on boot. Coordination is by **per-model** Postgres advisory locks: the lock id is `FREEBUFF_ADMISSION_LOCK_ID + hashStringToInt32(model)`, so different models can admit concurrently across pods while a single model is still serialized. Each per-model attempt takes the lock inside a transaction via `pg_try_advisory_xact_lock`; if the lock is held by another pod, that model is a no-op on this pod for this tick. The lock is released automatically when the transaction commits. + +Each tick does (in order): + +1. **Sweep expired.** `DELETE FROM free_session WHERE status='active' AND expires_at < now() - grace`. Runs once per tick regardless of upstream health so zombie sessions are cleaned up even during an outage. +2. **Fleet health probe.** `getFleetHealth()` returns a `Record`. One HTTP call per tick (cached ~25s across pods) covers every model. Deployment absent from the fleet map (serverless) defaults to `healthy` at the call site. +3. **Admit per model, in parallel.** For each model in `FREEBUFF_MODELS`, call `admitFromQueue({ model, health, sessionLengthMs, now })`: + - If `health !== 'healthy'`, returns `{ admitted: [], skipped: health }` without touching Postgres — the model's queue pauses and grows until recovery. + - Otherwise opens a transaction, takes the per-model advisory lock, and `SELECT ... WHERE status='queued' AND model=$1 ORDER BY queued_at, user_id LIMIT 1 FOR UPDATE SKIP LOCKED` → `UPDATE` the row to `status='active'` with `admitted_at=now()`, `expires_at=now()+sessionLength`. One admit per model per tick keeps Fireworks from a thundering herd of newly-admitted CLIs. + +The final tick result carries a `queueDepthByModel` map and a single `skipped` reason (the first non-null skip across models) for observability. + +### Tunables + +| Constant | Location | Default | Purpose | +| ---------------------------- | ----------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ADMISSION_TICK_MS` | `config.ts` | 15000 | How often the ticker fires. Up to one user is admitted per model per tick. | +| `FREEBUFF_MODELS` | `common/src/constants/freebuff-models.ts` | `deepseek-v4-pro`, `kimi-k2.6`, `minimax-m2.7`, `deepseek-v4-flash` | Selectable models; each gets its own queue and admission slot. | +| `FIREWORKS_DEPLOYMENT_MAP` | `web/src/llm-api/fireworks-config.ts` | `glm-5.1` | Models with dedicated Fireworks deployments. Models not listed are treated as `healthy` (serverless fallback) — drop this default when they migrate to their own deployments. | +| `HEALTH_CACHE_TTL_MS` | `fireworks-health.ts` | 25000 | Fleet probe cache TTL. Sits just under the Fireworks 30s exporter cadence and 6 req/min rate limit. | +| `FREEBUFF_SESSION_LENGTH_MS` | env | 3_600_000 | Session lifetime | +| `SESSION_GRACE_MS` | `web/src/server/free-session/config.ts` | 1_800_000 | Drain window after expiry — gate still admits requests so an in-flight agent can finish, but the CLI is expected to block new prompts. Hard cutoff at `expires_at + grace`. | + +### Premium Session Quota + +DeepSeek V4 Pro, Kimi, and legacy GLM share a per-user premium quota. The server counts `free_session_admit` rows from the last midnight in `America/Los_Angeles`; when the user reaches `FREEBUFF_PREMIUM_SESSION_LIMIT`, the next premium `POST /session` is rejected until the next Pacific midnight reset. MiniMax and DeepSeek V4 Flash remain unlimited. + +## HTTP API + +All endpoints authenticate via the standard `Authorization: Bearer ` or `x-codebuff-api-key` header. + +### `POST /api/v1/freebuff/session` + +**Called by the CLI on startup and whenever the user picks a different model in the waiting room.** Body: `{ "model": "" }` (optional; falls back to the default model if omitted or unknown). Idempotent. Semantics: + +- No existing row → create with `status='queued'`, `model` = requested, fresh `active_instance_id`, `queued_at=now()`. +- Existing queued row, **same model** → rotate `active_instance_id`, preserve `queued_at` (no queue jump). +- Existing queued row, **different model** → switch `model` and reset `queued_at=now()` (move to back of the new model's queue). Rotating `active_instance_id`. +- Existing active+unexpired row, **same model** → rotate `active_instance_id`, preserve `status`/`admitted_at`/`expires_at`. +- Existing active+unexpired row, **different model** → reject with `model_locked` (HTTP 409); `active_instance_id` is **not** rotated so the other CLI stays valid. Client must DELETE the session before switching. +- Existing active+expired row → reset to queued with fresh `queued_at` and the requested `model` (re-queue at back). + +Before any of those state transitions, the handler requires a resolved country and successful IPinfo/Spur privacy checks. Unsupported countries enter limited Freebuff access. In allowlisted countries, IPinfo privacy signals still receive full access when Spur returns clean context, fall back to limited access when Spur lookup fails, and hard-block only when Spur corroborates VPN/proxy/Tor/residential-proxy traffic. IPinfo lookup failures fail closed into limited access. + +Response shapes: + +```jsonc +// Waiting room disabled — CLI should treat this as "always admitted" +{ "status": "disabled" } + +// In queue +{ + "status": "queued", + "instanceId": "e47…", + "model": "minimax/minimax-m2.7", + "position": 17, // 1-indexed within this model's queue + "queueDepth": 43, // size of this model's queue + "queueDepthByModel": { // snapshot of every model's queue — powers the + "minimax/minimax-m2.7": 43, // "N ahead" hint in the selector. Missing + "z-ai/glm-5.1": 4 // entries should be treated as 0. + }, + "estimatedWaitMs": 384000, + "queuedAt": "2026-04-17T12:00:00Z" +} + +// Admitted +{ + "status": "active", + "instanceId": "e47…", + "model": "minimax/minimax-m2.7", + "admittedAt": "2026-04-17T12:00:00Z", + "expiresAt": "2026-04-17T13:00:00Z", + "remainingMs": 3600000 +} + +// Past expiresAt but inside the grace window — agent in flight may finish, +// CLI must not accept new user prompts. `instanceId` is present so chat +// requests still authenticate; once we're past the hard cutoff the row is +// swept and the next GET returns `none` instead. +{ + "status": "ended", + "instanceId": "e47…", + "admittedAt": "2026-04-17T12:00:00Z", + "expiresAt": "2026-04-17T13:00:00Z", + "gracePeriodEndsAt": "2026-04-17T13:30:00Z", + "gracePeriodRemainingMs": 1800000 +} + +// POST only: user asked for a different model while an active session is +// bound to `currentModel`. HTTP 409. CLI must DELETE /session and re-POST +// to actually switch. +{ + "status": "model_locked", + "currentModel": "minimax/minimax-m2.7", + "requestedModel": "minimax/minimax-m2.7" +} +``` + +### `GET /api/v1/freebuff/session` + +**Read-only polling.** Does not mutate `active_instance_id`. The CLI uses this to refresh the countdown / queue position. The CLI sends its currently-held instance id via the `X-Freebuff-Instance-Id` header so the server can detect takeover by another CLI on the same account. + +Returns the same shapes as POST, plus: + +```jsonc +// User has no row at all — must call POST first +{ "status": "none", "message": "Call POST to join the waiting room." } + +// Active row exists but the supplied instance id no longer matches — +// another CLI on the same account took over. +{ "status": "superseded" } +``` + +### `DELETE /api/v1/freebuff/session` + +**End session immediately.** Deletes the row; the freed slot is picked up by the next admission tick. + +Response: `{ "status": "ended" }`. + +## Chat Completions Gate + +For free-mode requests (`codebuff_metadata.cost_mode === 'free'`), `_post.ts` calls `checkSessionAdmissible` after the per-user rate limiter and before the subscriber block-grant check. + +### Response codes + +| HTTP | `error` | When | +| ---- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| 426 | `freebuff_update_required` | Request did not include a `freebuff_instance_id` — the client is a pre-waiting-room build. The CLI shows the server-supplied message verbatim. | +| 428 | `waiting_room_required` | No session row exists. Client should call POST /session. | +| 429 | `waiting_room_queued` | Row exists with `status='queued'`. Client should keep polling GET. | +| 409 | `session_superseded` | Claimed `instance_id` does not match stored one — another CLI took over. | +| 410 | `session_expired` | `expires_at + grace < now()` (past the hard cutoff). Client should POST /session to re-queue. | + +Successful results carry one of three reasons: `disabled` (gate is off), `active` (`expires_at > now()`, `remainingMs` provided), or `draining` (`expires_at <= now() < expires_at + grace`, `gracePeriodRemainingMs` provided). The CLI should treat `draining` as "let any in-flight agent run finish, but block new user prompts" — see [Drain / Grace Window](#drain--grace-window) below. The corresponding wire status from `getSessionState` is `ended`. + +When the waiting room is disabled, the gate returns `{ ok: true, reason: 'disabled' }` without touching the DB. + +## Drain / Grace Window + +We don't want to kill an agent mid-run just because the user's session ticked over. After `expires_at`, the row enters a "draining" state for `SESSION_GRACE_MS` (30 min). During the drain window: + +- `checkSessionAdmissible` returns `{ ok: true, reason: 'draining', gracePeriodRemainingMs }` — chat completions still go through. +- `getSessionState` / `requestSession` return `{ status: 'ended', instanceId, ... }` on the wire. The CLI hides the input and shows the Enter-to-rejoin banner while still forwarding the instance id so in-flight agent work can keep streaming. +- `sweepExpired` skips the row, keeping it in the DB so the gate keeps working. +- `joinOrTakeOver` still treats the row as expired (`expires_at <= now()`), so a fresh POST re-queues at the back of the line. This means starting a new CLI during the drain window cleanly hands off to a queued seat rather than extending the current one. + +This is a **trust-the-client** design: the server still admits requests during the drain window, and we rely on the CLI to stop submitting new user prompts at `expires_at`. The 30-min hard cutoff caps the abuse surface — a malicious client that ignores the contract can extend a session by at most one grace window per expiry. + +## Estimated Wait Time + +Computed in `session-view.ts` (`WAIT_MS_PER_SPOT_AHEAD = 24_000`) as a rough per-spot estimate within the user's own model queue: + +``` +waitMs = (position - 1) * 24_000 +``` + +- Position 1 → 0 (next tick admits you) +- Position 2 → 24s, and so on. + +`position` is scoped to this model's queue — a user at position 1 in the `minimax/minimax-m2.7` queue is not affected by the depth of the `z-ai/glm-5.1` queue. The estimate is intentionally decoupled from the admission tick — it's a human-friendly rule-of-thumb for the UI, not a precise projection. Actual wait depends on admission-tick cadence, health-gated pauses, and deployment-hours availability (during a GLM Fireworks incident or outside 9am ET-5pm PT, only GLM's queue stalls; MiniMax keeps draining), so the real wait can be longer or shorter. + +## CLI Integration (frontend-side contract) + +The CLI: + +1. **On startup**, calls `POST /api/v1/freebuff/session` with the user's persisted model choice. Stores `instanceId` in memory (not on disk — startup must re-admit). +2. **Loops while `status === 'queued'`:** polls `GET /api/v1/freebuff/session` (with `X-Freebuff-Instance-Id`) every ~5s and renders `position / queueDepth / estimatedWaitMs` alongside the selected model. +3. **Model switch from the waiting room** → re-POSTs with the new model id. Server moves the row to the back of the new model's queue. If the server responds `model_locked` (we already got admitted on the old model in the meantime), the tick loop silently reverts the local selection to the locked model rather than interrupting the active session — users who really want to switch can `/end-session` deliberately. +4. **When `status === 'active'`**, renders `remainingMs` as a countdown. Re-polls GET every ~30s to stay honest with server-side state. Chat completions use the admitted session's model for the rest of the session. +5. **When `status === 'ended'`** (the server-side draining/grace shape, with `instanceId`), hides the input and shows the Enter-to-rejoin banner while still forwarding the instance id on outgoing chat requests so in-flight agent work can finish. +6. **When `status === 'superseded'`**, stops polling and shows the "close the other CLI" screen. +7. **On every chat request**, includes `codebuff_metadata.freebuff_instance_id: `. +8. **Handles chat-gate errors:** the same statuses are reachable via the gate's 409/410/428/429 for fast in-flight feedback, and the CLI calls the matching `markFreebuff*` helper to flip local state without waiting for the next poll. +9. **On clean exit**, calls `DELETE /api/v1/freebuff/session` so the next user can be admitted sooner. + +The `disabled` response means the server has the waiting room turned off. CLI treats it identically to `active` with infinite remaining time — no countdown, and chat requests can omit `freebuff_instance_id` entirely. + +## Multi-pod Behavior + +- **`/api/v1/freebuff/session` routes** are stateless per pod; all state lives in Postgres. Any pod can serve any request. +- **Chat completions gate** is a single `SELECT` per free-mode request. At high QPS this is the hottest path — the `user_id` PK lookup is O(1). If it ever becomes a problem, the obvious fix is to cache the session row for ~1s per pod. +- **Admission loop** runs on every pod. Per-model advisory locks serialize admission _within_ each model while allowing different models to admit on different pods concurrently. At any given tick, exactly one pod actually admits for each model; the rest early-return on that model's lock. +- **Fleet health probe** is cached per-pod (`HEALTH_CACHE_TTL_MS`, 25s). Each pod hits the Fireworks metrics endpoint at most ~2.4/min, staying under the 6 req/min account rate limit with a comfortable margin. + +## Abuse Resistance Summary + +| Attack | Mitigation | +| ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CLI keeps submitting new prompts past `expires_at` | Trusted client; bounded by 30-min hard cutoff at `expires_at + grace`. After that the gate returns `session_expired` and the user must re-queue. | +| Multiple sessions per account | PK on `user_id` — structurally impossible | +| Multiple CLIs sharing one session | `active_instance_id` rotates on POST; stale id → 409 | +| Client-forged timestamps | All timestamps server-supplied (`DEFAULT now()` or explicit) | +| Queue jumping via timestamp manipulation | `queued_at` is server-supplied; FIFO order is server-determined | +| Repeatedly calling POST to reset queue position | POST preserves `queued_at` for already-queued users | +| Two pods admitting the same user | Per-model `SELECT ... FOR UPDATE SKIP LOCKED` + per-model advisory xact lock | +| Spamming POST/GET to starve admission tick | Admission uses per-model Postgres advisory locks; DDoS protection is upstream (Next's global rate limits). Consider adding a per-user limiter on `/session` if traffic warrants. | +| Repeatedly POSTing different models to get across every queue | Single row per user (PK on `user_id`); switching models moves the row, never clones it. A user holds exactly one queue slot at any time. | +| Fireworks metrics endpoint down / slow | `getFleetHealth()` fails closed (timeout, non-OK, or missing API key) → every dedicated-deployment model is flagged `unhealthy` and its queue pauses. | +| One deployment degraded while others are fine | Health is classified per-deployment; only the affected model's queue pauses, so a degraded GLM deployment doesn't block MiniMax admissions. | +| Zombie expired sessions holding capacity | Swept on every admission tick, even when upstream is unhealthy | + +## Testing + +Pure logic covered by `web/src/server/free-session/__tests__/*.test.ts`: + +- `session-view.test.ts` — wait-time estimation, row→response mapping +- `public-api.test.ts` — all status transitions via in-memory DI store (including `model_locked` and cross-model switching) +- `admission.test.ts` — tick behaviour with mocked store + per-model health (healthy/degraded/unhealthy, absent-entry-defaults-to-healthy for serverless models) +- `fireworks-health.test.ts` — `classifyOne` decision table: KV-blocks thresholds, 5xx fraction, prefill queue p90 histogram, per-deployment independence + +Handler tests in `web/src/app/api/v1/freebuff/session/__tests__/session.test.ts` cover auth + request routing with a mocked `SessionDeps`. + +The real store (`store.ts`) and admission loop ticker (`admission.ts` — the scheduling wrapper around `runAdmissionTick`) are not directly unit-tested because they're thin glue over Postgres and `setTimeout`. Integration-level validation of the store requires a Postgres instance and is left for the e2e harness. + +## Known Gaps / Future Work + +- **No rate limit on `/session` itself.** A determined user could spam POST/GET. Current throughput is bounded by general per-IP limits upstream, but this should be tightened before large rollouts. +- **Estimated wait is coarse.** Could be improved by tracking actual admission rate over the last N minutes. +- **No admin UI.** To inspect queue depth, active count, or kick a user, you currently need DB access. A small admin endpoint under `/api/admin/freebuff/*` is a natural add. +- **No metrics exposure.** Consider emitting queue depth and active count to Prometheus / BigQuery. +- **Session length is global.** Per-user or per-tier session length would require a column on the row; currently all admitted users get the same lifetime. diff --git a/docs/request-flow.md b/docs/request-flow.md new file mode 100644 index 0000000000..427611525f --- /dev/null +++ b/docs/request-flow.md @@ -0,0 +1,180 @@ +# Request Flow: CLI → Server → CLI + +This document traces the exact path a user prompt takes from the Codebuff CLI through the SDK, agent runtime, server, and back. + +## Overview + +``` +┌─────────┐ ┌─────────┐ ┌───────────────┐ ┌────────────────┐ ┌──────────┐ +│ CLI │───▶│ SDK │───▶│ Agent Runtime │───▶│ Codebuff Server│───▶│ LLM API │ +│ (TUI) │◀───│ run.ts │◀───│ loopAgentSteps│◀───│ /v1/chat/... │◀───│(OR/OAI/..)│ +└─────────┘ └─────────┘ └───────────────┘ └────────────────┘ └──────────┘ +``` + +## Step-by-Step Flow + +### 1. CLI: User Input + +**Files:** `cli/src/hooks/use-send-message.ts`, `cli/src/hooks/helpers/send-message.ts` + +1. User types a prompt and hits Enter. +2. `prepareUserMessage()` processes the input: + - Collects pending bash context (terminal output since last prompt) + - Processes image and text attachments + - Creates a user message in the chat UI +3. `setupStreamingContext()` initializes: + - An `AbortController` (for user cancellation via Escape) + - A timer (tracks elapsed time) + - A batched message updater (efficiently updates the UI) +4. The CLI calls `client.run()` from the SDK. + +### 2. SDK: Orchestration + +**File:** `sdk/src/run.ts` + +1. `run()` → `runOnce()` is called with the prompt, agent ID, cost mode, and session state. +2. **Session state** is initialized (fresh) or restored (from `previousRun`). +3. **User identity** is verified via `getUserInfoFromApiKey()` (calls the web API). +4. **Tool handlers** are registered — these execute locally on the user's machine: + - `write_file`, `str_replace`, `apply_patch` → file edits + - `run_terminal_command` → shell commands + - `code_search`, `glob`, `list_directory` → file search + - `read_files` → file reading + - Custom tool definitions and MCP tools +5. **Action handlers** are registered to process server responses: + - `response-chunk` → streams text to the CLI + - `subagent-response-chunk` → streams subagent output + - `prompt-response` → final result (resolves the promise) + - `prompt-error` → error result +6. `callMainPrompt()` is called (fire-and-forget, with a `.catch()` handler). +7. The function returns a promise that resolves when `prompt-response` or an error arrives. + +### 3. Agent Runtime: Main Prompt + +**File:** `packages/agent-runtime/src/main-prompt.ts` + +1. `callMainPrompt()` resets credits to 0 (server controls cost tracking). +2. Assembles **local agent templates** from the project's `.agents/` directory. +3. Sends a `response-chunk` `start` event to the CLI. +4. `mainPrompt()` determines the **agent type** based on cost mode: + - `free` → `base-free` + - `normal` → `base` + - `max` → `base-max` + - `ask` → `ask` + - `experimental` → `base2` + - Fallback (default) → `base2` + - Or a custom agent ID +5. Calls `loopAgentSteps()` with the agent template, prompt, and session state. + +### 4. Agent Runtime: Agent Loop + +**File:** `packages/agent-runtime/src/run-agent-step.ts` + +1. `loopAgentSteps()` starts an **agent run** (recorded in the database). +2. Builds the **system prompt**, **tool definitions**, and **initial messages**. +3. Enters the main loop: + ``` + while (true) { + // 1. Run programmatic step (if agent has handleSteps) + // 2. Check if turn should end + // 3. Call runAgentStep() for LLM inference + // 4. Process tool calls and responses + } + ``` +4. Each `runAgentStep()` call: + - Checks context token count via the `/api/v1/token-count` endpoint + - Calls `getAgentStreamFromTemplate()` → `promptAiSdkStream()` + - `processStream()` iterates over the AI SDK stream, handling text chunks and tool calls + - Tool calls are sent back to the SDK via `requestToolCall`, executed locally, and results fed back +5. The loop continues until the agent signals completion (no more tool calls, or `task_completed` tool). +6. Sends a `response-chunk` `finish` event, then a `prompt-response` action with the final session state and output. + +### 5. LLM Call: Model Provider Selection + +**Files:** `sdk/src/impl/llm.ts`, `sdk/src/impl/model-provider.ts` + +`promptAiSdkStream()` selects the model provider: + +1. **Claude OAuth** — If the user has connected their Claude subscription and the model is a Claude model, requests go directly to `api.anthropic.com` using the user's OAuth token. Zero cost to the user's Codebuff credits. +2. **ChatGPT OAuth** — If the user has connected their ChatGPT subscription and the model is an OpenAI model, requests go to the ChatGPT backend API. +3. **Codebuff Backend** (default) — Requests go to `POST /api/v1/chat/completions` on the Codebuff web server, which routes to the appropriate LLM provider. + +For OAuth providers, rate limit errors trigger automatic fallback to the Codebuff backend (unless in free mode). + +The AI SDK's `streamText()` function handles the actual HTTP call, streaming, and retry logic. + +### 6. Server: Chat Completions Endpoint + +**File:** `web/src/app/api/v1/chat/completions/_post.ts` + +The server processes the request through several validation gates: + +1. **Parse request body** — Returns 400 if invalid JSON. +2. **Authenticate** — Extracts API key from `Authorization` header. Returns 401 if missing/invalid. +3. **Check ban status** — Returns 403 `account_suspended` if user is banned. +4. **Free mode country check** — For free mode requests, checks user's IP against allowed countries. Returns 403 `free_mode_unavailable` if not allowed. +5. **Validate agent run** — Checks the `run_id` exists and is in `running` status. Returns 400 if invalid. +6. **Subscription block grant** — For subscribers, ensures a billing block is active. Returns 429 `rate_limit_exceeded` if limit hit and fallback disabled. +7. **Credit check** — Returns 402 if user has no remaining credits (and not a free mode request). +8. **Route to LLM provider** — Based on the model, routes to: + - Fireworks AI (for supported models) + - OpenAI direct (for OpenAI models) + - OpenRouter (default, for all other models) +9. **Return response** — Streaming requests return an SSE stream (`text/event-stream`). Non-streaming requests return JSON. + +### 7. Response Flow Back to CLI + +1. The LLM provider streams tokens back to the server. +2. The server forwards the SSE stream to the AI SDK client. +3. `promptAiSdkStream()` yields chunks from the AI SDK's `fullStream`: + - `text-delta` → text content + - `tool-call` → tool invocation + - `error` → error handling (OAuth fallback, retries, etc.) +4. `processStream()` in agent-runtime handles each chunk: + - Text chunks → `sendAction({ type: 'response-chunk', chunk })` → SDK → CLI UI + - Tool calls → `requestToolCall()` → SDK executes locally → result fed back to stream +5. When the agent loop finishes, `callMainPrompt` sends: + - A `response-chunk` `finish` event (with total cost) + - A `prompt-response` action (with final session state and output) +6. The SDK's `handlePromptResponse()` validates the output against `AgentOutputSchema` and resolves the promise. +7. The CLI's `handleRunCompletion()` processes the result: + - Checks for known error types (out of credits, free mode unavailable) + - Updates the UI with completion time and credit cost + - Marks the message as complete + +## Tool Call Lifecycle + +Tool calls execute **locally on the user's machine**, not on the server: + +``` +LLM Response (tool_call) Agent Runtime processes stream + │ │ + ▼ ▼ + processStream() ─── requestToolCall ──▶ SDK run.ts + │ │ + │ handleToolCall() + │ │ + │ Executes locally + │ (file edit, terminal, search) + │ │ + ◀─────── tool result ───────────────┘ + │ + Feeds result back into next LLM call +``` + +## Session State + +Session state persists across prompts within a conversation: + +- `sessionState.mainAgentState.messageHistory` — Full conversation history +- `sessionState.fileContext` — Project files, knowledge files, custom tools +- The CLI stores the `RunState` from each run and passes it as `previousRun` to the next `client.run()` call + +## Cancellation + +When the user presses Escape: + +1. CLI aborts the `AbortController` +2. The `abort` signal propagates through the SDK → agent runtime → AI SDK +3. `loopAgentSteps` catches the `AbortError`, marks the run as `cancelled` +4. CLI's abort handler shows an interruption notice and marks the message complete diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000000..3862f66adb --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,45 @@ +# Testing + +- Prefer dependency injection over module mocking; define contracts in `common/src/types/contracts/`. +- Use `spyOn()` only for globals / legacy seams. +- Avoid `mock.module()` for functions; use `@codebuff/common/testing/mock-modules.ts` helpers for constants only. + +CLI hook testing note: React 19 + Bun + RTL `renderHook()` is unreliable; prefer integration tests via components for hook behavior. + +## CLI tmux Testing + +For testing CLI behavior via tmux, use the helper scripts in `scripts/tmux/`. These handle bracketed paste mode and session logging automatically. Session data is saved to `debug/tmux-sessions/` in YAML format and can be viewed with `bun scripts/tmux/tmux-viewer/index.tsx`. See `scripts/tmux/README.md` for details. + +Useful workflow for agents: + +```bash +# Start the dev CLI in a detached tmux session. +SESSION=$(./scripts/tmux/tmux-cli.sh start --name cli-check -w 160 -h 40 --wait 6) + +# Capture the initial screen. Captures are written to debug/tmux-sessions/$SESSION/. +./scripts/tmux/tmux-cli.sh capture "$SESSION" --label initial + +# Send a prompt. The helper uses bracketed paste so text is not dropped. +./scripts/tmux/tmux-cli.sh send "$SESSION" "Search for getAgentBaseName and report what you find" --wait-idle 4 + +# Capture after the run, then inspect the saved capture text. +./scripts/tmux/tmux-cli.sh capture "$SESSION" --label after-search --wait 2 + +# Clean up when finished. +./scripts/tmux/tmux-cli.sh stop "$SESSION" +``` + +If a change can be verified with a small local harness instead of a live model-backed CLI run, run that harness inside tmux too. This still checks terminal rendering and produces a capture: + +```bash +SESSION=$(./scripts/tmux/tmux-cli.sh start \ + --name render-check \ + -w 160 -h 20 \ + --wait 1 \ + --command "bun .context/my-render-check.tsx") + +./scripts/tmux/tmux-cli.sh capture "$SESSION" --label rendered +./scripts/tmux/tmux-cli.sh stop "$SESSION" +``` + +When verifying UI output, prefer checking the saved capture file for concrete strings that should and should not appear. For example, after expanding a code-searcher agent, check that the capture shows the search summary but not raw structured payload keys like `results:` or `stdout:`. diff --git a/eslint.config.js b/eslint.config.js index 0aaa64cddf..48ef179c78 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,6 +12,7 @@ export default tseslint.config( '**/.next/*', '**/.contentlayer/*', '**/node_modules/*', + 'agents-graveyard/**', // Archived/deprecated agents - no need to lint ], }, @@ -111,7 +112,7 @@ export default tseslint.config( 'newlines-between': 'always', }, ], - 'import/no-unresolved': 'warn', + 'import/no-unresolved': 'off', // Disabled: TypeScript/Bun handles module resolution; this rule produces false positives with path aliases 'import/no-duplicates': 'warn', 'unused-imports/no-unused-imports': 'warn', '@typescript-eslint/consistent-type-imports': [ @@ -121,7 +122,16 @@ export default tseslint.config( fixStyle: 'separate-type-imports', }, ], - 'no-unused-vars': 'warn', + 'no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', // Allow unused args prefixed with _ + varsIgnorePattern: '^_', // Allow unused vars prefixed with _ + args: 'none', // Don't check function arguments (common in callbacks with required signatures) + }, + ], + 'react-hooks/exhaustive-deps': 'off', // Disabled: plugin not configured for all packages + '@next/next/no-img-element': 'off', // Disabled: plugin not configured for all packages }, }, diff --git a/evals/buffbench/README.md b/evals/buffbench/README.md index 2707cdd2b2..9e6dc4d303 100644 --- a/evals/buffbench/README.md +++ b/evals/buffbench/README.md @@ -139,6 +139,7 @@ BuffBench supports running external CLI coding agents for comparison: - **Claude Code**: Use `external:claude` - requires `claude` CLI installed - **Codex**: Use `external:codex` - requires `codex` CLI installed +- **OpenCode**: Use `external:opencode` - requires `opencode` CLI installed Example comparing Codebuff vs Claude Code: @@ -164,6 +165,13 @@ npm install -g @openai/codex # Set OPENAI_API_KEY environment variable ``` +**OpenCode CLI:** +```bash +# Install from https://opencode.ai/docs/install +# Set OPENCODE_API_KEY environment variable +# BuffBench uses opencode/kimi-k2.6 by default; override with OPENCODE_MODEL if needed. +``` + ## Directory Structure ``` diff --git a/evals/buffbench/agent-runner.ts b/evals/buffbench/agent-runner.ts index 1cf21a4ecf..57f2fa1e50 100644 --- a/evals/buffbench/agent-runner.ts +++ b/evals/buffbench/agent-runner.ts @@ -1,22 +1,23 @@ -import { execSync } from 'child_process' +import { execSync, exec } from 'child_process' import { promisify } from 'util' -import { exec } from 'child_process' const execAsync = promisify(exec) import { withTimeout } from '@codebuff/common/util/promise' -import { CodebuffClient } from '@codebuff/sdk' + import { withTestRepo } from '../subagents/test-repo-utils' import { ClaudeRunner } from './runners/claude' -import { CodexRunner } from './runners/codex' import { CodebuffRunner } from './runners/codebuff' +import { CodexRunner } from './runners/codex' +import { OpenCodeRunner } from './runners/opencode' -import type { EvalCommitV2, FinalCheckOutput } from './types' import type { Runner, AgentStep } from './runners/runner' +import type { EvalCommitV2, FinalCheckOutput } from './types' +import type { CodebuffClient } from '@codebuff/sdk' export type { AgentStep } -export type ExternalAgentType = 'claude' | 'codex' +export type ExternalAgentType = 'claude' | 'codex' | 'opencode' export async function runAgentOnCommit({ client, @@ -75,6 +76,8 @@ export async function runAgentOnCommit({ runner = new ClaudeRunner(repoDir, env) } else if (externalAgentType === 'codex') { runner = new CodexRunner(repoDir, env) + } else if (externalAgentType === 'opencode') { + runner = new OpenCodeRunner(repoDir, env) } else { runner = new CodebuffRunner({ cwd: repoDir, diff --git a/evals/buffbench/analyze-task-scores.ts b/evals/buffbench/analyze-task-scores.ts index 21fb8361a9..4edf5b0782 100644 --- a/evals/buffbench/analyze-task-scores.ts +++ b/evals/buffbench/analyze-task-scores.ts @@ -30,12 +30,7 @@ interface EvalResult { judgeResult?: JudgeResult } -interface TaskScore { - taskNum: number - taskName: string - scores: number[] - runs: string[] -} +// TaskScore interface removed - not used (inline types used instead) async function getLogDirectories(): Promise { const entries = await readdir(LOGS_DIR) diff --git a/evals/buffbench/eval-codebuff.json b/evals/buffbench/eval-codebuff.json index 7c5098637a..67ef66a02f 100644 --- a/evals/buffbench/eval-codebuff.json +++ b/evals/buffbench/eval-codebuff.json @@ -27,8 +27,8 @@ "NEXTAUTH_SECRET": "test-nextauth-secret", "STRIPE_SECRET_KEY": "test-stripe-key", "STRIPE_WEBHOOK_SECRET_KEY": "test-stripe-webhook", - "STRIPE_USAGE_PRICE_ID": "test-price-id", "STRIPE_TEAM_FEE_PRICE_ID": "test-team-price-id", + "STRIPE_USAGE_PRICE_ID": "test-usage-price-id", "LOOPS_API_KEY": "test-loops", "DISCORD_PUBLIC_KEY": "test-discord-public", "DISCORD_BOT_TOKEN": "test-discord-bot", diff --git a/evals/buffbench/eval-task-generator.ts b/evals/buffbench/eval-task-generator.ts index cddfbd9224..bc828dfdba 100644 --- a/evals/buffbench/eval-task-generator.ts +++ b/evals/buffbench/eval-task-generator.ts @@ -1,7 +1,9 @@ -import { CodebuffClient, type AgentDefinition } from '@codebuff/sdk' -import fileExplorerDef from '../../agents/file-explorer/file-explorer' -import findAllReferencerDef from '../../agents/file-explorer/find-all-referencer' +import type { CodebuffClient} from '@codebuff/sdk'; +import { type AgentDefinition } from '@codebuff/sdk' + import { PLACEHOLDER } from '../../agents/types/secret-agent-definition' +import fileExplorerDef from '../../agents-graveyard/file-explorer/file-explorer' +import findAllReferencerDef from '../../agents-graveyard/file-explorer/find-all-referencer' const evalTaskGeneratorAgentDef: AgentDefinition = { id: 'eval-task-generator', diff --git a/evals/buffbench/format-output.ts b/evals/buffbench/format-output.ts index 09f41c5276..d30517ce43 100644 --- a/evals/buffbench/format-output.ts +++ b/evals/buffbench/format-output.ts @@ -164,7 +164,7 @@ export function formatTraceAnalysis(params: { recommendations: string[] }> }): string { - const { commit, overallAnalysis, agentFeedback } = params + const { overallAnalysis, agentFeedback } = params const lines: string[] = [] const separator = '='.repeat(80) @@ -172,7 +172,7 @@ export function formatTraceAnalysis(params: { lines.push('') lines.push(separator) - lines.push(`TRACE ANALYSIS: ${commit.id} (${commit.sha.slice(0, 7)})`) + lines.push(`TRACE ANALYSIS`) lines.push(separator) lines.push('') diff --git a/evals/buffbench/gen-evals.ts b/evals/buffbench/gen-evals.ts index eb07704d10..3817feefdd 100644 --- a/evals/buffbench/gen-evals.ts +++ b/evals/buffbench/gen-evals.ts @@ -1,16 +1,17 @@ import { execSync } from 'child_process' -import { createTwoFilesPatch } from 'diff' import fs from 'fs' import path from 'path' -import { mapLimit } from 'async' -import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' +import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' import { CodebuffClient, getUserCredentials } from '@codebuff/sdk' -import { extractRepoNameFromUrl } from './setup-test-repo' -import { withTestRepoAndParent } from '../subagents/test-repo-utils' +import { mapLimit } from 'async' +import { createTwoFilesPatch } from 'diff' + import { generateEvalTask } from './eval-task-generator' import { filterSupplementalFiles } from './filter-supplemental-files' +import { extractRepoNameFromUrl } from './setup-test-repo' +import { withTestRepoAndParent } from '../subagents/test-repo-utils' import type { EvalDataV2, EvalCommitV2, FileDiff } from './types' @@ -225,7 +226,7 @@ export async function generateEvalFileV2({ ) } - const batchResults = await mapLimit( + const _batchResults = await mapLimit( commitShas, BATCH_SIZE, async (commitSha: string) => { diff --git a/evals/buffbench/gen-repo-eval.ts b/evals/buffbench/gen-repo-eval.ts index ec52aedcf8..068a637759 100644 --- a/evals/buffbench/gen-repo-eval.ts +++ b/evals/buffbench/gen-repo-eval.ts @@ -3,8 +3,8 @@ import fs from 'fs' import path from 'path' -import { pickCommits } from './pick-commits' import { generateEvalFileV2 } from './gen-evals' +import { pickCommits } from './pick-commits' export async function generateRepoEvalV2(repoUrl: string): Promise { console.log(`\n=== Git Evals V2: Generating Eval for ${repoUrl} ===\n`) diff --git a/evals/buffbench/judge.ts b/evals/buffbench/judge.ts index 7a6a329b31..eea09deba9 100644 --- a/evals/buffbench/judge.ts +++ b/evals/buffbench/judge.ts @@ -1,10 +1,11 @@ +import fs from 'fs' +import path from 'path' + +import { withTimeout } from '@codebuff/common/util/promise' import { z } from 'zod/v4' import type { EvalCommitV2 } from './types' import type { AgentDefinition, CodebuffClient } from '@codebuff/sdk' -import { withTimeout } from '@codebuff/common/util/promise' -import path from 'path' -import fs from 'fs' const DEBUG_ERROR = true @@ -122,17 +123,17 @@ Provide detailed analysis, strengths, weaknesses, and numerical scores.`, const judgeAgents: Record = { 'judge-gpt': { id: 'judge-gpt', - model: 'openai/gpt-5.1', + model: 'openai/gpt-5.4', ...judgeAgentBase, }, 'judge-gemini': { id: 'judge-gemini', - model: 'google/gemini-3-pro-preview', + model: 'google/gemini-3.1-pro-preview', ...judgeAgentBase, }, 'judge-sonnet': { id: 'judge-claude', - model: 'anthropic/claude-sonnet-4.5', + model: 'anthropic/claude-sonnet-4.6', ...judgeAgentBase, }, } diff --git a/evals/buffbench/main-hard-tasks.ts b/evals/buffbench/main-hard-tasks.ts index c28aa332e2..0d03c20f0d 100644 --- a/evals/buffbench/main-hard-tasks.ts +++ b/evals/buffbench/main-hard-tasks.ts @@ -2,6 +2,7 @@ import fs from 'fs' import path from 'path' import { runBuffBench } from './run-buffbench' + import type { EvalDataV2 } from './types' // Load task IDs from an eval file @@ -12,6 +13,8 @@ function loadTaskIds(evalPath: string): string[] { } async function main() { + const saveTraces = process.argv.includes('--save-traces') + const evalPaths = [ path.join(__dirname, 'eval-codebuff2.json'), path.join(__dirname, 'eval-manifold2.json'), @@ -32,6 +35,7 @@ async function main() { agents: ['base2', 'external:claude'], taskIds: allTaskIds, taskConcurrency: 4, + saveTraces, }) process.exit(0) diff --git a/evals/buffbench/main-nightly.ts b/evals/buffbench/main-nightly.ts index 840365a0bd..35998fbc21 100644 --- a/evals/buffbench/main-nightly.ts +++ b/evals/buffbench/main-nightly.ts @@ -3,18 +3,22 @@ import path from 'path' import { sendBasicEmail } from '@codebuff/internal/loops' import { runBuffBench } from './run-buffbench' -import type { AgentEvalResults } from './types' + import type { MetaAnalysisResult } from './meta-analyzer' +import type { AgentEvalResults } from './types' async function main() { + const saveTraces = process.argv.includes('--save-traces') + console.log('Starting nightly buffbench evaluation...') console.log('Eval set: codebuff') console.log() const results = await runBuffBench({ evalDataPaths: [ path.join(__dirname, 'eval-codebuff.json')], - agents: ['base2-lite'], - taskConcurrency: 3, + agents: ['base2-free'], + taskConcurrency: 5, + saveTraces, }) console.log('\nNightly buffbench evaluation completed successfully!') diff --git a/evals/buffbench/main-single-eval.ts b/evals/buffbench/main-single-eval.ts index 229251932f..bff2d322bf 100644 --- a/evals/buffbench/main-single-eval.ts +++ b/evals/buffbench/main-single-eval.ts @@ -3,10 +3,13 @@ import path from 'path' import { runBuffBench } from './run-buffbench' async function main() { + const saveTraces = process.argv.includes('--save-traces') + await runBuffBench({ evalDataPaths: [path.join(__dirname, 'eval-codebuff.json')], - agents: ['base2'], - taskIds: ['filter-system-history'], + agents: ['base2-free-deepseek-v4'], + taskIds: ['server-agent-validation'], + saveTraces, }) process.exit(0) diff --git a/evals/buffbench/main.ts b/evals/buffbench/main.ts index a1739f50b1..0173a09fba 100644 --- a/evals/buffbench/main.ts +++ b/evals/buffbench/main.ts @@ -3,13 +3,17 @@ import path from 'path' import { runBuffBench } from './run-buffbench' async function main() { + const saveTraces = process.argv.includes('--save-traces') + // Compare Codebuff agents against external CLI agents // Use 'external:claude' for Claude Code CLI // Use 'external:codex' for OpenAI Codex CLI + // Use 'external:opencode' for OpenCode CLI await runBuffBench({ evalDataPaths: [path.join(__dirname, 'eval-codebuff.json')], - agents: ['base2', 'external:claude', 'external:codex'], - taskConcurrency: 1, + agents: ['base2-free-evals'], + taskConcurrency: 6, + saveTraces, }) process.exit(0) diff --git a/evals/buffbench/meta-analyzer.ts b/evals/buffbench/meta-analyzer.ts index c0819414aa..38f3750d53 100644 --- a/evals/buffbench/meta-analyzer.ts +++ b/evals/buffbench/meta-analyzer.ts @@ -1,9 +1,11 @@ -import type { CodebuffClient, AgentDefinition } from '@codebuff/sdk' -import { withTimeout } from '@codebuff/common/util/promise' -import { getErrorObject } from '@codebuff/common/util/error' import fs from 'fs' import path from 'path' +import { getErrorObject } from '@codebuff/common/util/error' +import { withTimeout } from '@codebuff/common/util/promise' + +import type { CodebuffClient, AgentDefinition } from '@codebuff/sdk' + export interface TaskAnalysisData { commitSha: string prompt: string diff --git a/evals/buffbench/pick-commits.ts b/evals/buffbench/pick-commits.ts index 0e18d77ded..a694836e54 100644 --- a/evals/buffbench/pick-commits.ts +++ b/evals/buffbench/pick-commits.ts @@ -398,7 +398,7 @@ async function screenCommitsWithGpt5( const prompt = `${COMMIT_SCREENING_PROMPT}\n\nCommit to evaluate:\n\n${commitInfo}` try { - const response = await promptAiSdkStructured({ + const result = await promptAiSdkStructured({ messages: [userMessage(prompt)], schema: CommitSelectionSchema, model: models.openrouter_gpt5, @@ -414,6 +414,14 @@ async function screenCommitsWithGpt5( signal: new AbortController().signal, }) + // Handle aborted request + if (result.aborted) { + console.log(`Commit ${commit.sha.substring(0, 8)} screening aborted`) + return null + } + + const response = result.value + // Handle empty or invalid response if ( !response || diff --git a/evals/buffbench/run-buffbench.ts b/evals/buffbench/run-buffbench.ts index 7be48bd30d..b94ab04278 100644 --- a/evals/buffbench/run-buffbench.ts +++ b/evals/buffbench/run-buffbench.ts @@ -27,9 +27,13 @@ function parseAgentId(agent: string): { } { if (agent.startsWith('external:')) { const externalType = agent.slice('external:'.length) as ExternalAgentType - if (externalType !== 'claude' && externalType !== 'codex') { + if ( + externalType !== 'claude' && + externalType !== 'codex' && + externalType !== 'opencode' + ) { throw new Error( - `Unknown external agent type: ${externalType}. Supported: claude, codex`, + `Unknown external agent type: ${externalType}. Supported: claude, codex, opencode`, ) } return { agentId: agent, externalAgentType: externalType } @@ -57,6 +61,7 @@ async function runTask(options: { printEvents: boolean finalCheckCommands?: string[] disableAnalysis?: boolean + saveTraces?: boolean }) { const { client, @@ -74,6 +79,7 @@ async function runTask(options: { printEvents, finalCheckCommands, disableAnalysis, + saveTraces = false, } = options console.log( @@ -173,6 +179,24 @@ async function runTask(options: { finalCheckOutputs: agentResult.finalCheckOutputs, }) + // Save judge traces to separate files if saveTraces is enabled + if (saveTraces) { + const tracesDir = path.join(logsDir, 'traces') + if (!fs.existsSync(tracesDir)) { + fs.mkdirSync(tracesDir, { recursive: true }) + } + + // Save agent trace only (not judge traces) + const agentTracePath = path.join( + tracesDir, + `${index + 1}-${safeTaskId}-${safeAgentId}-${safeCommitShort}-agent.json`, + ) + fs.writeFileSync( + agentTracePath, + JSON.stringify(agentResult.trace, null, 2), + ) + } + fs.writeFileSync( tracePath, JSON.stringify(commitTraces[commitTraces.length - 1], null, 2), @@ -300,6 +324,7 @@ export async function runBuffBench(options: { taskIds?: string[] extractLessons?: boolean disableAnalysis?: boolean + saveTraces?: boolean }) { const { evalDataPaths, @@ -308,6 +333,7 @@ export async function runBuffBench(options: { taskIds, extractLessons = false, disableAnalysis = false, + saveTraces = false, } = options if (evalDataPaths.length === 0) { @@ -389,7 +415,7 @@ export async function runBuffBench(options: { }) // Load local agent definitions and type definition file for analyzers - const agentsPath = path.join(__dirname, '../../.agents') + const agentsPath = path.join(__dirname, '../../agents') const loadedAgents = await loadLocalAgents({ agentsPath }) const agentTypeDefinitionPath = path.join( agentsPath, @@ -453,6 +479,7 @@ export async function runBuffBench(options: { printEvents: agents.length === 1 && taskConcurrency === 1, finalCheckCommands: evalData.finalCheckCommands, disableAnalysis, + saveTraces, }), ) }) diff --git a/evals/buffbench/runners/claude.ts b/evals/buffbench/runners/claude.ts index 0cb083c215..1ecd200567 100644 --- a/evals/buffbench/runners/claude.ts +++ b/evals/buffbench/runners/claude.ts @@ -46,7 +46,7 @@ export class ClaudeRunner implements Runner { stdio: ['ignore', 'pipe', 'pipe'], }) - let stdout = '' + let _stdout = '' let stderr = '' let responseText = '' let toolCalls: PrintModeToolCall[] = [] @@ -69,7 +69,7 @@ export class ClaudeRunner implements Runner { child.stdout.on('data', (data: Buffer) => { const chunk = data.toString() - stdout += chunk + _stdout += chunk // Parse streaming JSON output from Claude CLI const lines = chunk.split('\n').filter((line) => line.trim()) diff --git a/evals/buffbench/runners/codebuff.ts b/evals/buffbench/runners/codebuff.ts index 1eef99f049..867b95ee1a 100644 --- a/evals/buffbench/runners/codebuff.ts +++ b/evals/buffbench/runners/codebuff.ts @@ -1,10 +1,10 @@ +import { execSync } from 'child_process' import fs from 'fs' import path from 'path' -import { execSync } from 'child_process' - -import { CodebuffClient } from '@codebuff/sdk' import type { Runner, RunnerResult, AgentStep } from './runner' +import type { CodebuffClient } from '@codebuff/sdk' + const DEBUG_ERROR = true diff --git a/evals/buffbench/runners/codex.ts b/evals/buffbench/runners/codex.ts index bfd1ae4a75..b8a3ad7726 100644 --- a/evals/buffbench/runners/codex.ts +++ b/evals/buffbench/runners/codex.ts @@ -42,12 +42,12 @@ export class CodexRunner implements Runner { stdio: ['ignore', 'pipe', 'pipe'], }) - let stdout = '' + let _stdout = '' let stderr = '' child.stdout.on('data', (data: Buffer) => { const chunk = data.toString() - stdout += chunk + _stdout += chunk process.stdout.write(chunk) // Codex outputs events as JSON lines in some modes diff --git a/evals/buffbench/runners/index.ts b/evals/buffbench/runners/index.ts index 99adc3d28a..0567543ccc 100644 --- a/evals/buffbench/runners/index.ts +++ b/evals/buffbench/runners/index.ts @@ -1,3 +1,4 @@ export { ClaudeRunner } from './claude' export { CodexRunner } from './codex' +export { OpenCodeRunner } from './opencode' export type { Runner, RunnerResult } from './runner' diff --git a/evals/buffbench/runners/opencode.ts b/evals/buffbench/runners/opencode.ts new file mode 100644 index 0000000000..a34aaf815f --- /dev/null +++ b/evals/buffbench/runners/opencode.ts @@ -0,0 +1,252 @@ +import { execSync, spawn } from 'child_process' + +import type { AgentStep, Runner, RunnerResult } from './runner' +import type { + PrintModeToolCall, + PrintModeToolResult, +} from '@codebuff/common/types/print-mode' +import type { JSONValue } from '@codebuff/common/types/json' + +const OPENCODE_MODEL = 'opencode/kimi-k2.6' + +function toJsonValue(value: unknown): JSONValue { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value + } + + if (Array.isArray(value)) { + return value.map(toJsonValue) + } + + if (typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, toJsonValue(entry)]), + ) + } + + return String(value) +} + +type OpenCodeEvent = { + type?: string + sessionID?: string + error?: { + name?: string + message?: string + statusCode?: number + data?: { + message?: string + } + } + part?: { + id?: string + type?: string + text?: string + tool?: string + callID?: string + state?: { + input?: unknown + output?: unknown + } + cost?: number + } +} + +function formatOpenCodeError(error: OpenCodeEvent['error']): string { + const message = + error?.data?.message || + error?.message || + error?.name || + 'OpenCode emitted an error event.' + + return error?.statusCode ? `${message} (status ${error.statusCode})` : message +} + +export class OpenCodeRunner implements Runner { + private cwd: string + private env: Record + + constructor(cwd: string, env: Record = {}) { + this.cwd = cwd + this.env = env + } + + async run(prompt: string): Promise { + const steps: AgentStep[] = [] + let totalCostUsd = 0 + + return new Promise((resolve, reject) => { + let openCodeError: string | undefined + const model = + this.env.OPENCODE_MODEL || process.env.OPENCODE_MODEL || OPENCODE_MODEL + const args = [ + 'run', + '--model', + model, + '--format', + 'json', + '--agent', + 'build', + prompt, + ] + + console.log(`[OpenCodeRunner] Running: opencode run --model ${model}`) + + const child = spawn('opencode', args, { + cwd: this.cwd, + env: { + ...process.env, + ...this.env, + OPENCODE_API_KEY: + this.env.OPENCODE_API_KEY || process.env.OPENCODE_API_KEY, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + let stdoutBuffer = '' + let stderr = '' + + const processEvent = (event: OpenCodeEvent) => { + if (event.type === 'error') { + openCodeError = formatOpenCodeError(event.error) + steps.push({ + type: 'text', + text: `[OpenCode error] ${openCodeError}`, + }) + return + } + + const part = event.part + if (!part) { + return + } + + if (event.type === 'text' || part.type === 'text') { + const text = part.text ?? '' + if (text.length > 0) { + steps.push({ type: 'text', text }) + process.stdout.write(text) + } + return + } + + if (event.type === 'step_finish' || part.type === 'step-finish') { + if (typeof part.cost === 'number') { + totalCostUsd += part.cost + } + return + } + + if (part.type === 'tool') { + const toolName = part.tool ?? 'unknown' + const toolCallId = part.callID ?? part.id ?? `opencode-${Date.now()}` + const input = part.state?.input ?? {} + + const toolCall: PrintModeToolCall = { + type: 'tool_call', + toolName, + toolCallId, + input: + input && typeof input === 'object' + ? (input as Record) + : { input }, + } + steps.push(toolCall) + + if (part.state && 'output' in part.state) { + const toolResult: PrintModeToolResult = { + type: 'tool_result', + toolName, + toolCallId, + output: [ + { + type: 'json', + value: toJsonValue(part.state.output ?? ''), + }, + ], + } + steps.push(toolResult) + } + } + } + + const processLine = (line: string) => { + if (!line.trim()) { + return + } + + try { + processEvent(JSON.parse(line)) + } catch { + steps.push({ type: 'text', text: line }) + } + } + + child.stdout.on('data', (data: Buffer) => { + stdoutBuffer += data.toString() + + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) + } + }) + + child.stderr.on('data', (data: Buffer) => { + stderr += data.toString() + process.stderr.write(data) + }) + + child.on('error', (error) => { + reject( + new Error( + `OpenCode CLI failed to start: ${error.message}. Make sure 'opencode' is installed and in PATH.`, + ), + ) + }) + + child.on('close', (code) => { + if (stdoutBuffer.trim()) { + processLine(stdoutBuffer) + } + + let diff = '' + try { + execSync('git add .', { cwd: this.cwd, stdio: 'ignore' }) + diff = execSync('git diff HEAD', { + cwd: this.cwd, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + }) + } catch { + // Ignore git errors + } + + if (code !== 0) { + reject( + new Error( + `OpenCode CLI exited with code ${code}. stderr: ${stderr}`, + ), + ) + return + } + + if (openCodeError) { + reject(new Error(openCodeError)) + return + } + + resolve({ + steps, + totalCostUsd, + diff, + }) + }) + }) + } +} diff --git a/evals/buffbench/trace-analyzer.ts b/evals/buffbench/trace-analyzer.ts index 0ef9d9b25e..f4cc25eb88 100644 --- a/evals/buffbench/trace-analyzer.ts +++ b/evals/buffbench/trace-analyzer.ts @@ -1,11 +1,14 @@ -import type { AgentStep } from './agent-runner' -import type { JudgingResult } from './judge' -import type { AgentDefinition, CodebuffClient } from '@codebuff/sdk' -import { withTimeout } from '@codebuff/common/util/promise' import { getErrorObject } from '@codebuff/common/util/error' +import { withTimeout } from '@codebuff/common/util/promise' + import { truncateTrace } from './trace-utils' +import type { AgentStep } from './agent-runner' +import type { JudgingResult } from './judge' import type { FinalCheckOutput } from './types' +import type { AgentDefinition, CodebuffClient } from '@codebuff/sdk' + + export interface AgentTraceData { agentId: string diff --git a/evals/impl/agent-runtime.ts b/evals/impl/agent-runtime.ts index a9801f59b1..d20cb54caa 100644 --- a/evals/impl/agent-runtime.ts +++ b/evals/impl/agent-runtime.ts @@ -39,6 +39,7 @@ export const EVALS_AGENT_RUNTIME_IMPL = Object.freeze({ referral_code: 'ref-test-code', stripe_customer_id: null, banned: false, + created_at: new Date('2024-01-01T00:00:00Z'), }), fetchAgentFromDatabase: async () => null, startAgentRun: async () => 'test-agent-run-id', diff --git a/evals/package.json b/evals/package.json index 4f33a8dd03..c27555a957 100644 --- a/evals/package.json +++ b/evals/package.json @@ -23,11 +23,12 @@ "run-eval-set": "bun run git-evals/run-eval-set.ts", "run-buffbench": "bun run buffbench/main.ts", "run-buffbench-nightly": "bun run buffbench/main-nightly.ts", + "trigger-buffbench": "bun run scripts/trigger-buffbench.ts", "setup-codebuff-repo": "bun run setup-codebuff-repo.ts" }, "sideEffects": false, "engines": { - "bun": "^1.3.5" + "bun": "1.3.11" }, "dependencies": { "@anthropic-ai/claude-code": "^2.0.56", @@ -39,7 +40,6 @@ "@oclif/parser": "^3.8.17", "async": "^3.2.6", "diff": "^8.0.2", - "lodash": "4.17.21", "p-limit": "^6.2.0", "zod": "^4.2.1" }, diff --git a/evals/scaffolding.ts b/evals/scaffolding.ts index 9d4adc31da..eb221f4228 100644 --- a/evals/scaffolding.ts +++ b/evals/scaffolding.ts @@ -13,7 +13,7 @@ import { import type { ProjectFileContext } from '@codebuff/common/util/file' -let projectRootForMocks: string | undefined +let _projectRootForMocks: string | undefined function readMockFile(projectRoot: string, filePath: string): string | null { const fullPath = path.join(projectRoot, filePath) @@ -25,13 +25,13 @@ function readMockFile(projectRoot: string, filePath: string): string | null { } export function createFileReadingMock(projectRoot: string) { - projectRootForMocks = projectRoot + _projectRootForMocks = projectRoot } export async function getProjectFileContext( projectPath: string, ): Promise { - projectRootForMocks = projectPath + _projectRootForMocks = projectPath const fileTree = await getProjectFileTree({ projectRoot: projectPath, fs: fs.promises, diff --git a/evals/scripts/trigger-buffbench.ts b/evals/scripts/trigger-buffbench.ts new file mode 100644 index 0000000000..65f7176084 --- /dev/null +++ b/evals/scripts/trigger-buffbench.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +const { execSync } = require('child_process') + +function log(message: string) { + console.log(`${message}`) +} + +function error(message: string) { + console.error(`❌ ${message}`) + process.exit(1) +} + +function checkGitHubToken() { + const token = process.env.CODEBUFF_GITHUB_TOKEN + if (!token) { + error( + 'CODEBUFF_GITHUB_TOKEN environment variable is required but not set.\n' + + 'Please set it with your GitHub personal access token or use the infisical setup.' + ) + } + return token +} + +function getCurrentBranch(): string { + try { + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim() + } catch { + return 'main' + } +} + +async function triggerWorkflow(token: string, branch: string) { + try { + const triggerCmd = `curl -s -w "HTTP Status: %{http_code}" -X POST \ + -H "Accept: application/vnd.github.v3+json" \ + -H "Authorization: token ${token}" \ + -H "Content-Type: application/json" \ + https://api.github.com/repos/CodebuffAI/codebuff/actions/workflows/buffbench.yml/dispatches \ + -d '{"ref":"${branch}"}'` + + const response = execSync(triggerCmd, { encoding: 'utf8' }) + + if (response.includes('workflow_dispatch')) { + log(`⚠️ Workflow dispatch failed: ${response}`) + log( + 'Please manually trigger the workflow at: https://github.com/CodebuffAI/codebuff/actions/workflows/buffbench.yml', + ) + } else { + log('🎉 BuffBench workflow triggered!') + } + } catch (err: any) { + log(`⚠️ Failed to trigger workflow automatically: ${err.message}`) + log( + 'You may need to trigger it manually at: https://github.com/CodebuffAI/codebuff/actions/workflows/buffbench.yml', + ) + } +} + +async function main() { + const branch = process.argv[2] || getCurrentBranch() + + log('🧪 Triggering BuffBench workflow...') + log(`Branch: ${branch}`) + + const token = checkGitHubToken() + if (!token) return + log('✅ Using CODEBUFF_GITHUB_TOKEN') + + await triggerWorkflow(token, branch) + + log('') + log('Monitor progress at: https://github.com/CodebuffAI/codebuff/actions/workflows/buffbench.yml') +} + +main().catch((err) => { + error(`Failed to trigger BuffBench: ${err.message}`) +}) diff --git a/evals/subagents/test-repo-utils.ts b/evals/subagents/test-repo-utils.ts index 53ec30da2b..60039a3a62 100644 --- a/evals/subagents/test-repo-utils.ts +++ b/evals/subagents/test-repo-utils.ts @@ -1,7 +1,8 @@ +import { execSync } from 'child_process' import fs from 'fs' -import path from 'path' import * as os from 'os' -import { execSync } from 'child_process' +import path from 'path' + import { getErrorObject } from '@codebuff/common/util/error' /** diff --git a/freebuff/README.md b/freebuff/README.md new file mode 100644 index 0000000000..7e757ce410 --- /dev/null +++ b/freebuff/README.md @@ -0,0 +1,94 @@ +# Freebuff + +**The free coding agent.** No subscription. No configuration. Start in seconds. + +An AI coding agent that runs in your terminal — describe what you want, and Freebuff edits your code. + +## Install + +```bash +npm install -g freebuff +``` + +## Usage + +```bash +cd ~/my-project +freebuff +``` + +## Why Freebuff? + +**Simple** — No modes. No config. Just works. + +**Fast** — 5–10× speed up. Faster models plus context gathering in seconds rather than minutes. + +**Loaded** — Built-in web research, browser use, and more. + +**Connect ChatGPT** — Link your ChatGPT subscription for planning and review. + +## Features + +- **File mentions** — Use `@filename` to reference specific files +- **Agent mentions** — Use `@AgentName` to invoke specialized agents +- **Bash mode** — Run terminal commands with `!command` or `/bash` +- **Chat history** — Resume past conversations with `/history` +- **Knowledge files** — Add `knowledge.md` to your project for context +- **Themes** — Toggle light/dark mode with `/theme:toggle` + +## Commands + +| Command | Description | +| --------------- | -------------------------------- | +| `/help` | Show keyboard shortcuts and tips | +| `/new` | Start a new conversation | +| `/history` | Browse past conversations | +| `/bash` | Enter bash mode | +| `/init` | Create a starter knowledge.md | +| `/feedback` | Share feedback | +| `/theme:toggle` | Toggle light/dark mode | +| `/logout` | Sign out | +| `/exit` | Quit | + +## FAQ + +**How can it be free?** Freebuff is supported by ads shown in the CLI. + +**What models do you use?** DeepSeek V4 Pro (smartest, but its API collects data for training), Kimi K2.6, MiniMax M2.7, or DeepSeek V4 Flash as the main coding agent. Gemini 3.1 Flash Lite handles file finding and research, and GPT-5.4 handles deep thinking if you connect your ChatGPT subscription. + +**Are you training on my data?** No. We only use model providers that do not train on our requests. Your code stays yours. + +**Which countries is Freebuff available in?** Freebuff is currently available in select countries. See [freebuff.com](https://freebuff.com) for the full list. + +**What data do you store?** We don't store your codebase. We only collect minimal logs for debugging purposes. + +## How It Works + +Freebuff connects to a cloud backend and uses models optimized for fast, high-quality assistance. Ads are shown to support the free tier. + +## Project Structure + +``` +freebuff/ +├── cli/ # CLI build & npm release files +└── web/ # Freebuff website +``` + +## Building from Source + +```bash +# From the repo root +bun freebuff/cli/build.ts 1.0.0 +``` + +## Links + +- [Documentation](https://codebuff.com/docs) +- [GitHub](https://github.com/CodebuffAI/codebuff) +- [Website](https://codebuff.com) + +> Built on the [Codebuff](https://codebuff.com) platform. + +## License + +MIT diff --git a/freebuff/SPEC.md b/freebuff/SPEC.md new file mode 100644 index 0000000000..134cd471c7 --- /dev/null +++ b/freebuff/SPEC.md @@ -0,0 +1,371 @@ +# Freebuff Spec + +Freebuff is a free-only variant of the Codebuff CLI, distributed as a separate npm package (`freebuff`). It reuses the entire `cli/` package but builds with a compile-time flag that strips out paid features, subscription logic, credits display, and mode switching — leaving only the FREE mode experience. + +--- + +## 1. Build-Time Flag + +### Environment Variable + +- **`FREEBUFF_MODE=true`** — set during the build to produce a Freebuff binary. +- Injected via `--define process.env.FREEBUFF_MODE="true"` in `bun build`, following the same pattern as `CODEBUFF_IS_BINARY` and `CODEBUFF_CLI_VERSION`. + +### Runtime Constant + +Create a shared constant in `cli/src/utils/constants.ts`: + +```ts +export const IS_FREEBUFF = process.env.FREEBUFF_MODE === 'true' +``` + +This enables dead-code elimination in production builds — all `if (!IS_FREEBUFF)` branches are removed by the bundler. + +--- + +## 2. Branding Changes + +| Area | Codebuff | Freebuff | +| --------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- | +| Terminal title prefix | `Codebuff: ` | `Freebuff: ` | +| CLI commander name | `codebuff` | `freebuff` | +| npm package name | `codebuff` | `freebuff` | +| Binary name | `codebuff` | `freebuff` | +| App header text | "Codebuff will run commands on your behalf to help you build." | "Freebuff will run commands on your behalf to help you build." | +| ASCII logo | `CODEBUFF` block letters | `FREEBUFF` block letters (new logo) | +| Description | "AI coding agent" | "Free AI coding assistant" | +| Homepage | codebuff.com | codebuff.com/free (or same) | +| `WEBSITE_URL` usage | Points to codebuff.com | Same (login, feedback, etc. stay on codebuff.com) | + +### Files to modify (conditional on `IS_FREEBUFF`) + +- **`cli/src/utils/terminal-title.ts`** — Change `TITLE_PREFIX` from `'Codebuff: '` to `'Freebuff: '` when `IS_FREEBUFF`. +- **`cli/src/login/constants.ts`** — Add a `LOGO_FREEBUFF` ASCII art variant, select based on `IS_FREEBUFF`. +- **`cli/src/app.tsx`** — Conditional header text ("Freebuff will run commands..."). +- **`cli/src/index.tsx`** — Change commander `.name('freebuff')` and `.description(...)` when `IS_FREEBUFF`. + +--- + +## 3. Mode Restrictions + +Freebuff only supports **FREE mode**. All mode-related features are stripped. + +### Behavior + +- `agentMode` is always `'FREE'` and never changes. +- The initial mode flag (`--free`, `--max`, `--plan`) CLI options are removed in Freebuff; mode is hardcoded. +- No mode divider messages are ever inserted into chat history. + +### Files to modify + +- **`cli/src/utils/constants.ts`** — When `IS_FREEBUFF`, export a single-element `AGENT_MODES = ['FREE']` and `AGENT_MODE_TO_ID` with only the FREE entry. Or: the mode toggle component simply never renders. +- **`cli/src/components/agent-mode-toggle.tsx`** — Return `null` when `IS_FREEBUFF` (hide entirely). +- **`cli/src/components/build-mode-buttons.tsx`** — Return `null` when `IS_FREEBUFF` (hides mode-switching buttons in message UI). +- **`cli/src/components/mode-divider.tsx`** — Return `null` when `IS_FREEBUFF` (no mode transition markers). +- **`cli/src/utils/input-modes.ts`** — Set `showAgentModeToggle: false` for all input mode configs when `IS_FREEBUFF`. +- **`cli/src/index.tsx`** — Remove `--free`, `--max`, `--plan`, `--lite` CLI flags when `IS_FREEBUFF`; hardcode `initialMode = 'FREE'`. +- **`cli/src/state/chat-store.ts`** — Default `agentMode` to `'FREE'`; make `setAgentMode` a no-op when `IS_FREEBUFF`. + +--- + +## 4. Slash Commands + +### Commands to REMOVE in Freebuff + +| Command | Reason | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `/subscribe` (+ `/strong`, `/sub`, `/buy-credits`) | No subscription model | +| `/usage` (+ `/credits`) | No credits display | +| `/ads:enable` | Ads always on, not toggleable | +| `/ads:disable` | Ads always on, not toggleable | +| `/connect:claude` (+ `/claude`) | Claude subscription not available | +| `/refer-friends` (+ `/referral`, `/redeem`) | Referrals earn credits, not applicable | +| `/mode:*` (all mode commands) | Only FREE mode | +| `/agent:gpt-5` | Premium agent, not available in free tier | +| `/review` | Uses thinker-gpt under the hood | +| `/publish` | Agent publishing not available in free tier | +| `/image` (+ `/img`, `/attach`) | Image attachments unavailable with free models (Kimi K2.6, DeepSeek V4 Pro, DeepSeek V4 Flash) | + +### Commands to KEEP + +| Command | Notes | +| ----------------------------------------- | ------------------------------ | +| `/help` | Modified help content (see §6) | +| `/new` (+ `/clear`, `/reset`, `/n`, `/c`) | Clear conversation | +| `/history` (+ `/chats`) | Browse past conversations | +| `/feedback` (+ `/bug`, `/report`) | Share feedback | +| `/bash` (+ `/!`) | Bash mode | +| `/theme:toggle` | Light/dark toggle | +| `/logout` (+ `/signout`) | Sign out | +| `/exit` (+ `/quit`, `/q`) | Quit | +| `/login` (+ `/signin`) | Already-logged-in message | +| Skill commands (`/skill:*`) | Keep if skills are loaded | + +### Implementation + +- **`cli/src/data/slash-commands.ts`** — Filter `SLASH_COMMANDS` based on `IS_FREEBUFF`. Remove mode commands, subscription commands, credits commands, ads commands, referral, review, publish, and gpt-5 agent commands. +- **`cli/src/commands/command-registry.ts`** — Filter `COMMAND_REGISTRY` similarly. Wrap removed commands in `!IS_FREEBUFF` guards. + +--- + +## 5. Credits & Subscription UI + +Freebuff never displays credits, usage, subscription info, or out-of-credits states. + +### Components to suppress (render `null` when `IS_FREEBUFF`) + +| Component | File | Behavior | +| -------------------------- | ------------------------------------------ | ------------------------------------------------------------------------ | +| `UsageBanner` | `components/usage-banner.tsx` | Never rendered | +| `OutOfCreditsBanner` | `components/out-of-credits-banner.tsx` | Never rendered | +| `SubscriptionLimitBanner` | `components/subscription-limit-banner.tsx` | Never rendered | +| `BottomStatusLine` | `components/bottom-status-line.tsx` | Never rendered (Claude subscription status) | +| Credits in `MessageFooter` | `components/message-footer.tsx` | Remove `CreditsOrSubscriptionIndicator` — no credits or "✓ Strong" shown | +| `ClaudeConnectBanner` | `components/claude-connect-banner.tsx` | Never rendered | + +### Input modes to disable + +When `IS_FREEBUFF`, these input modes should be unreachable: + +- `outOfCredits` — never triggered +- `subscriptionLimit` — never triggered +- `usage` — no `/usage` command +- `connect:claude` — no `/connect:claude` command +- `referral` — no `/refer-friends` command + +### Hooks to disable/skip + +- **`use-usage-monitor.ts`** — Return early when `IS_FREEBUFF` (no credits to monitor). +- **`use-subscription-query.ts`** — Return empty/disabled when `IS_FREEBUFF`. +- **`use-claude-quota-query.ts`** — Return empty/disabled when `IS_FREEBUFF`. +- **`use-usage-query.ts`** — Still needed for server-side billing, but UI never shows it. + +### Session credits tracking + +- `sessionCreditsUsed` in `chat-store.ts` still accumulates (server tracks usage), but the UI never displays it. +- The `chat.tsx` ad banner continues to pass `isFreeMode={true}` (hardcoded). + +--- + +## 6. Help Menu + +The `/help` banner in Freebuff should be simplified. Remove the **Credits** section entirely. + +### Freebuff Help Content + +``` +Shortcuts + Ctrl+C / Esc stop + Ctrl+J / Opt+Enter newline + ↑↓ history + Ctrl+T collapse/expand agents + +Features + / commands + @files mention + @agents use agent + !bash run command +``` + +No "Credits" section. No `/subscribe`, `/usage`, or `/ads:enable` references. + +### File to modify + +- **`cli/src/components/help-banner.tsx`** — Conditionally hide the Credits section when `IS_FREEBUFF`. + +--- + +## 7. Ads Behavior + +In Freebuff, ads are **always enabled** and **cannot be disabled**. + +- The ad banner always renders (when an ad is available). +- The "Hide ads" link in the info panel is replaced with "Ads are required in Free mode." (this already exists in `ad-banner.tsx` when `isFreeMode` is true). +- The `/ads:enable` and `/ads:disable` commands are removed (see §4). +- `getAdsEnabled()` always returns `true` when `IS_FREEBUFF`. + +### Files to modify + +- **`cli/src/commands/ads.ts`** — `getAdsEnabled()` returns `true` unconditionally when `IS_FREEBUFF`. +- **`cli/src/chat.tsx`** — Skip the `!hasSubscription` guard for ads when `IS_FREEBUFF`; always show. + +--- + +## 8. Build & Release + +### Directory Structure + +The `freebuff/` directory is organized as a product-level directory with subdirectories for each surface (CLI, web, etc.): + +``` +freebuff/ +├── SPEC.md # This file (product-level spec) +├── README.md # Product-level documentation +├── cli/ # CLI build & release infrastructure +│ ├── build.ts # Build script that sets FREEBUFF_MODE=true +│ └── release/ +│ ├── package.json # npm package metadata (name: "freebuff") +│ ├── index.js # Entry point (finds/runs binary) +│ ├── postinstall.js# Downloads platform binary on install +│ └── README.md # npm package README +└── web/ # (Future) Freebuff website code +``` + +This structure allows `freebuff/web/` (or other surfaces) to be added alongside the CLI without restructuring. + +### Build Script (`freebuff/cli/build.ts`) + +Wraps `cli/scripts/build-binary.ts` with: + +```bash +FREEBUFF_MODE=true bun cli/scripts/build-binary.ts freebuff +``` + +The existing `build-binary.ts` already supports a custom binary name argument and passes `NEXT_PUBLIC_*` env vars. We add `FREEBUFF_MODE` to the `defineFlags` array in `build-binary.ts`. + +### Release Package (`freebuff/cli/release/package.json`) + +Mirrors `cli/release/package.json` but with: + +- `"name": "freebuff"` +- `"description": "Free AI coding assistant"` +- `"bin": { "freebuff": "index.js" }` +- Same `postinstall.js` pattern (downloads platform-specific binary from GitHub releases) +- Binary stored at `~/.config/manicode/freebuff` (or `freebuff.exe` on Windows) + +### GitHub Workflow + +New file: `.github/workflows/freebuff-release.yml` + +Mirrors `cli-release-prod.yml` with these changes: + +- **Trigger**: `workflow_dispatch` (manual) or scheduled +- **Binary name**: `freebuff` +- **Version source**: `freebuff/cli/release/package.json` +- **Git tags**: `freebuff-v` +- **npm publish**: `freebuff` package +- **Environment overrides**: `{"FREEBUFF_MODE": "true", "NEXT_PUBLIC_CB_ENVIRONMENT": "prod"}` +- **GitHub Release**: Creates releases in `CodebuffAI/codebuff-community` (or a separate repo) + +--- + +## 9. Changes to `cli/scripts/build-binary.ts` + +Add `FREEBUFF_MODE` to the define flags so it's available at compile time: + +```ts +const defineFlags = [ + ['process.env.NODE_ENV', '"production"'], + ['process.env.CODEBUFF_IS_BINARY', '"true"'], + ['process.env.CODEBUFF_CLI_VERSION', `"${version}"`], + [ + 'process.env.CODEBUFF_CLI_TARGET', + `"${targetInfo.platform}-${targetInfo.arch}"`, + ], + // Freebuff mode flag + ['process.env.FREEBUFF_MODE', `"${process.env.FREEBUFF_MODE ?? 'false'}"`], + ...nextPublicEnvVars, +] +``` + +--- + +## 10. Features That Stay Unchanged + +These features work identically in Freebuff: + +- **Authentication** — Login/logout flow, API key storage +- **Chat** — Message history, streaming, agent spawning +- **File mentions** (`@files`) — Browse and attach files +- **Agent mentions** (`@agents`) — Use available agents (free-tier agents only) +- **Bash mode** — Run terminal commands +- **Image attachments** — Attach and paste images +- **Knowledge files** — `knowledge.md` +- **Chat history** — `/history`, resume conversations +- **Feedback** — `/feedback` command +- **Theme** — Light/dark toggle +- **Skills** — Loaded from `.agents/skills` +- **Local agents** — Loaded from `.agents/` directory + +--- + +## 11. Analytics + +When `IS_FREEBUFF`: + +- `APP_LAUNCHED` event includes `isFreebuff: true` +- All existing analytics events continue to fire (helps understand free vs paid usage) +- No new analytics events needed initially + +--- + +## 12. Server-Side Considerations + +The server already handles FREE mode correctly: + +- `isFreeMode(costMode)` in `common/src/constants/free-agents.ts` recognizes the `'free'` cost mode +- `AGENT_MODE_TO_COST_MODE.FREE === 'free'` is already set +- Free-mode-allowed agent+model combos cost 0 credits +- Ad impressions in FREE mode already don't grant credits + +No server-side changes are needed for Freebuff, **except** the release download API (`/api/releases/download/`) must be configured to serve `freebuff-*` binary tarballs. This may require updating the download route to recognize Freebuff release tags (`freebuff-v*`). + +--- + +## 13. Testing Strategy + +### Unit Tests + +- Test that `IS_FREEBUFF` guards correctly hide/show components +- Test filtered slash commands list +- Test filtered command registry +- Test help banner content + +### Integration Tests + +- Build a Freebuff binary and verify: + - Title says "Freebuff" + - No mode toggle visible + - `/subscribe`, `/usage` commands not found + - Help menu has no Credits section + - Ads always show + +### E2E (tmux) + +- Use `codebuff-local-cli` agent with `FREEBUFF_MODE=true` to verify visual output + +--- + +## 14. Implementation Phases + +### Phase 1: Core Flag & Branding + +1. Add `IS_FREEBUFF` constant +2. Update `build-binary.ts` to pass through `FREEBUFF_MODE` +3. Conditional branding (title, logo, app header, CLI name) + +### Phase 2: Feature Stripping + +4. Filter slash commands and command registry +5. Hide agent mode toggle +6. Suppress credits/subscription UI components +7. Disable usage monitor hook +8. Simplify help banner + +### Phase 3: Ads & Cleanup + +9. Always-on ads behavior +10. Disable unreachable input modes +11. Hide `BuildModeButtons` and `ModeDivider` components + +### Phase 4: Build & Release Infrastructure + +11. Create `freebuff/cli/release/` package files +12. Create `freebuff/cli/build.ts` script +13. Create `.github/workflows/freebuff-release.yml` + +### Phase 5: Testing + +14. Add unit tests for IS_FREEBUFF guards +15. Add integration/E2E tests +16. Manual QA of built binary diff --git a/freebuff/cli/build.ts b/freebuff/cli/build.ts new file mode 100644 index 0000000000..b56a68e9b6 --- /dev/null +++ b/freebuff/cli/build.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env bun + +/** + * Freebuff CLI build script. + * + * Wraps the existing CLI build-binary.ts with FREEBUFF_MODE=true + * to produce a free-only variant of the Codebuff CLI. + * + * Usage: + * bun freebuff/cli/build.ts + * + * Example: + * bun freebuff/cli/build.ts 1.0.0 + */ + +import { spawnSync } from 'child_process' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(__dirname, '..', '..') + +const version = process.argv[2] +if (!version) { + console.error('Usage: bun freebuff/cli/build.ts ') + process.exit(1) +} + +console.log(`Building Freebuff v${version}...`) + +const result = spawnSync( + 'bun', + ['cli/scripts/build-binary.ts', 'freebuff', version], + { + cwd: repoRoot, + stdio: 'inherit', + env: { + ...process.env, + FREEBUFF_MODE: 'true', + }, + }, +) + +if (result.status !== 0) { + console.error('Freebuff build failed') + process.exit(result.status ?? 1) +} + +console.log(`✅ Freebuff v${version} built successfully`) diff --git a/freebuff/cli/release.ts b/freebuff/cli/release.ts new file mode 100644 index 0000000000..e3e92ef673 --- /dev/null +++ b/freebuff/cli/release.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env bun + +/** + * Freebuff CLI release script. + * + * Triggers the freebuff-release.yml GitHub Actions workflow + * to build, publish, and release the Freebuff CLI to npm. + * + * Usage: + * bun freebuff/cli/release.ts [patch|minor|major] [--ref ] + * + * Requires: + * CODEBUFF_GITHUB_TOKEN environment variable + */ + +import { execSync } from 'child_process' + +const args = process.argv.slice(2) + +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}`) +} + +function error(message: string): never { + console.error(`❌ ${message}`) + process.exit(1) +} + +function formatTimestamp() { + const now = new Date() + const options = { + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short', + } as const + return now.toLocaleDateString('en-US', options) +} + +function checkGitHubToken() { + const token = process.env.CODEBUFF_GITHUB_TOKEN + if (!token) { + error( + 'CODEBUFF_GITHUB_TOKEN environment variable is required but not set.\n' + + 'Please set it with your GitHub personal access token or use the infisical setup.', + ) + } + + process.env.GITHUB_TOKEN = token + return token +} + +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 '${payload}'` + + const response = execSync(triggerCmd, { encoding: 'utf8' }) + + if (response.includes('workflow_dispatch')) { + log(`⚠️ Workflow dispatch failed: ${response}`) + log( + 'Please manually trigger the workflow at: https://github.com/CodebuffAI/codebuff/actions/workflows/freebuff-release.yml', + ) + } else { + log('🎉 Freebuff release workflow triggered!') + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + log(`⚠️ Failed to trigger workflow automatically: ${message}`) + log( + 'You may need to trigger it manually at: https://github.com/CodebuffAI/codebuff/actions/workflows/freebuff-release.yml', + ) + } +} + +async function main() { + log('🚀 Initiating Freebuff release...') + log(`Date: ${formatTimestamp()}`) + + checkGitHubToken() + log('✅ Using local CODEBUFF_GITHUB_TOKEN') + + log(`Version bump type: ${versionType}`) + if (checkoutRef) { + log(`Building from ref: ${checkoutRef}`) + } + + await triggerWorkflow(versionType, checkoutRef) + + log('') + log( + 'Monitor progress at: https://github.com/CodebuffAI/codebuff/actions/workflows/freebuff-release.yml', + ) +} + +main().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + error(`Release failed: ${message}`) +}) diff --git a/freebuff/cli/release/README.md b/freebuff/cli/release/README.md new file mode 100644 index 0000000000..49e7a2c82e --- /dev/null +++ b/freebuff/cli/release/README.md @@ -0,0 +1,42 @@ +# Freebuff + +**The free coding agent.** No subscription. No configuration. Start in seconds. + +An AI coding agent that runs in your terminal — describe what you want, and Freebuff edits your code. + +## Install + +```bash +npm install -g freebuff +``` + +## Usage + +```bash +cd ~/my-project +freebuff +``` + +## Why Freebuff? + +**Simple** — No modes. No config. Just works. + +**Fast** — 5–10× speed up. 3–5× tokens per second compared to Claude, plus context gathering in seconds. + +**Loaded** — Built-in web research, browser use, and more. + +**Connect ChatGPT** — Link your ChatGPT subscription for planning and review. + +## FAQ + +**How can it be free?** Freebuff is supported by ads shown in the CLI. + +**Are you training on my data?** No. We only use model providers that do not train on our requests. Your code stays yours. + +## Links + +- [Documentation](https://codebuff.com/docs) +- [GitHub](https://github.com/CodebuffAI/codebuff) +- [Website](https://codebuff.com) + +> Built on the [Codebuff](https://codebuff.com) platform. diff --git a/freebuff/cli/release/http.js b/freebuff/cli/release/http.js new file mode 100644 index 0000000000..3419e80ca3 --- /dev/null +++ b/freebuff/cli/release/http.js @@ -0,0 +1,176 @@ +const http = require('http') +const https = require('https') +const tls = require('tls') + +function createReleaseHttpClient({ + env = process.env, + userAgent, + requestTimeout, + httpModule = http, + httpsModule = https, + tlsModule = tls, +}) { + function getProxyUrl() { + return ( + env.HTTPS_PROXY || + env.https_proxy || + env.HTTP_PROXY || + env.http_proxy || + null + ) + } + + function shouldBypassProxy(hostname) { + const noProxy = env.NO_PROXY || env.no_proxy || '' + if (!noProxy) return false + + const domains = noProxy + .split(',') + .map((domain) => domain.trim().toLowerCase().replace(/:\d+$/, '')) + const host = hostname.toLowerCase() + + return domains.some((domain) => { + if (domain === '*') return true + if (domain.startsWith('.')) { + return host.endsWith(domain) || host === domain.slice(1) + } + return host === domain || host.endsWith(`.${domain}`) + }) + } + + function connectThroughProxy(proxyUrl, targetHost, targetPort) { + return new Promise((resolve, reject) => { + const proxy = new URL(proxyUrl) + const isHttpsProxy = proxy.protocol === 'https:' + const connectOptions = { + hostname: proxy.hostname, + port: proxy.port || (isHttpsProxy ? 443 : 80), + method: 'CONNECT', + path: `${targetHost}:${targetPort}`, + headers: { + Host: `${targetHost}:${targetPort}`, + }, + } + + if (proxy.username || proxy.password) { + const auth = Buffer.from( + `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent( + proxy.password || '', + )}`, + ).toString('base64') + connectOptions.headers['Proxy-Authorization'] = `Basic ${auth}` + } + + const transport = isHttpsProxy ? httpsModule : httpModule + const req = transport.request(connectOptions) + + req.on('connect', (res, socket) => { + if (res.statusCode === 200) { + resolve(socket) + return + } + + socket.destroy() + reject(new Error(`Proxy CONNECT failed with status ${res.statusCode}`)) + }) + + req.on('error', (error) => { + reject(new Error(`Proxy connection failed: ${error.message}`)) + }) + + req.setTimeout(requestTimeout, () => { + req.destroy() + reject(new Error('Proxy connection timeout.')) + }) + + req.end() + }) + } + + async function buildRequestOptions(url, options = {}) { + const parsedUrl = new URL(url) + const reqOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: parsedUrl.pathname + parsedUrl.search, + headers: { + 'User-Agent': userAgent, + ...options.headers, + }, + } + + const proxyUrl = getProxyUrl() + if (!proxyUrl || shouldBypassProxy(parsedUrl.hostname)) { + return reqOptions + } + + const tunnelSocket = await connectThroughProxy( + proxyUrl, + parsedUrl.hostname, + parsedUrl.port || 443, + ) + + class TunnelAgent extends httpsModule.Agent { + createConnection(_options, callback) { + const secureSocket = tlsModule.connect({ + socket: tunnelSocket, + servername: parsedUrl.hostname, + }) + + if (typeof callback === 'function') { + if (typeof secureSocket.once === 'function') { + let settled = false + const finish = (error) => { + if (settled) return + settled = true + callback(error || null, error ? undefined : secureSocket) + } + + secureSocket.once('secureConnect', () => finish(null)) + secureSocket.once('error', (error) => finish(error)) + } else { + callback(null, secureSocket) + } + } + + return secureSocket + } + } + + reqOptions.agent = new TunnelAgent({ keepAlive: false }) + return reqOptions + } + + async function httpGet(url, options = {}) { + const reqOptions = await buildRequestOptions(url, options) + + return new Promise((resolve, reject) => { + const req = httpsModule.get(reqOptions, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + res.resume() + httpGet(new URL(res.headers.location, url).href, options) + .then(resolve) + .catch(reject) + return + } + + resolve(res) + }) + + req.on('error', reject) + req.setTimeout(options.timeout || requestTimeout, () => { + req.destroy() + reject(new Error('Request timeout.')) + }) + }) + } + + return { + getProxyUrl, + httpGet, + } +} + +module.exports = { + createReleaseHttpClient, +} diff --git a/freebuff/cli/release/index.js b/freebuff/cli/release/index.js new file mode 100644 index 0000000000..044d86ebc5 --- /dev/null +++ b/freebuff/cli/release/index.js @@ -0,0 +1,579 @@ +#!/usr/bin/env node + +const { spawn } = require('child_process') +const fs = require('fs') +const http = require('http') +const https = require('https') +const os = require('os') +const path = require('path') +const zlib = require('zlib') + +const tar = require('tar') +const { createReleaseHttpClient } = require('./http') + +const packageName = 'freebuff' + +/** + * Terminal escape sequences to reset terminal state after the child process exits. + * When the binary is SIGKILL'd, it can't clean up its own terminal state. + * The wrapper (this process) survives and must reset these modes. + * + * Keep in sync with TERMINAL_RESET_SEQUENCES in cli/src/utils/renderer-cleanup.ts + */ +const TERMINAL_RESET_SEQUENCES = + '\x1b[?1049l' + // Exit alternate screen buffer + '\x1b[?1000l' + // Disable X10 mouse mode + '\x1b[?1002l' + // Disable button event mouse mode + '\x1b[?1003l' + // Disable any-event mouse mode (all motion) + '\x1b[?1006l' + // Disable SGR extended mouse mode + '\x1b[?1004l' + // Disable focus reporting + '\x1b[?2004l' + // Disable bracketed paste mode + '\x1b[?25h' // Show cursor + +function resetTerminal() { + try { + if (process.stdin.isTTY && process.stdin.setRawMode) { + process.stdin.setRawMode(false) + } + } catch { + // stdin may be closed + } + try { + if (process.stdout.isTTY) { + process.stdout.write(TERMINAL_RESET_SEQUENCES) + } + } catch { + // stdout may be closed + } +} + +function createConfig(packageName) { + const homeDir = os.homedir() + const configDir = path.join(homeDir, '.config', 'manicode') + const binaryName = + process.platform === 'win32' ? `${packageName}.exe` : packageName + + return { + homeDir, + configDir, + binaryName, + binaryPath: path.join(configDir, binaryName), + metadataPath: path.join(configDir, 'freebuff-metadata.json'), + tempDownloadDir: path.join(configDir, '.freebuff-download-temp'), + userAgent: `${packageName}-cli`, + requestTimeout: 20000, + } +} + +const CONFIG = createConfig(packageName) +const { getProxyUrl, httpGet } = createReleaseHttpClient({ + env: process.env, + userAgent: CONFIG.userAgent, + requestTimeout: CONFIG.requestTimeout, +}) + +function getPostHogConfig() { + const apiKey = + process.env.CODEBUFF_POSTHOG_API_KEY || + process.env.NEXT_PUBLIC_POSTHOG_API_KEY + const host = + process.env.CODEBUFF_POSTHOG_HOST || + process.env.NEXT_PUBLIC_POSTHOG_HOST_URL + + if (!apiKey || !host) { + return null + } + + return { apiKey, host } +} + +/** + * Track update failure event to PostHog. + * Fire-and-forget - errors are silently ignored. + */ +function trackUpdateFailed(errorMessage, version, context = {}) { + try { + const posthogConfig = getPostHogConfig() + if (!posthogConfig) { + return + } + + const payload = JSON.stringify({ + api_key: posthogConfig.apiKey, + event: 'cli.update_freebuff_failed', + properties: { + distinct_id: `anonymous-${CONFIG.homeDir}`, + error: errorMessage, + version: version || 'unknown', + platform: process.platform, + arch: process.arch, + ...context, + }, + timestamp: new Date().toISOString(), + }) + + const parsedUrl = new URL(`${posthogConfig.host}/capture/`) + const isHttps = parsedUrl.protocol === 'https:' + const options = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (isHttps ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + } + + const transport = isHttps ? https : http + const req = transport.request(options) + req.on('error', () => {}) + req.write(payload) + req.end() + } catch (e) { + // Silently ignore any tracking errors + } +} + +const PLATFORM_TARGETS = { + 'linux-x64': `${packageName}-linux-x64.tar.gz`, + 'linux-arm64': `${packageName}-linux-arm64.tar.gz`, + 'darwin-x64': `${packageName}-darwin-x64.tar.gz`, + 'darwin-arm64': `${packageName}-darwin-arm64.tar.gz`, + 'win32-x64': `${packageName}-win32-x64.tar.gz`, +} + +const term = { + clearLine: () => { + if (process.stderr.isTTY) { + process.stderr.write('\r\x1b[K') + } + }, + write: (text) => { + term.clearLine() + process.stderr.write(text) + }, + writeLine: (text) => { + term.clearLine() + process.stderr.write(text + '\n') + }, +} + +async function getLatestVersion() { + try { + const res = await httpGet( + `https://registry.npmjs.org/${packageName}/latest`, + ) + + if (res.statusCode !== 200) return null + + const body = await streamToString(res) + const packageData = JSON.parse(body) + + return packageData.version || null + } catch (error) { + return null + } +} + +function streamToString(stream) { + return new Promise((resolve, reject) => { + let data = '' + stream.on('data', (chunk) => (data += chunk)) + stream.on('end', () => resolve(data)) + stream.on('error', reject) + }) +} + +function getCurrentVersion() { + try { + if (!fs.existsSync(CONFIG.metadataPath)) { + return null + } + const metadata = JSON.parse(fs.readFileSync(CONFIG.metadataPath, 'utf8')) + if (!fs.existsSync(CONFIG.binaryPath)) { + return null + } + return metadata.version || null + } catch (error) { + return null + } +} + +function compareVersions(v1, v2) { + if (!v1 || !v2) return 0 + + if (!v1.match(/^\d+(\.\d+)*$/)) { + return -1 + } + + const parseVersion = (version) => { + const parts = version.split('-') + const mainParts = parts[0].split('.').map(Number) + const prereleaseParts = parts[1] ? parts[1].split('.') : [] + return { main: mainParts, prerelease: prereleaseParts } + } + + const p1 = parseVersion(v1) + const p2 = parseVersion(v2) + + for (let i = 0; i < Math.max(p1.main.length, p2.main.length); i++) { + const n1 = p1.main[i] || 0 + const n2 = p2.main[i] || 0 + + if (n1 < n2) return -1 + if (n1 > n2) return 1 + } + + if (p1.prerelease.length === 0 && p2.prerelease.length === 0) { + return 0 + } else if (p1.prerelease.length === 0) { + return 1 + } else if (p2.prerelease.length === 0) { + return -1 + } else { + for ( + let i = 0; + i < Math.max(p1.prerelease.length, p2.prerelease.length); + i++ + ) { + const pr1 = p1.prerelease[i] || '' + const pr2 = p2.prerelease[i] || '' + + const isNum1 = !isNaN(parseInt(pr1)) + const isNum2 = !isNaN(parseInt(pr2)) + + if (isNum1 && isNum2) { + const num1 = parseInt(pr1) + const num2 = parseInt(pr2) + if (num1 < num2) return -1 + if (num1 > num2) return 1 + } else if (isNum1 && !isNum2) { + return 1 + } else if (!isNum1 && isNum2) { + return -1 + } else if (pr1 < pr2) { + return -1 + } else if (pr1 > pr2) { + return 1 + } + } + return 0 + } +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +function createProgressBar(percentage, width = 30) { + const filled = Math.round((width * percentage) / 100) + const empty = width - filled + return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']' +} + +async function downloadBinary(version) { + const platformKey = `${process.platform}-${process.arch}` + const fileName = PLATFORM_TARGETS[platformKey] + + if (!fileName) { + const error = new Error(`Unsupported platform: ${process.platform} ${process.arch}`) + trackUpdateFailed(error.message, version, { stage: 'platform_check' }) + throw error + } + + const downloadUrl = `${ + process.env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'https://codebuff.com' + }/api/releases/download/${version}/${fileName}` + + fs.mkdirSync(CONFIG.configDir, { recursive: true }) + + if (fs.existsSync(CONFIG.tempDownloadDir)) { + fs.rmSync(CONFIG.tempDownloadDir, { recursive: true }) + } + fs.mkdirSync(CONFIG.tempDownloadDir, { recursive: true }) + + term.write('Downloading...') + + const res = await httpGet(downloadUrl) + + if (res.statusCode !== 200) { + fs.rmSync(CONFIG.tempDownloadDir, { recursive: true }) + const error = new Error(`Download failed: HTTP ${res.statusCode}`) + trackUpdateFailed(error.message, version, { stage: 'http_download', statusCode: res.statusCode }) + throw error + } + + const totalSize = parseInt(res.headers['content-length'] || '0', 10) + let downloadedSize = 0 + let lastProgressTime = Date.now() + + res.on('data', (chunk) => { + downloadedSize += chunk.length + const now = Date.now() + if (now - lastProgressTime >= 100 || downloadedSize === totalSize) { + lastProgressTime = now + if (totalSize > 0) { + const pct = Math.round((downloadedSize / totalSize) * 100) + term.write( + `Downloading... ${createProgressBar(pct)} ${pct}% of ${formatBytes( + totalSize, + )}`, + ) + } else { + term.write(`Downloading... ${formatBytes(downloadedSize)}`) + } + } + }) + + await new Promise((resolve, reject) => { + res + .pipe(zlib.createGunzip()) + .pipe(tar.x({ cwd: CONFIG.tempDownloadDir })) + .on('finish', resolve) + .on('error', reject) + }) + + const tempBinaryPath = path.join(CONFIG.tempDownloadDir, CONFIG.binaryName) + + if (!fs.existsSync(tempBinaryPath)) { + const files = fs.readdirSync(CONFIG.tempDownloadDir) + fs.rmSync(CONFIG.tempDownloadDir, { recursive: true }) + const error = new Error( + `Binary not found after extraction. Expected: ${CONFIG.binaryName}, Available files: ${files.join(', ')}`, + ) + trackUpdateFailed(error.message, version, { stage: 'extraction' }) + throw error + } + + if (process.platform !== 'win32') { + fs.chmodSync(tempBinaryPath, 0o755) + } + + try { + if (fs.existsSync(CONFIG.binaryPath)) { + try { + fs.unlinkSync(CONFIG.binaryPath) + } catch (err) { + const backupPath = CONFIG.binaryPath + `.old.${Date.now()}` + try { + fs.renameSync(CONFIG.binaryPath, backupPath) + } catch (renameErr) { + throw new Error( + `Failed to replace existing binary. ` + + `unlink error: ${err.code || err.message}, ` + + `rename error: ${renameErr.code || renameErr.message}`, + ) + } + } + } + fs.renameSync(tempBinaryPath, CONFIG.binaryPath) + + // Move tree-sitter.wasm next to the binary if the tarball included + // it. The CLI binary loads this at startup; embedding it inside the + // binary itself was unreliable on Windows (bun --compile asset + // bundling silently dropped or unbound it across several attempts), + // so we ship it as a sibling file instead. Older artifacts that + // pre-date this change won't have the wasm and will still install — + // they'll just hit the same crash they had before, which is fine. + const tempWasmPath = path.join(CONFIG.tempDownloadDir, 'tree-sitter.wasm') + if (fs.existsSync(tempWasmPath)) { + const targetWasmPath = path.join( + path.dirname(CONFIG.binaryPath), + 'tree-sitter.wasm', + ) + try { + if (fs.existsSync(targetWasmPath)) fs.unlinkSync(targetWasmPath) + } catch { + // best effort; rename below will surface the real error if it matters + } + fs.renameSync(tempWasmPath, targetWasmPath) + } + + fs.writeFileSync( + CONFIG.metadataPath, + JSON.stringify({ version }, null, 2), + ) + } finally { + if (fs.existsSync(CONFIG.tempDownloadDir)) { + fs.rmSync(CONFIG.tempDownloadDir, { recursive: true }) + } + } + + term.clearLine() + console.log('Download complete! Starting Freebuff...') +} + +async function ensureBinaryExists() { + const currentVersion = getCurrentVersion() + if (currentVersion !== null) { + return + } + + const version = await getLatestVersion() + if (!version) { + console.error('❌ Failed to determine latest version') + console.error('Please check your internet connection and try again') + if (!getProxyUrl()) { + console.error( + 'If you are behind a proxy, set the HTTPS_PROXY environment variable', + ) + } + process.exit(1) + } + + try { + await downloadBinary(version) + } catch (error) { + term.clearLine() + console.error('❌ Failed to download freebuff:', error.message) + console.error('Please check your internet connection and try again') + if (!getProxyUrl()) { + console.error( + 'If you are behind a proxy, set the HTTPS_PROXY environment variable', + ) + } + process.exit(1) + } +} + +async function checkForUpdates(runningProcess, exitListener) { + try { + const currentVersion = getCurrentVersion() + + const latestVersion = await getLatestVersion() + if (!latestVersion) return + + if ( + currentVersion === null || + compareVersions(currentVersion, latestVersion) < 0 + ) { + term.clearLine() + + runningProcess.removeListener('exit', exitListener) + + await new Promise((resolve) => { + let exited = false + runningProcess.once('exit', () => { + exited = true + resolve() + }) + runningProcess.kill('SIGTERM') + setTimeout(() => { + if (!exited) { + runningProcess.kill('SIGKILL') + // Safety: resolve after giving SIGKILL time to take effect + setTimeout(() => resolve(), 1000) + } + }, 5000) + }) + + resetTerminal() + console.log(`Update available: ${currentVersion} → ${latestVersion}`) + + await downloadBinary(latestVersion) + + const newChild = spawn(CONFIG.binaryPath, process.argv.slice(2), { + stdio: 'inherit', + detached: false, + }) + + newChild.on('exit', (code, signal) => { + resetTerminal() + printCrashDiagnostics(code, signal) + process.exit(signal ? 1 : (code || 0)) + }) + + newChild.on('error', (err) => { + console.error('Failed to start freebuff:', err.message) + process.exit(1) + }) + + return new Promise(() => {}) + } + } catch (error) { + // Ignore update failures + } +} + +function printCrashDiagnostics(code, signal) { + // Windows NTSTATUS codes (unsigned DWORD) + const unsignedCode = code != null && code < 0 ? (code >>> 0) : code + const isIllegalInstruction = + signal === 'SIGILL' || + (process.platform === 'win32' && unsignedCode === 0xC000001D) + const isAccessViolation = + signal === 'SIGSEGV' || + (process.platform === 'win32' && unsignedCode === 0xC0000005) + const isBusError = signal === 'SIGBUS' + const isAbort = + signal === 'SIGABRT' || + (process.platform === 'win32' && unsignedCode === 0xC0000409) + + if (!isIllegalInstruction && !isAccessViolation && !isBusError && !isAbort) return + + const exitInfo = signal ? `signal ${signal}` : `code ${code}` + console.error('') + console.error(`❌ ${packageName} exited immediately (${exitInfo})`) + console.error('') + + if (isIllegalInstruction) { + console.error('Your CPU may not support the required instruction set (AVX2).') + console.error('This typically affects CPUs from before 2013.') + console.error('Unfortunately, this binary is not compatible with your system.') + console.error('') + } else if (isAccessViolation) { + console.error('The binary crashed with an access violation.') + console.error('') + } else if (isBusError) { + console.error('The binary crashed with a bus error.') + console.error('This may indicate a platform compatibility issue.') + console.error('') + } else if (isAbort) { + console.error('The binary crashed with an abort signal.') + console.error('') + } + + console.error('System info:') + console.error(` Platform: ${process.platform} ${process.arch}`) + console.error(` Node: ${process.version}`) + console.error(` Binary: ${CONFIG.binaryPath}`) + console.error('') + console.error('Please report this issue at:') + console.error(' https://github.com/CodebuffAI/codebuff/issues') + console.error('') +} + +async function main() { + await ensureBinaryExists() + + const child = spawn(CONFIG.binaryPath, process.argv.slice(2), { + stdio: 'inherit', + }) + + const exitListener = (code, signal) => { + resetTerminal() + printCrashDiagnostics(code, signal) + process.exit(signal ? 1 : (code || 0)) + } + + child.on('exit', exitListener) + + child.on('error', (err) => { + console.error('Failed to start freebuff:', err.message) + process.exit(1) + }) + + setTimeout(() => { + checkForUpdates(child, exitListener) + }, 100) +} + +main().catch((error) => { + console.error('❌ Unexpected error:', error.message) + process.exit(1) +}) diff --git a/freebuff/cli/release/package.json b/freebuff/cli/release/package.json new file mode 100644 index 0000000000..26831a2d68 --- /dev/null +++ b/freebuff/cli/release/package.json @@ -0,0 +1,42 @@ +{ + "name": "freebuff", + "version": "0.0.95", + "description": "The world's strongest free coding agent", + "license": "MIT", + "bin": { + "freebuff": "index.js" + }, + "scripts": { + "postinstall": "node postinstall.js", + "preuninstall": "node -e \"const fs = require('fs'); const path = require('path'); const os = require('os'); const binaryPath = path.join(os.homedir(), '.config', 'manicode', process.platform === 'win32' ? 'freebuff.exe' : 'freebuff'); try { fs.unlinkSync(binaryPath) } catch (e) { /* ignore if file doesn't exist */ }\"" + }, + "files": [ + "index.js", + "http.js", + "postinstall.js", + "README.md" + ], + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ], + "engines": { + "node": ">=16" + }, + "dependencies": { + "tar": "^7.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/CodebuffAI/codebuff.git" + }, + "homepage": "https://codebuff.com", + "publishConfig": { + "access": "public" + } +} diff --git a/freebuff/cli/release/postinstall.js b/freebuff/cli/release/postinstall.js new file mode 100644 index 0000000000..3bc21de1df --- /dev/null +++ b/freebuff/cli/release/postinstall.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Clean up old binary to force fresh download on next launch +const binaryPath = path.join( + os.homedir(), + '.config', + 'manicode', + process.platform === 'win32' ? 'freebuff.exe' : 'freebuff' +); + +try { + fs.unlinkSync(binaryPath); +} catch (e) { + /* ignore if file doesn't exist */ +} + +console.log('\n'); +console.log('⚡ Welcome to Freebuff!'); +console.log('\n'); +console.log('To get started:'); +console.log(' 1. cd to your project directory'); +console.log(' 2. Run: freebuff'); +console.log('\n'); +console.log('Example:'); +console.log(' $ cd ~/my-project'); +console.log(' $ freebuff'); +console.log('\n'); +console.log('For more information, visit: https://codebuff.com/docs'); +console.log('\n'); diff --git a/freebuff/cli/smoke-test.test.ts b/freebuff/cli/smoke-test.test.ts new file mode 100644 index 0000000000..bd225ed77f --- /dev/null +++ b/freebuff/cli/smoke-test.test.ts @@ -0,0 +1,218 @@ +#!/usr/bin/env bun +/** + * Freebuff Binary Smoke Test + * + * Verifies the compiled Freebuff binary: + * 1. Reports a valid version number + * 2. Shows Freebuff branding (not Codebuff) in --help output + * 3. Excludes mode flags (--free, --max, --plan) from --help + * 4. Renders the Freebuff title screen (ASCII logo) in tmux + * + * Prerequisites: + * bun freebuff/cli/build.ts # build the binary + * brew install tmux # for title-screen test + * + * Run: + * bun test freebuff/cli/smoke-test.test.ts + */ + +import { execFileSync, execSync, spawn } from 'child_process' +import { existsSync } from 'fs' +import path from 'path' + +import { describe, test, expect, afterEach } from 'bun:test' + +const REPO_ROOT = path.join(__dirname, '..', '..') +const BINARY_PATH = path.join(REPO_ROOT, 'cli', 'bin', 'freebuff') +const TIMEOUT_MS = 20_000 + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function stripAnsiCodes(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '') +} + +function isTmuxAvailable(): boolean { + if (process.env.CI === 'true' || process.env.CI === '1') return false + try { + execSync( + 'which tmux && tmux new-session -d -s __freebuff_tmux_check__ && tmux kill-session -t __freebuff_tmux_check__', + { stdio: 'pipe', timeout: 5000 }, + ) + return true + } catch { + return false + } +} + +function tmux(args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('tmux', args, { stdio: 'pipe' }) + let stdout = '' + let stderr = '' + proc.stdout?.on('data', (d: Buffer) => { + stdout += d.toString() + }) + proc.stderr?.on('data', (d: Buffer) => { + stderr += d.toString() + }) + proc.on('close', (code) => { + if (code === 0) resolve(stdout) + else reject(new Error(`tmux failed (exit ${code}): ${stderr}`)) + }) + }) +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +function runBinary(args: string[]): string { + return execFileSync(BINARY_PATH, args, { + encoding: 'utf-8', + timeout: 10_000, + env: { ...process.env, NO_COLOR: '1' }, + }) +} + +const binaryExists = existsSync(BINARY_PATH) +const tmuxAvailable = isTmuxAvailable() + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe.skipIf(!binaryExists)('Freebuff Binary Smoke Tests', () => { + test( + '--version outputs a valid semver version', + () => { + const output = stripAnsiCodes(runBinary(['--version'])).trim() + // The binary may print env info before the version; grab the last line + const lastLine = + output + .split('\n') + .filter((l) => l.trim()) + .pop() ?? '' + expect(lastLine.trim()).toMatch(/^\d+\.\d+\.\d+/) + }, + TIMEOUT_MS, + ) + + test( + '--help shows Freebuff branding', + () => { + const output = stripAnsiCodes(runBinary(['--help'])) + + // CLI name is "freebuff" + expect(output).toContain('Usage: freebuff') + // Description is Freebuff-specific + expect(output).toContain('Free AI coding assistant') + // Must NOT contain the Codebuff CLI name in the usage line + expect(output).not.toContain('Usage: codebuff') + }, + TIMEOUT_MS, + ) + + test( + '--help excludes mode flags (Freebuff is free-only)', + () => { + const output = stripAnsiCodes(runBinary(['--help'])) + + // Mode flags should not be present in Freebuff + expect(output).not.toMatch(/--free\b/) + expect(output).not.toMatch(/--max\b/) + expect(output).not.toMatch(/--plan\b/) + expect(output).not.toMatch(/--lite\b/) + }, + TIMEOUT_MS, + ) + + // ------------------------------------------------------------------------- + // tmux title-screen test + // ------------------------------------------------------------------------- + + describe.skipIf(!tmuxAvailable)('tmux title screen', () => { + let sessionName = '' + + afterEach(async () => { + if (sessionName) { + try { + await tmux(['kill-session', '-t', sessionName]) + } catch { + // session may have already exited + } + sessionName = '' + } + }) + + test( + 'displays Freebuff ASCII logo on startup', + async () => { + sessionName = `freebuff-smoke-${Date.now()}` + + // Start the binary in a detached tmux session + await tmux([ + 'new-session', + '-d', + '-s', + sessionName, + '-x', + '120', + '-y', + '35', + BINARY_PATH, + ]) + + // Poll until the title screen renders (ASCII art uses block chars) + let cleanOutput = '' + for (let attempt = 0; attempt < 20; attempt++) { + await sleep(500) + const raw = await tmux(['capture-pane', '-t', sessionName, '-p']) + cleanOutput = stripAnsiCodes(raw) + + // Block characters from the ASCII logo indicate the title screen rendered + if (cleanOutput.includes('██')) break + } + + // Bail with a descriptive error if the title screen never appeared + if (!cleanOutput.includes('██')) { + throw new Error( + `Freebuff title screen did not render within 10s. Captured output:\n${cleanOutput}`, + ) + } + + // Verify it's the FREEBUFF logo, not CODEBUFF. + // The Freebuff 'F' character's third line starts with the crossbar: + // █████╗ ██████╔╝ + // whereas Codebuff 'C' has: + // ██║ ██║ ██║ + // We check for the F + R pattern on line 3 of the logo. + expect(cleanOutput).toContain('█████╗ ██████╔╝') + + // The Codebuff logo's distinctive C+O opening should NOT appear + expect(cleanOutput).not.toContain('██╔════╝██╔═══██╗') + }, + TIMEOUT_MS, + ) + }) +}) + +// Show skip messages so test output is informative +if (!binaryExists) { + describe('Freebuff Binary Required', () => { + test.skip( + 'Build the binary first: bun freebuff/cli/build.ts ', + () => {}, + ) + }) +} + +if (binaryExists && !tmuxAvailable) { + describe('tmux Required for Title Screen Test', () => { + test.skip( + 'Install tmux: brew install tmux (macOS) or apt-get install tmux (Linux)', + () => {}, + ) + }) +} diff --git a/freebuff/e2e/README.md b/freebuff/e2e/README.md new file mode 100644 index 0000000000..861d31f5be --- /dev/null +++ b/freebuff/e2e/README.md @@ -0,0 +1,169 @@ +# Freebuff E2E Tests + +End-to-end tests for the Freebuff CLI binary. Tests verify that the compiled binary works correctly by interacting with it via tmux. + +## Architecture + +Two testing approaches are supported: + +### 1. Direct tmux tests (fast, deterministic) + +Use the `FreebuffSession` class to start the binary in tmux, send commands, capture output, and assert directly. + +```typescript +import { describe, test, expect, afterEach } from 'bun:test' +import { FreebuffSession, requireFreebuffBinary } from '../utils' + +describe('My Feature', () => { + let session: FreebuffSession | null = null + + afterEach(async () => { + if (session) await session.stop() + session = null + }) + + test('works correctly', async () => { + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary) + + await session.send('/help') + const output = await session.capture(2) + + expect(output).toContain('Shortcuts') + }, 60_000) +}) +``` + +### 2. SDK agent-driven tests (AI-powered verification) + +Use the Codebuff SDK to run a testing agent that interacts with Freebuff via custom tmux tools. The agent reasons about the CLI output and verifies complex behaviors. + +```typescript +import { describe, test, expect, afterEach } from 'bun:test' +import { CodebuffClient } from '@codebuff/sdk' +import { freebuffTesterAgent } from '../agent/freebuff-tester' +import { createFreebuffTmuxTools, requireFreebuffBinary } from '../utils' + +describe('Agent Test', () => { + let cleanup: (() => Promise) | null = null + + afterEach(async () => { + if (cleanup) await cleanup() + cleanup = null + }) + + test('verifies startup', async () => { + const apiKey = process.env.CODEBUFF_API_KEY + if (!apiKey) return // Skip if no API key + + const binary = requireFreebuffBinary() + const tmuxTools = createFreebuffTmuxTools(binary) + cleanup = tmuxTools.cleanup + + const client = new CodebuffClient({ apiKey }) + const result = await client.run({ + agent: freebuffTesterAgent.id, + prompt: 'Start Freebuff and verify the branding is correct.', + agentDefinitions: [freebuffTesterAgent], + customToolDefinitions: tmuxTools.tools, + handleEvent: () => {}, + }) + + expect(result.output.type).not.toBe('error') + }, 180_000) +}) +``` + +## Prerequisites + +- **tmux** must be installed: `brew install tmux` (macOS) or `sudo apt-get install tmux` (Ubuntu) +- **Freebuff binary** must be built: `bun freebuff/cli/build.ts 0.0.0-dev` +- **SDK built** (for agent tests): `cd sdk && bun run build` +- **CODEBUFF_API_KEY** (for agent tests only): Set this environment variable + +## Running Tests + +### Build the binary first + +```bash +bun freebuff/cli/build.ts 0.0.0-dev +``` + +### Run all tests + +```bash +bun test freebuff/e2e/tests/ +``` + +### Run a specific test + +```bash +bun test freebuff/e2e/tests/version.e2e.test.ts +bun test freebuff/e2e/tests/startup.e2e.test.ts +bun test freebuff/e2e/tests/help-command.e2e.test.ts +bun test freebuff/e2e/tests/agent-startup.e2e.test.ts +``` + +### Use a custom binary path + +```bash +FREEBUFF_BINARY=/path/to/freebuff bun test freebuff/e2e/tests/ +``` + +## Adding New Tests + +1. Create a new file in `freebuff/e2e/tests/` with the naming convention `.e2e.test.ts` +2. Add the test name to `.github/workflows/freebuff-e2e.yml` matrix: + +```yaml +matrix: + test: + - version + - startup + - help-command + - agent-startup + - your-new-test # <-- add here +``` + +3. The test will automatically run in parallel with other tests in CI. + +## CI Workflow + +The `.github/workflows/freebuff-e2e.yml` workflow: + +1. **Builds** the Freebuff binary once (linux-x64) +2. **Runs each test file in parallel** via GitHub Actions matrix strategy +3. **Uploads tmux session logs** on failure for debugging + +Triggers: +- **Nightly** at 6:00 AM PT +- **Manual** via workflow_dispatch + +## Utilities Reference + +### `FreebuffSession` + +| Method | Description | +|--------|-------------| +| `FreebuffSession.start(binaryPath)` | Start binary in tmux, returns session | +| `session.send(text)` | Send text input (presses Enter) | +| `session.sendKey(key)` | Send special key (e.g. `'C-c'`, `'Escape'`) | +| `session.capture(waitSec?)` | Capture terminal output | +| `session.captureLabeled(label, waitSec?)` | Capture and save to session logs | +| `session.waitForText(pattern, timeoutMs?)` | Poll until text appears | +| `session.stop()` | Stop session and clean up | + +### `createFreebuffTmuxTools(binaryPath)` + +Creates SDK custom tools for agent-driven testing: +- `start_freebuff` - Launch the CLI +- `send_to_freebuff` - Send text input +- `capture_freebuff_output` - Capture terminal output +- `stop_freebuff` - Stop and clean up + +### Helper functions + +| Function | Description | +|----------|-------------| +| `requireFreebuffBinary()` | Get binary path, throws if not found | +| `getFreebuffBinaryPath()` | Get binary path (may not exist) | diff --git a/freebuff/e2e/agent/freebuff-tester.ts b/freebuff/e2e/agent/freebuff-tester.ts new file mode 100644 index 0000000000..e4cf221423 --- /dev/null +++ b/freebuff/e2e/agent/freebuff-tester.ts @@ -0,0 +1,52 @@ +import type { AgentDefinition } from '@codebuff/sdk' + +/** + * Agent definition for testing the Freebuff CLI via tmux. + * + * This agent is designed to be used with the custom tmux tools from + * `createFreebuffTmuxTools()`. It receives a testing task in its prompt + * and uses tmux tools to start Freebuff, interact with it, and verify behavior. + * + * Example usage: + * ```ts + * const { tools, cleanup } = createFreebuffTmuxTools(binaryPath) + * const result = await client.run({ + * agent: freebuffTesterAgent.id, + * prompt: 'Start freebuff and verify the welcome screen shows Freebuff branding', + * agentDefinitions: [freebuffTesterAgent], + * customToolDefinitions: tools, + * handleEvent: collector.handleEvent, + * }) + * await cleanup() + * ``` + */ +export const freebuffTesterAgent: AgentDefinition = { + id: 'freebuff-tester', + displayName: 'Freebuff E2E Tester', + model: 'anthropic/claude-sonnet-4.5', + toolNames: [ + 'start_freebuff', + 'send_to_freebuff', + 'capture_freebuff_output', + 'stop_freebuff', + ], + instructionsPrompt: `You are a QA tester for the Freebuff CLI application. + +Your job is to verify that Freebuff behaves correctly by interacting with it +through tmux tools. Follow these steps: + +1. Call start_freebuff to launch the CLI +2. Use capture_freebuff_output (with waitSeconds) to see the terminal output +3. Use send_to_freebuff to type commands or text +4. Capture output again to verify behavior +5. ALWAYS call stop_freebuff when done + +Key things to verify: +- The CLI starts without errors or crashes +- The startup screen has visible content (non-empty output) +- Commands work as expected +- Error messages are user-friendly + +Report your findings clearly. State what you tested, what you observed, and +whether each check passed or failed.`, +} diff --git a/freebuff/e2e/tests/ads-behavior.e2e.test.ts b/freebuff/e2e/tests/ads-behavior.e2e.test.ts new file mode 100644 index 0000000000..5876d51bea --- /dev/null +++ b/freebuff/e2e/tests/ads-behavior.e2e.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { FreebuffSession, requireFreebuffBinary } from '../utils' + +const TEST_TIMEOUT = 60_000 + +describe('Freebuff: Ads Behavior', () => { + let session: FreebuffSession | null = null + + afterEach(async () => { + if (session) { + await session.stop() + session = null + } + }) + + test( + 'ads commands are not available', + async () => { + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary) + await session.waitForReady() + + // Type "/ads" to check for ads commands in autocomplete + await session.send('/ads', { noEnter: true }) + const output = await session.capture(2) + + // Neither ads:enable nor ads:disable should appear + expect(output).not.toContain('ads:enable') + expect(output).not.toContain('ads:disable') + }, + TEST_TIMEOUT, + ) + + test( + 'startup screen does not show ad-related UI', + async () => { + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary) + await session.waitForReady() + + const output = await session.capture() + + // Ads are always enabled in Freebuff — no credits or toggle UI + expect(output).not.toMatch(/\+\d+ credits/) + expect(output).not.toContain('Hide ads') + expect(output).not.toContain('/ads:enable') + }, + TEST_TIMEOUT, + ) +}) diff --git a/freebuff/e2e/tests/agent-startup.e2e.test.ts b/freebuff/e2e/tests/agent-startup.e2e.test.ts new file mode 100644 index 0000000000..95340b127a --- /dev/null +++ b/freebuff/e2e/tests/agent-startup.e2e.test.ts @@ -0,0 +1,121 @@ +/** + * Agent-driven E2E test for Freebuff. + * + * Uses the Codebuff SDK to run a testing agent that interacts with the + * Freebuff CLI binary via tmux custom tools. Requires CODEBUFF_API_KEY. + * + * Set CODEBUFF_API_KEY to run this test, otherwise it will be skipped. + */ + +import { afterEach, describe, expect, test } from 'bun:test' + +import { freebuffTesterAgent } from '../agent/freebuff-tester' +import { createFreebuffTmuxTools, requireFreebuffBinary } from '../utils' + +import type { CodebuffClient as CodebuffClientType } from '@codebuff/sdk' + +const AGENT_TEST_TIMEOUT = 180_000 + +function getApiKey(): string | null { + return process.env.CODEBUFF_API_KEY ?? null +} + +describe('Freebuff: Agent-driven E2E', () => { + let cleanup: (() => Promise) | null = null + + afterEach(async () => { + if (cleanup) { + await cleanup() + cleanup = null + } + }) + + test( + 'agent can start freebuff and verify startup behavior', + async () => { + const apiKey = getApiKey() + if (!apiKey) { + console.log( + 'Skipping agent test: CODEBUFF_API_KEY not set. ' + + 'Set it to run agent-driven e2e tests.', + ) + return + } + + const binary = requireFreebuffBinary() + const tmuxTools = createFreebuffTmuxTools(binary) + cleanup = tmuxTools.cleanup + + // Dynamically import SDK to avoid build-time dependency issues + const { CodebuffClient } = (await import( + '@codebuff/sdk' + )) as typeof import('@codebuff/sdk') + + const client: CodebuffClientType = new CodebuffClient({ apiKey }) + + const events: Array<{ type: string; [key: string]: unknown }> = [] + + const result = await client.run({ + agent: freebuffTesterAgent.id, + prompt: + 'Start Freebuff using the start_freebuff tool. Then capture the output ' + + 'with capture_freebuff_output (waitSeconds: 3). Verify that:\n' + + '1. The CLI started without errors (no FATAL, panic, or crash messages)\n' + + '2. The output has visible content (not a blank screen)\n' + + 'Finally, call stop_freebuff to clean up. Report your findings.', + agentDefinitions: [freebuffTesterAgent], + customToolDefinitions: tmuxTools.tools, + handleEvent: (event) => { + events.push(event) + }, + }) + + expect(result.output.type).not.toBe('error') + + // Verify the agent exercised the startup path. The afterEach cleanup + // handles stopping Freebuff deterministically if the agent finishes early. + const toolCalls = events.filter((e) => e.type === 'tool_call') + const toolNames = toolCalls.map((e) => e.toolName) + expect(toolNames).toContain('start_freebuff') + expect(toolNames).toContain('capture_freebuff_output') + }, + AGENT_TEST_TIMEOUT, + ) + + test( + 'agent can send commands and verify output', + async () => { + const apiKey = getApiKey() + if (!apiKey) { + console.log('Skipping agent test: CODEBUFF_API_KEY not set.') + return + } + + const binary = requireFreebuffBinary() + const tmuxTools = createFreebuffTmuxTools(binary) + cleanup = tmuxTools.cleanup + + const { CodebuffClient } = (await import( + '@codebuff/sdk' + )) as typeof import('@codebuff/sdk') + + const client: CodebuffClientType = new CodebuffClient({ apiKey }) + + const result = await client.run({ + agent: freebuffTesterAgent.id, + prompt: + 'Start Freebuff, wait for it to load (capture with waitSeconds: 5), ' + + 'then send the "/help" command using send_to_freebuff. ' + + 'Capture the output after 2 seconds. ' + + 'Verify the help content is displayed. ' + + 'Stop Freebuff when done and report your findings.', + agentDefinitions: [freebuffTesterAgent], + customToolDefinitions: tmuxTools.tools, + handleEvent: () => {}, + }) + + expect(result.output.type).not.toBe('error') + }, + AGENT_TEST_TIMEOUT, + ) +}) diff --git a/freebuff/e2e/tests/code-edit.e2e.test.ts b/freebuff/e2e/tests/code-edit.e2e.test.ts new file mode 100644 index 0000000000..a2737de120 --- /dev/null +++ b/freebuff/e2e/tests/code-edit.e2e.test.ts @@ -0,0 +1,78 @@ +/** + * E2E test that verifies Freebuff can perform a simple code edit. + * + * Starts Freebuff in tmux, sends a prompt asking it to add a console.log + * to a file, and verifies the file was modified correctly. + * + * Requires CODEBUFF_API_KEY — skipped if not set. + */ + +import { afterEach, describe, expect, test } from 'bun:test' + +import { FreebuffSession, requireFreebuffBinary } from '../utils' + +const TEST_TIMEOUT = 1_000_000 + +function getApiKey(): string | null { + return process.env.CODEBUFF_API_KEY ?? null +} + +describe.skip('Freebuff: Code Edit', () => { + let session: FreebuffSession | null = null + + afterEach(async () => { + if (session) { + await session.stop() + session = null + } + }) + + test( + 'adds a console.log to a file', + async () => { + if (!getApiKey()) { + console.log( + 'Skipping code-edit test: CODEBUFF_API_KEY not set. ' + + 'Set it to run code-edit e2e tests.', + ) + return + } + + const binary = requireFreebuffBinary() + const initialContent = [ + 'function greet(name) {', + " return 'Hello, ' + name", + '}', + '', + ].join('\n') + + // Create the file before starting freebuff so it's in the initial context + session = await FreebuffSession.start(binary, { + waitSeconds: 5, + initialFiles: { 'index.js': initialContent }, + }) + + // Wait for the CLI to be fully ready before sending input + await session.waitForReady() + + // Verify the file was created + expect(session.readFile('index.js')).toBe(initialContent) + + // Send a prompt asking freebuff to add a console.log + await session.send('Add console.log("hello world") to index.js') + + // Wait for the file to be modified with the console.log + const finalContent = await session.waitForFileContent( + 'index.js', + 'console.log', + 900_000, + ) + + expect(finalContent).toContain('console.log') + expect(finalContent).toContain('hello world') + // The original function should still be present + expect(finalContent).toContain('function greet') + }, + TEST_TIMEOUT, + ) +}) diff --git a/freebuff/e2e/tests/help-command.e2e.test.ts b/freebuff/e2e/tests/help-command.e2e.test.ts new file mode 100644 index 0000000000..f119502561 --- /dev/null +++ b/freebuff/e2e/tests/help-command.e2e.test.ts @@ -0,0 +1,79 @@ +import { execFileSync } from 'node:child_process' + +import { afterEach, describe, expect, test } from 'bun:test' + +import { FreebuffSession, requireFreebuffBinary } from '../utils' + +const TEST_TIMEOUT = 60_000 + +describe('Freebuff: --help flag', () => { + test('shows CLI usage information', () => { + const binary = requireFreebuffBinary() + const output = execFileSync(binary, ['--help'], { + encoding: 'utf-8', + timeout: 10_000, + }) + + // Should show the binary name + expect(output.toLowerCase()).toContain('freebuff') + + // Should show usage info + expect(output).toMatch(/usage|options|commands/i) + }) + + test('does not reference Codebuff', () => { + const binary = requireFreebuffBinary() + const output = execFileSync(binary, ['--help'], { + encoding: 'utf-8', + timeout: 10_000, + }) + + // The --help output should say Freebuff, not Codebuff + expect(output).not.toMatch(/\bcodebuff\b/i) + }) +}) + +describe('Freebuff: /help slash command', () => { + let session: FreebuffSession | null = null + + afterEach(async () => { + if (session) { + await session.stop() + session = null + } + }) + + test( + 'shows help content when /help is entered', + async () => { + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary) + await session.waitForReady() + + await session.send('/help') + const output = await session.capture(2) + + // Should show shortcuts section + expect(output).toMatch(/shortcut|ctrl|esc/i) + }, + TEST_TIMEOUT, + ) + + test( + 'does not show subscription commands in help', + async () => { + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary) + await session.waitForReady() + + await session.send('/help') + const output = await session.capture(2) + + // Freebuff should NOT show these paid/subscription commands + expect(output).not.toContain('/subscribe') + expect(output).not.toContain('/usage') + expect(output).not.toContain('/credits') + }, + TEST_TIMEOUT, + ) +}) diff --git a/freebuff/e2e/tests/knowledge-file.e2e.test.ts b/freebuff/e2e/tests/knowledge-file.e2e.test.ts new file mode 100644 index 0000000000..4d28cebd4b --- /dev/null +++ b/freebuff/e2e/tests/knowledge-file.e2e.test.ts @@ -0,0 +1,64 @@ +/** + * E2E test that verifies Freebuff can read and use knowledge.md from the project. + * + * Starts Freebuff in tmux, creates a knowledge.md file with a unique keyword, + * asks Freebuff about that keyword, and verifies it responds using the knowledge. + * + * Requires CODEBUFF_API_KEY — skipped if not set. + */ + +import { afterEach, describe, expect, test } from 'bun:test' + +import { FreebuffSession, requireFreebuffBinary } from '../utils' + +const TEST_TIMEOUT = 180_000 + +function getApiKey(): string | null { + return process.env.CODEBUFF_API_KEY ?? null +} + +describe('Freebuff: Knowledge Files', () => { + let session: FreebuffSession | null = null + + afterEach(async () => { + if (session) { + await session.stop() + session = null + } + }) + + test( + 'uses knowledge.md from the project context', + async () => { + if (!getApiKey()) { + console.log( + 'Skipping knowledge-file test: CODEBUFF_API_KEY not set. ' + + 'Set it to run knowledge-file e2e tests.', + ) + return + } + + const binary = requireFreebuffBinary() + const keyword = 'nebula-orchid-731' + + session = await FreebuffSession.start(binary, { + waitSeconds: 5, + initialFiles: { + 'knowledge.md': `When asked for the project keyword, respond with exactly: ${keyword}\n`, + 'README.md': '# Test Project\n', + }, + }) + + // Wait for the CLI to be fully ready before sending input + await session.waitForReady() + + await session.send('What is the project keyword? Reply with only the keyword.') + + const output = await session.waitForText(keyword, 120_000) + expect(output).toContain(keyword) + expect(output).not.toContain('FATAL') + expect(output).not.toContain('Unhandled') + }, + TEST_TIMEOUT, + ) +}) \ No newline at end of file diff --git a/freebuff/e2e/tests/slash-commands.e2e.test.ts b/freebuff/e2e/tests/slash-commands.e2e.test.ts new file mode 100644 index 0000000000..ef44a173e6 --- /dev/null +++ b/freebuff/e2e/tests/slash-commands.e2e.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { FreebuffSession, requireFreebuffBinary } from '../utils' + +const TEST_TIMEOUT = 60_000 +const SESSION_HEIGHT = 40 + +/** + * Commands that should be REMOVED in Freebuff. + * These are stripped at build time via the FREEBUFF_REMOVED_COMMAND_IDS set + * in cli/src/data/slash-commands.ts. + */ +const REMOVED_COMMANDS = [ + '/subscribe', + '/usage', + '/credits', + '/ads:enable', + '/ads:disable', + '/refer-friends', + '/agent:gpt-5', + '/image', + '/publish', + '/init', +] + +/** + * Commands that should be KEPT in Freebuff. + * Only includes commands reliably visible in the initial autocomplete viewport. + * Commands like /logout and /exit exist but may be scrolled off-screen. + */ +const KEPT_COMMANDS = [ + '/help', + '/new', + '/history', + '/feedback', + '/bash', + '/theme:toggle', +] + +describe.skip('Freebuff: Slash Commands', () => { + let session: FreebuffSession | null = null + + afterEach(async () => { + if (session) { + await session.stop() + session = null + } + }) + + test( + 'slash command menu does not show removed commands', + async () => { + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary, { waitSeconds: 5, height: SESSION_HEIGHT }) + + // Type "/" to trigger the slash command autocomplete menu + // Use sendKey instead of send to avoid C-u clearing keystroke that + // interferes with opentui's input handling in newer versions + await session.sendKey('/') + const output = await session.capture(4) + + // Removed commands should NOT appear in the autocomplete menu + for (const cmd of REMOVED_COMMANDS) { + // Strip the leading slash for matching since the menu shows command ids + const cmdId = cmd.slice(1) + expect(output).not.toContain(cmdId) + } + }, + TEST_TIMEOUT, + ) + + test( + 'slash command menu shows kept commands', + async () => { + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary, { waitSeconds: 5, height: SESSION_HEIGHT }) + + // Type "/" to trigger the slash command autocomplete menu + await session.sendKey('/') + const output = await session.capture(4) + + // Kept commands SHOULD appear in the autocomplete menu + for (const cmd of KEPT_COMMANDS) { + const cmdId = cmd.slice(1) + expect(output).toContain(cmdId) + } + }, + TEST_TIMEOUT, + ) + + test( + 'no mode-related slash commands are visible', + async () => { + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary, { waitSeconds: 5, height: SESSION_HEIGHT }) + + // Type "/mode" to check for mode commands + // Use sendKey for the full string to avoid C-u clearing the input + await session.sendKey('/mode') + const output = await session.capture(4) + + // Mode commands should not exist in Freebuff + expect(output).not.toContain('mode:max') + expect(output).not.toContain('mode:default') + expect(output).not.toContain('mode:lite') + expect(output).not.toContain('mode:free') + }, + TEST_TIMEOUT, + ) +}) diff --git a/freebuff/e2e/tests/startup.e2e.test.ts b/freebuff/e2e/tests/startup.e2e.test.ts new file mode 100644 index 0000000000..699dd4b643 --- /dev/null +++ b/freebuff/e2e/tests/startup.e2e.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import { FreebuffSession, requireFreebuffBinary } from '../utils' + +const STARTUP_TIMEOUT = 60_000 + +describe('Freebuff: Startup', () => { + let session: FreebuffSession | null = null + + afterEach(async () => { + if (session) { + await session.stop() + session = null + } + }) + + test( + 'binary renders its boot screen', + async () => { + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary) + + // The 3rd row of the FREEBUFF ASCII logo: the crossbars of F and R + // adjacent. Picked because the logo renders for *every* valid boot + // state — model picker, waiting room, country-blocked (which is what + // CI runners hit, since GitHub Actions egress is flagged as anonymized + // network) — but never appears if module init crashes before React + // mounts (the post-OpenTUI-upgrade tree-sitter wasm regression). This + // gives us a positive "boot succeeded" signal that's robust against + // novel error modes, not just the ones we listed below. + const output = await session.waitForText('█████╗ ██████╔╝') + + // Belt-and-braces: known fatal markers should never coexist with a + // rendered logo, but if some race ever surfaces one we still want to + // see it called out clearly rather than buried in raw output. + expect(output).not.toContain('Fatal error during startup') + expect(output).not.toContain('Internal error: tree-sitter.wasm not found') + expect(output).not.toContain('FATAL') + expect(output).not.toContain('panic') + expect(output).not.toContain('Segmentation fault') + }, + STARTUP_TIMEOUT, + ) + + test( + 'responds to Ctrl+C gracefully', + async () => { + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary) + await session.waitForReady() + + await session.sendKey('C-c') + + // Give it a moment to process + const output = await session.capture(1) + + // Should not show an unhandled error + expect(output).not.toContain('Unhandled') + expect(output).not.toContain('FATAL') + }, + STARTUP_TIMEOUT, + ) +}) diff --git a/freebuff/e2e/tests/terminal-command.e2e.test.ts b/freebuff/e2e/tests/terminal-command.e2e.test.ts new file mode 100644 index 0000000000..c1fa5c4fb1 --- /dev/null +++ b/freebuff/e2e/tests/terminal-command.e2e.test.ts @@ -0,0 +1,71 @@ +/** + * E2E test that verifies Freebuff can run terminal commands. + * + * Starts Freebuff in tmux, sends a prompt asking it to run a shell command, + * and verifies the command was executed by checking its side effects. + * + * Requires CODEBUFF_API_KEY — skipped if not set. + */ + +import { afterEach, describe, expect, test } from 'bun:test' + +import { FreebuffSession, requireFreebuffBinary } from '../utils' + +const TEST_TIMEOUT = 1_000_000 + +function getApiKey(): string | null { + return process.env.CODEBUFF_API_KEY ?? null +} + +describe.skip('Freebuff: Terminal Command', () => { + let session: FreebuffSession | null = null + + afterEach(async () => { + if (session) { + await session.stop() + session = null + } + }) + + test( + 'runs a terminal command that creates a file', + async () => { + if (!getApiKey()) { + console.log( + 'Skipping terminal-command test: CODEBUFF_API_KEY not set. ' + + 'Set it to run terminal-command e2e tests.', + ) + return + } + + const binary = requireFreebuffBinary() + session = await FreebuffSession.start(binary, { waitSeconds: 5 }) + + // Wait for the CLI to be fully ready before sending input + await session.waitForReady() + + // Ask freebuff to run a shell command whose output can only come from + // actual terminal execution (not file-writing tools) + await session.send( + 'Execute a shell command in the terminal to write the current Unix timestamp in seconds to timestamp.txt', + ) + + // Wait for the file to be created by the terminal command + const content = await session.waitForFileContent( + 'timestamp.txt', + '', + 900_000, + ) + + // The file should contain a Unix timestamp (numeric string) + const trimmed = content.trim() + expect(trimmed).toMatch(/^\d{10,}$/) + + // Verify the timestamp is recent (within the last 5 minutes) + const timestamp = parseInt(trimmed, 10) + const now = Math.floor(Date.now() / 1000) + expect(Math.abs(now - timestamp)).toBeLessThan(300) + }, + TEST_TIMEOUT, + ) +}) diff --git a/freebuff/e2e/tests/version.e2e.test.ts b/freebuff/e2e/tests/version.e2e.test.ts new file mode 100644 index 0000000000..2e01990c9d --- /dev/null +++ b/freebuff/e2e/tests/version.e2e.test.ts @@ -0,0 +1,24 @@ +import { execFileSync } from 'node:child_process' + +import { describe, expect, test } from 'bun:test' + +import { requireFreebuffBinary } from '../utils' + +describe('Freebuff: --version', () => { + test('outputs a version string', () => { + const binary = requireFreebuffBinary() + const output = execFileSync(binary, ['--version'], { + encoding: 'utf-8', + timeout: 10_000, + }).trim() + + // Should contain a semver-like version (e.g. "0.0.15" or "1.0.0") + expect(output).toMatch(/\d+\.\d+\.\d+/) + }) + + test('exits with code 0', () => { + const binary = requireFreebuffBinary() + // execFileSync throws on non-zero exit codes, so if this doesn't throw, it exited 0 + execFileSync(binary, ['--version'], { encoding: 'utf-8', timeout: 10_000 }) + }) +}) diff --git a/freebuff/e2e/utils/binary-helpers.ts b/freebuff/e2e/utils/binary-helpers.ts new file mode 100644 index 0000000000..c233574dd4 --- /dev/null +++ b/freebuff/e2e/utils/binary-helpers.ts @@ -0,0 +1,24 @@ +import { existsSync } from 'fs' +import { dirname, resolve } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +export const REPO_ROOT = resolve(__dirname, '../../..') + +export function getFreebuffBinaryPath(): string { + if (process.env.FREEBUFF_BINARY) { + return resolve(process.env.FREEBUFF_BINARY) + } + return resolve(REPO_ROOT, 'cli/bin/freebuff') +} + +export function requireFreebuffBinary(): string { + const binaryPath = getFreebuffBinaryPath() + if (!existsSync(binaryPath)) { + throw new Error( + `Freebuff binary not found at ${binaryPath}. ` + + 'Build with: bun freebuff/cli/build.ts ', + ) + } + return binaryPath +} diff --git a/freebuff/e2e/utils/freebuff-session.ts b/freebuff/e2e/utils/freebuff-session.ts new file mode 100644 index 0000000000..d2c5633086 --- /dev/null +++ b/freebuff/e2e/utils/freebuff-session.ts @@ -0,0 +1,186 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' + +import { tmuxCapture, tmuxSend, tmuxSendKey, tmuxStart, tmuxStop } from './tmux-helpers' + +export class FreebuffSession { + public readonly name: string + public readonly workDir: string + + private constructor(sessionName: string, workDir: string) { + this.name = sessionName + this.workDir = workDir + } + + /** + * Start a freebuff binary in a tmux session. + * Creates a temporary working directory to simulate a real user project. + */ + static async start( + binaryPath: string, + options?: { + waitSeconds?: number + width?: number + height?: number + initialFiles?: Record + }, + ): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'freebuff-e2e-')) + + // Create a minimal project so freebuff has something to work with + fs.writeFileSync( + path.join(tmpDir, 'README.md'), + '# E2E Test Project\n', + 'utf-8', + ) + + // Write any initial files before starting the binary + if (options?.initialFiles) { + for (const [relativePath, content] of Object.entries(options.initialFiles)) { + const filePath = path.join(tmpDir, relativePath) + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(filePath, content, 'utf-8') + } + } + + const command = `cd '${tmpDir}' && '${binaryPath}'` + const sessionName = tmuxStart({ + command, + waitSeconds: options?.waitSeconds ?? 4, + width: options?.width ?? 120, + height: options?.height ?? 30, + }) + + return new FreebuffSession(sessionName, tmpDir) + } + + /** Write a file into the session's working directory. */ + writeFile(relativePath: string, content: string): void { + const filePath = path.join(this.workDir, relativePath) + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(filePath, content, 'utf-8') + } + + /** Read a file from the session's working directory. */ + readFile(relativePath: string): string { + return fs.readFileSync(path.join(this.workDir, relativePath), 'utf-8') + } + + /** Check if a file exists in the session's working directory. */ + fileExists(relativePath: string): boolean { + return fs.existsSync(path.join(this.workDir, relativePath)) + } + + /** + * Poll until a file in the working directory contains the given text. + * Throws if the timeout is exceeded. + */ + async waitForFileContent( + relativePath: string, + pattern: string, + timeoutMs = 60_000, + ): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + try { + const content = this.readFile(relativePath) + if (content.includes(pattern)) return content + } catch { + // File may not exist yet + } + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + let finalContent = '(file does not exist)' + try { + finalContent = this.readFile(relativePath) + } catch { + // ignore + } + const terminalOutput = await this.capture() + throw new Error( + `Timed out after ${timeoutMs}ms waiting for "${pattern}" in ${relativePath}.\n` + + `Last content:\n${finalContent}\n` + + `Terminal output:\n${terminalOutput}`, + ) + } + + /** + * Wait for the CLI to be fully initialized and ready for input. + * Polls terminal output until enough non-empty lines are visible, + * indicating the TUI has rendered its initial layout. + */ + async waitForReady(timeoutMs = 30_000, minLines = 5): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const output = await this.capture() + const nonEmptyLines = output + .split('\n') + .filter((line) => line.trim().length > 0) + if (nonEmptyLines.length >= minLines) return + await new Promise((resolve) => setTimeout(resolve, 250)) + } + const finalOutput = await this.capture() + throw new Error( + `Timed out after ${timeoutMs}ms waiting for CLI to be ready.\n` + + `Last output:\n${finalOutput}`, + ) + } + + /** Send text input to the freebuff CLI (presses Enter by default). */ + async send( + text: string, + options?: { noEnter?: boolean; waitIdle?: number }, + ): Promise { + tmuxSend(this.name, text, { ...options, force: true }) + } + + /** Send a special key (e.g. Escape, C-c, Enter). */ + async sendKey(key: string): Promise { + tmuxSendKey(this.name, key) + } + + /** Capture current terminal output, optionally waiting first. */ + async capture(waitSeconds?: number): Promise { + return tmuxCapture(this.name, { waitSeconds, noSave: true }) + } + + /** Capture and auto-save to the session logs directory with a label. */ + async captureLabeled(label: string, waitSeconds?: number): Promise { + return tmuxCapture(this.name, { waitSeconds, label }) + } + + /** + * Poll until the terminal output contains the given text. + * Throws if the timeout is exceeded. + */ + async waitForText(pattern: string, timeoutMs = 30_000): Promise { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const output = await this.capture() + if (output.includes(pattern)) return output + await new Promise((resolve) => setTimeout(resolve, 500)) + } + const finalOutput = await this.capture() + throw new Error( + `Timed out after ${timeoutMs}ms waiting for "${pattern}".\n` + + `Last output:\n${finalOutput}`, + ) + } + + /** Stop the tmux session and clean up the temp directory. */ + async stop(): Promise { + tmuxStop(this.name) + try { + fs.rmSync(this.workDir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } + } +} diff --git a/freebuff/e2e/utils/index.ts b/freebuff/e2e/utils/index.ts new file mode 100644 index 0000000000..6927a4abd4 --- /dev/null +++ b/freebuff/e2e/utils/index.ts @@ -0,0 +1,10 @@ +export { getFreebuffBinaryPath, requireFreebuffBinary, REPO_ROOT } from './binary-helpers' +export { FreebuffSession } from './freebuff-session' +export { createFreebuffTmuxTools } from './tmux-custom-tools' +export { + tmuxStart, + tmuxSend, + tmuxSendKey, + tmuxCapture, + tmuxStop, +} from './tmux-helpers' diff --git a/freebuff/e2e/utils/tmux-custom-tools.ts b/freebuff/e2e/utils/tmux-custom-tools.ts new file mode 100644 index 0000000000..f37fae014d --- /dev/null +++ b/freebuff/e2e/utils/tmux-custom-tools.ts @@ -0,0 +1,156 @@ +import { z } from 'zod/v4' + +import { FreebuffSession } from './freebuff-session' + +import type { ZodType } from 'zod/v4' + +interface FreebuffToolDefinition { + toolName: string + description: string + inputSchema: ZodType + endsAgentStep: boolean + exampleInputs: Record[] + execute: (input: Record) => Promise +} + +type ToolOutput = { type: 'json'; value: Record }[] + +/** + * Creates custom tool definitions that allow a Codebuff SDK agent + * to interact with a Freebuff CLI binary via tmux. + * + * Returns the tools array and a cleanup function to call in afterEach. + * + * Usage: + * ```ts + * const { tools, cleanup } = createFreebuffTmuxTools(binaryPath) + * // ... pass tools to client.run({ customToolDefinitions: tools }) + * // ... in afterEach: await cleanup() + * ``` + */ +export function createFreebuffTmuxTools(binaryPath: string): { + tools: FreebuffToolDefinition[] + cleanup: () => Promise +} { + let session: FreebuffSession | null = null + + const startTool: FreebuffToolDefinition = { + toolName: 'start_freebuff', + description: + 'Start the Freebuff CLI binary in a tmux terminal session. Call this first before interacting with Freebuff.', + inputSchema: z.object({}), + endsAgentStep: true, + exampleInputs: [{}], + execute: async (): Promise => { + if (session) { + return [ + { + type: 'json', + value: { + error: 'Session already running', + sessionName: session.name, + }, + }, + ] + } + session = await FreebuffSession.start(binaryPath) + await session.waitForReady() + const initialOutput = await session.capture() + return [ + { + type: 'json', + value: { + started: true, + sessionName: session.name, + initialOutput, + }, + }, + ] + }, + } + + const sendInputTool: FreebuffToolDefinition = { + toolName: 'send_to_freebuff', + description: + 'Send text input to the running Freebuff CLI. The text is sent as if typed by the user and Enter is pressed.', + inputSchema: z.object({ + text: z.string().describe('Text to send to Freebuff'), + }), + endsAgentStep: false, + exampleInputs: [{ text: '/help' }], + execute: async (input): Promise => { + const text = (input as { text: string }).text + if (!session) { + return [ + { + type: 'json', + value: { error: 'No session running. Call start_freebuff first.' }, + }, + ] + } + await session.send(text) + return [{ type: 'json', value: { sent: true, text } }] + }, + } + + const captureOutputTool: FreebuffToolDefinition = { + toolName: 'capture_freebuff_output', + description: + 'Capture the current terminal output from the running Freebuff CLI session. ' + + 'Use waitSeconds to wait before capturing (useful after sending a command).', + inputSchema: z.object({ + waitSeconds: z + .number() + .optional() + .describe('Seconds to wait before capturing (default: 0)'), + }), + endsAgentStep: true, + exampleInputs: [{ waitSeconds: 2 }], + execute: async (input): Promise => { + const waitSeconds = (input as { waitSeconds?: number }).waitSeconds + if (!session) { + return [ + { + type: 'json', + value: { error: 'No session running. Call start_freebuff first.' }, + }, + ] + } + const output = await session.capture(waitSeconds) + return [{ type: 'json', value: { output } }] + }, + } + + const stopTool: FreebuffToolDefinition = { + toolName: 'stop_freebuff', + description: + 'Stop the running Freebuff CLI session and clean up resources. Always call this when done testing.', + inputSchema: z.object({}), + endsAgentStep: true, + exampleInputs: [{}], + execute: async (): Promise => { + if (!session) { + return [ + { type: 'json', value: { stopped: true, wasRunning: false } }, + ] + } + await session.stop() + session = null + return [ + { type: 'json', value: { stopped: true, wasRunning: true } }, + ] + }, + } + + const cleanup = async () => { + if (session) { + await session.stop() + session = null + } + } + + return { + tools: [startTool, sendInputTool, captureOutputTool, stopTool], + cleanup, + } +} diff --git a/freebuff/e2e/utils/tmux-helpers.ts b/freebuff/e2e/utils/tmux-helpers.ts new file mode 100644 index 0000000000..40999a3360 --- /dev/null +++ b/freebuff/e2e/utils/tmux-helpers.ts @@ -0,0 +1,83 @@ +import { execFileSync } from 'child_process' + +import { REPO_ROOT } from './binary-helpers' + +const SCRIPTS_DIR = `${REPO_ROOT}/scripts/tmux` + +const EXEC_OPTIONS = { encoding: 'utf-8' as const, cwd: REPO_ROOT } + +export interface TmuxStartOptions { + command: string + name?: string + width?: number + height?: number + waitSeconds?: number +} + +export function tmuxStart(options: TmuxStartOptions): string { + const args: string[] = [ + `${SCRIPTS_DIR}/tmux-start.sh`, + '--command', + options.command, + '--plain', + ] + if (options.name) args.push('--name', options.name) + if (options.width) args.push('--width', String(options.width)) + if (options.height) args.push('--height', String(options.height)) + if (options.waitSeconds !== undefined) + args.push('--wait', String(options.waitSeconds)) + + return execFileSync('bash', args, EXEC_OPTIONS).trim() +} + +export function tmuxSend( + sessionName: string, + text: string, + options?: { noEnter?: boolean; waitIdle?: number; force?: boolean }, +): void { + const args: string[] = [ + `${SCRIPTS_DIR}/tmux-send.sh`, + sessionName, + text, + ] + if (options?.noEnter) args.push('--no-enter') + if (options?.waitIdle) args.push('--wait-idle', String(options.waitIdle)) + if (options?.force) args.push('--force') + + execFileSync('bash', args, EXEC_OPTIONS) +} + +export function tmuxSendKey(sessionName: string, key: string): void { + execFileSync( + 'bash', + [`${SCRIPTS_DIR}/tmux-send.sh`, sessionName, '--key', key], + EXEC_OPTIONS, + ) +} + +export function tmuxCapture( + sessionName: string, + options?: { waitSeconds?: number; label?: string; noSave?: boolean }, +): string { + const args: string[] = [`${SCRIPTS_DIR}/tmux-capture.sh`, sessionName] + if (options?.waitSeconds) args.push('--wait', String(options.waitSeconds)) + if (options?.label) args.push('--label', options.label) + if (options?.noSave) args.push('--no-save') + + return execFileSync('bash', args, { + ...EXEC_OPTIONS, + stdio: ['pipe', 'pipe', 'pipe'], + }) +} + +export function tmuxStop(sessionName: string): void { + try { + execFileSync( + 'bash', + [`${SCRIPTS_DIR}/tmux-stop.sh`, sessionName], + EXEC_OPTIONS, + ) + } catch { + // tmux-stop.sh is idempotent; ignore errors if session already gone + } +} diff --git a/freebuff/package.json b/freebuff/package.json new file mode 100644 index 0000000000..1a42f3c055 --- /dev/null +++ b/freebuff/package.json @@ -0,0 +1,20 @@ +{ + "name": "@codebuff/freebuff", + "version": "1.0.0", + "private": true, + "scripts": { + "release": "bun cli/release.ts", + "build:binary": "bun cli/build.ts 0.0.0-dev", + "e2e": "bun run build:binary && bun test e2e/tests/", + "e2e:version": "bun test e2e/tests/version.e2e.test.ts", + "e2e:startup": "bun test e2e/tests/startup.e2e.test.ts", + "e2e:help": "bun test e2e/tests/help-command.e2e.test.ts", + "e2e:slash-commands": "bun test e2e/tests/slash-commands.e2e.test.ts", + "e2e:mode": "bun test e2e/tests/mode-restriction.e2e.test.ts", + "e2e:ads": "bun test e2e/tests/ads-behavior.e2e.test.ts", + "e2e:agent": "bun test e2e/tests/agent-startup.e2e.test.ts", + "e2e:code-edit": "bun test e2e/tests/code-edit.e2e.test.ts", + "e2e:terminal-command": "bun test e2e/tests/terminal-command.e2e.test.ts", + "e2e:knowledge-file": "bun test e2e/tests/knowledge-file.e2e.test.ts" + } +} diff --git a/freebuff/web/.gitignore b/freebuff/web/.gitignore new file mode 100644 index 0000000000..5e637f4474 --- /dev/null +++ b/freebuff/web/.gitignore @@ -0,0 +1,3 @@ +.next/ +node_modules/ +next-env.d.ts diff --git a/freebuff/web/knowledge.md b/freebuff/web/knowledge.md new file mode 100644 index 0000000000..41765f437d --- /dev/null +++ b/freebuff/web/knowledge.md @@ -0,0 +1,34 @@ +# Freebuff Web + +The Freebuff website (freebuff.com) — a simplified marketing and auth frontend for the Freebuff free coding agent. + +## Architecture + +- **Separate Next.js app** in `freebuff/web/`, not a conditionally-configured version of `web/` +- **Shared auth**: Same NextAuth config, same database, same GitHub OAuth — one account works for both Codebuff and Freebuff +- **Shared backend**: The Freebuff CLI talks to the Codebuff backend (`codebuff.com`). This website is primarily a marketing + auth frontend. +- **Minimal scope**: Landing page, login, onboard (CLI auth callback). No pricing, store, org management, admin, or docs. + +## Key differences from Codebuff web + +- No PostHog analytics +- No contentlayer/docs system +- No Stripe billing UI (but auth-options still creates Stripe customers for shared accounts) +- No org management, admin panel, or store +- Freebuff-specific branding (green accent, "Free" emphasis) + +## Running locally + +```bash +bun --cwd freebuff/web dev +``` + +Runs on port 3002 by default (to avoid conflicts with Codebuff web on 3000). + +## Environment + +Same env vars as the main Codebuff web app. In production, deploy with: +- `NEXT_PUBLIC_CODEBUFF_APP_URL=https://freebuff.com` +- `NEXTAUTH_URL=https://freebuff.com` +- Same DB credentials as Codebuff +- Potentially a separate GitHub OAuth app for the freebuff.com callback URL diff --git a/freebuff/web/next.config.mjs b/freebuff/web/next.config.mjs new file mode 100644 index 0000000000..5030be8c6f --- /dev/null +++ b/freebuff/web/next.config.mjs @@ -0,0 +1,98 @@ +import { resolve } from 'path' + +const FREEBUFF_PORT = 3002 + +/** @type {import('next').NextConfig} */ +const nextConfig = { + outputFileTracingRoot: resolve(import.meta.dirname, '../../'), + env: { + // In development, override the app URL to point to the Freebuff dev server port. + // In production, NEXT_PUBLIC_CODEBUFF_APP_URL is set via deployment env vars. + ...(process.env.NODE_ENV === 'development' + ? { + NEXT_PUBLIC_CODEBUFF_APP_URL: `http://localhost:${FREEBUFF_PORT}`, + NEXTAUTH_URL: `http://localhost:${FREEBUFF_PORT}`, + } + : {}), + }, + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, + webpack: (config) => { + config.resolve.fallback = { fs: false, net: false, tls: false, path: false } + config.externals.push( + { 'thread-stream': 'commonjs thread-stream', pino: 'commonjs pino' }, + 'pino-pretty', + 'encoding', + 'perf_hooks', + 'async_hooks', + ) + config.externals.push( + '@codebuff/code-map', + '@codebuff/code-map/parse', + '@codebuff/code-map/languages', + /^@codebuff\/code-map/, + ) + config.infrastructureLogging = { + level: 'error', + } + return config + }, + headers: () => { + return [ + { + source: '/(.*)', + headers: [ + { + key: 'X-Frame-Options', + value: 'SAMEORIGIN', + }, + ], + }, + { + source: '/api/auth/cli/:path*', + headers: [ + { + key: 'Access-Control-Allow-Origin', + value: '*', + }, + { + key: 'Access-Control-Allow-Methods', + value: 'GET, POST, OPTIONS', + }, + { + key: 'Access-Control-Allow-Headers', + value: 'Content-Type', + }, + ], + }, + ] + }, + reactStrictMode: false, + async redirects() { + return [ + { + source: '/b/:hash', + destination: 'https://go.trybeluga.ai/:hash', + permanent: false, + }, + ] + }, + async rewrites() { + return [ + { + source: '/ingest/static/:path*', + destination: 'https://us-assets.i.posthog.com/static/:path*', + }, + { + source: '/ingest/:path*', + destination: 'https://us.i.posthog.com/:path*', + }, + ] + }, +} + +export default nextConfig diff --git a/freebuff/web/package.json b/freebuff/web/package.json new file mode 100644 index 0000000000..b22be8891a --- /dev/null +++ b/freebuff/web/package.json @@ -0,0 +1,45 @@ +{ + "name": "@codebuff/freebuff-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --port 3002", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit -p .", + "clean": "rm -rf .next" + }, + "dependencies": { + "@auth/drizzle-adapter": "^1.7.4", + "@codebuff/billing": "workspace:*", + "@codebuff/common": "workspace:*", + "@codebuff/internal": "workspace:*", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-slot": "^1.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^11.13.3", + "lucide-react": "^0.487.0", + "next": "15.5.16", + "next-auth": "^4.24.11", + "next-themes": "^0.4.6", + "pino": "^9.6.0", + "posthog-js": "^1.363.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.2", + "zod": "^4.2.1" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.15", + "@types/node": "^22.14.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "autoprefixer": "^10.4.21", + "postcss": "^8", + "tailwindcss": "^3.4.11", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5" + } +} diff --git a/freebuff/web/postcss.config.cjs b/freebuff/web/postcss.config.cjs new file mode 100644 index 0000000000..33ad091d26 --- /dev/null +++ b/freebuff/web/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/freebuff/web/public/favicon/apple-touch-icon.png b/freebuff/web/public/favicon/apple-touch-icon.png new file mode 100644 index 0000000000..c4a8bdd13e Binary files /dev/null and b/freebuff/web/public/favicon/apple-touch-icon.png differ diff --git a/freebuff/web/public/favicon/favicon-16x16.ico b/freebuff/web/public/favicon/favicon-16x16.ico new file mode 100644 index 0000000000..ac9379977b Binary files /dev/null and b/freebuff/web/public/favicon/favicon-16x16.ico differ diff --git a/freebuff/web/public/favicon/favicon-32x32.ico b/freebuff/web/public/favicon/favicon-32x32.ico new file mode 100644 index 0000000000..7ded827c51 Binary files /dev/null and b/freebuff/web/public/favicon/favicon-32x32.ico differ diff --git a/freebuff/web/public/logo-icon-black-bg.png b/freebuff/web/public/logo-icon-black-bg.png new file mode 100644 index 0000000000..f99f944c8d Binary files /dev/null and b/freebuff/web/public/logo-icon-black-bg.png differ diff --git a/freebuff/web/public/logo-icon.png b/freebuff/web/public/logo-icon.png new file mode 100644 index 0000000000..54806e0831 Binary files /dev/null and b/freebuff/web/public/logo-icon.png differ diff --git a/freebuff/web/public/logos/cursor.png b/freebuff/web/public/logos/cursor.png new file mode 100644 index 0000000000..f63ec8349a Binary files /dev/null and b/freebuff/web/public/logos/cursor.png differ diff --git a/freebuff/web/public/logos/intellij.png b/freebuff/web/public/logos/intellij.png new file mode 100644 index 0000000000..a92be39a69 Binary files /dev/null and b/freebuff/web/public/logos/intellij.png differ diff --git a/freebuff/web/public/logos/terminal.svg b/freebuff/web/public/logos/terminal.svg new file mode 100644 index 0000000000..69ad44343a --- /dev/null +++ b/freebuff/web/public/logos/terminal.svg @@ -0,0 +1,10 @@ + + + + + + > + + + + \ No newline at end of file diff --git a/freebuff/web/public/logos/visual-studio.png b/freebuff/web/public/logos/visual-studio.png new file mode 100644 index 0000000000..719076ff34 Binary files /dev/null and b/freebuff/web/public/logos/visual-studio.png differ diff --git a/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts new file mode 100644 index 0000000000..53a0d05aea --- /dev/null +++ b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts @@ -0,0 +1,198 @@ +// TODO: Extract shared auth config to packages/auth to avoid duplication with web/src/app/api/auth/[...nextauth]/auth-options.ts +import { DrizzleAdapter } from '@auth/drizzle-adapter' +import { trackEvent } from '@codebuff/common/analytics' +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { SESSION_MAX_AGE_SECONDS } from '@codebuff/common/old-constants' +import { loops } from '@codebuff/internal' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { env } from '@codebuff/internal/env' +import { stripeServer } from '@codebuff/internal/util/stripe' +import { logSyncFailure } from '@codebuff/internal/util/sync-failure' +import { eq } from 'drizzle-orm' +import GitHubProvider from 'next-auth/providers/github' + +import type { NextAuthOptions } from 'next-auth' +import type { Adapter } from 'next-auth/adapters' + +import { + getCliAuthCodeHashPrefix, + getCliAuthOnboardSearchParams, + isCliAuthCodeCandidate, +} from '@/app/onboard/_helpers' +import { logger } from '@/util/logger' + +async function createAndLinkStripeCustomer(params: { + userId: string + email: string | null + name: string | null +}): Promise { + const { userId, email, name } = params + + if (!email || !name) { + logger.warn( + { userId }, + 'User email or name missing, cannot create Stripe customer.', + ) + return null + } + try { + const customer = await stripeServer.customers.create({ + email, + name, + metadata: { + user_id: userId, + }, + }) + + await db + .update(schema.user) + .set({ + stripe_customer_id: customer.id, + }) + .where(eq(schema.user.id, userId)) + + logger.info( + { userId, customerId: customer.id }, + 'Stripe customer created and linked to user.', + ) + return customer.id + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Unknown error creating Stripe customer' + logger.error( + { userId, error }, + 'Failed to create Stripe customer or update user record.', + ) + await logSyncFailure({ + id: userId, + errorMessage, + provider: 'stripe', + logger, + }) + return null + } +} + +export const authOptions: NextAuthOptions = { + adapter: DrizzleAdapter(db, { + usersTable: schema.user, + accountsTable: schema.account, + sessionsTable: schema.session, + verificationTokensTable: schema.verificationToken, + }) as Adapter, + providers: [ + GitHubProvider({ + clientId: env.FREEBUFF_GITHUB_ID ?? env.CODEBUFF_GITHUB_ID, + clientSecret: env.FREEBUFF_GITHUB_SECRET ?? env.CODEBUFF_GITHUB_SECRET, + }), + ], + session: { + strategy: 'database', + maxAge: SESSION_MAX_AGE_SECONDS, + }, + callbacks: { + async session({ session, user }) { + if (session.user) { + session.user.id = user.id + session.user.image = user.image + session.user.name = user.name + session.user.email = user.email + session.user.stripe_customer_id = user.stripe_customer_id + } + return session + }, + async redirect({ url, baseUrl }) { + const potentialRedirectUrl = new URL(url, baseUrl) + const authCode = potentialRedirectUrl.searchParams.get('auth_code') + + if (authCode) { + if (!isCliAuthCodeCandidate(authCode)) { + const searchParamKeys = Array.from( + potentialRedirectUrl.searchParams.keys(), + ).sort() + logger.warn( + { + authCodeLength: authCode.length, + authCodeTrimmedLength: authCode.trim().length, + authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode), + authCodeParamCount: + potentialRedirectUrl.searchParams.getAll('auth_code').length, + searchParamKeys, + searchParamCount: searchParamKeys.length, + hasCallbackUrlParam: searchParamKeys.includes('callbackUrl'), + hasCodeParam: searchParamKeys.includes('code'), + hasRedirectParam: searchParamKeys.includes('redirect'), + dotCount: authCode.match(/\./g)?.length ?? 0, + hyphenCount: authCode.match(/-/g)?.length ?? 0, + redirectUrlOrigin: potentialRedirectUrl.origin, + baseUrl, + }, + 'Freebuff auth redirect received non-CLI-shaped auth_code', + ) + return baseUrl + } + + const onboardUrl = new URL(`${baseUrl}/onboard`) + onboardUrl.search = getCliAuthOnboardSearchParams( + potentialRedirectUrl.searchParams, + authCode, + ).toString() + return onboardUrl.toString() + } + + if (url.startsWith('/') || potentialRedirectUrl.origin === baseUrl) { + return potentialRedirectUrl.toString() + } + + return baseUrl + }, + }, + events: { + createUser: async ({ user }) => { + logger.info( + { userId: user.id, email: user.email }, + 'createUser event triggered', + ) + + const userData = await db.query.user.findFirst({ + where: eq(schema.user.id, user.id), + columns: { + id: true, + email: true, + name: true, + next_quota_reset: true, + }, + }) + + if (!userData) { + logger.error({ userId: user.id }, 'User data not found after creation') + return + } + + await createAndLinkStripeCustomer({ + ...userData, + userId: userData.id, + }) + + // Freebuff is free - new accounts do not receive any credit grant. + + await loops.sendSignupEventToLoops({ + ...userData, + userId: userData.id, + logger, + signupSource: 'freebuff', + }) + + trackEvent({ + event: AnalyticsEvent.SIGNUP, + userId: userData.id, + logger, + }) + + logger.info({ user }, 'createUser event processing finished.') + }, + }, +} diff --git a/freebuff/web/src/app/api/auth/[...nextauth]/route.ts b/freebuff/web/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000000..5ea370065d --- /dev/null +++ b/freebuff/web/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,7 @@ +import NextAuth from 'next-auth' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST } diff --git a/freebuff/web/src/app/api/auth/cli/code/__tests__/origin.test.ts b/freebuff/web/src/app/api/auth/cli/code/__tests__/origin.test.ts new file mode 100644 index 0000000000..e23a3cf671 --- /dev/null +++ b/freebuff/web/src/app/api/auth/cli/code/__tests__/origin.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' + +import { getLoginUrlOrigin } from '../_origin' + +describe('api/auth/cli/code/_origin', () => { + test('uses the configured public app URL over the request origin', () => { + const req = new Request('https://localhost:10000/api/auth/cli/code') + + expect( + getLoginUrlOrigin( + req, + 'https://freebuff.com', + 'https://freebuff.com', + false, + ), + ).toBe('https://freebuff.com') + }) + + test('ignores a localhost configured URL in production', () => { + const req = new Request('https://localhost:10000/api/auth/cli/code') + + expect( + getLoginUrlOrigin( + req, + 'https://localhost:10000', + 'https://freebuff.com', + false, + ), + ).toBe('https://freebuff.com') + }) + + test('ignores IPv6 localhost in production', () => { + const req = new Request('http://[::1]:3002/api/auth/cli/code') + + expect( + getLoginUrlOrigin( + req, + 'http://[::1]:3002', + 'https://freebuff.com', + false, + ), + ).toBe('https://freebuff.com') + }) + + test('allows a localhost configured URL outside production', () => { + const req = new Request('http://localhost:3002/api/auth/cli/code') + + expect( + getLoginUrlOrigin( + req, + 'http://localhost:3002', + 'https://freebuff.com', + true, + ), + ).toBe('http://localhost:3002') + }) + + test('falls back to the request origin when configured URL is invalid', () => { + const req = new Request('http://localhost:3002/api/auth/cli/code') + + expect( + getLoginUrlOrigin(req, 'not a url', 'https://freebuff.com', true), + ).toBe('http://localhost:3002') + }) +}) diff --git a/freebuff/web/src/app/api/auth/cli/code/_origin.ts b/freebuff/web/src/app/api/auth/cli/code/_origin.ts new file mode 100644 index 0000000000..f2c3c4dfa1 --- /dev/null +++ b/freebuff/web/src/app/api/auth/cli/code/_origin.ts @@ -0,0 +1,35 @@ +export function getLoginUrlOrigin( + req: Request, + configuredAppUrl: string, + fallbackOrigin: string, + allowLocalhost: boolean, +): string { + const configuredOrigin = getUsableOrigin(configuredAppUrl, allowLocalhost) + if (configuredOrigin) { + return configuredOrigin + } + + return getUsableOrigin(req.url, allowLocalhost) ?? fallbackOrigin +} + +function getUsableOrigin(url: string, allowLocalhost: boolean) { + try { + const parsedUrl = new URL(url) + if (!allowLocalhost && isLocalhost(parsedUrl.hostname)) { + return null + } + return parsedUrl.origin + } catch { + return null + } +} + +function isLocalhost(hostname: string) { + const normalizedHostname = hostname.replace(/^\[|\]$/g, '') + return ( + normalizedHostname === 'localhost' || + normalizedHostname === '127.0.0.1' || + normalizedHostname === '0.0.0.0' || + normalizedHostname === '::1' + ) +} diff --git a/freebuff/web/src/app/api/auth/cli/code/route.ts b/freebuff/web/src/app/api/auth/cli/code/route.ts new file mode 100644 index 0000000000..734d5e4e01 --- /dev/null +++ b/freebuff/web/src/app/api/auth/cli/code/route.ts @@ -0,0 +1,121 @@ +import { randomBytes } from 'node:crypto' + +import { genAuthCode } from '@codebuff/common/util/credentials' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { env } from '@codebuff/internal/env' +import { and, eq, gt } from 'drizzle-orm' +import { NextResponse } from 'next/server' +import { z } from 'zod/v4' + +import { + buildCliAuthCode, + getCliAuthCodeHashPrefix, + getCliAuthCodeTokenIdentifier, +} from '@/app/onboard/_helpers' +import { logger } from '@/util/logger' + +import { getLoginUrlOrigin } from './_origin' + +export async function POST(req: Request) { + const reqSchema = z.object({ + fingerprintId: z.string(), + }) + const requestBody = await req.json() + const result = reqSchema.safeParse(requestBody) + if (!result.success) { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + const { fingerprintId } = result.data + + try { + const expiresAt = Date.now() + 60 * 60 * 1000 // 1 hour + const fingerprintHash = genAuthCode( + fingerprintId, + expiresAt.toString(), + env.NEXTAUTH_SECRET, + ) + + const existingSession = await db + .select({ + userId: schema.session.userId, + expires: schema.session.expires, + }) + .from(schema.session) + .where( + and( + eq(schema.session.fingerprint_id, fingerprintId), + gt(schema.session.expires, new Date()), + ), + ) + .limit(1) + + if (existingSession.length > 0) { + logger.info( + { + fingerprintId, + existingUserId: existingSession[0].userId, + event: 'relogin_attempt_with_active_session', + }, + 'Login attempt for fingerprint with active session', + ) + } + + const authCode = buildCliAuthCode( + fingerprintId, + expiresAt.toString(), + fingerprintHash, + ) + const loginToken = randomBytes(32).toString('base64url') + + await db.insert(schema.verificationToken).values({ + identifier: getCliAuthCodeTokenIdentifier(loginToken), + token: authCode, + expires: new Date(expiresAt), + }) + + const loginUrl = new URL( + '/login', + getLoginUrlOrigin( + req, + env.NEXT_PUBLIC_CODEBUFF_APP_URL, + 'https://freebuff.com', + env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod', + ), + ) + loginUrl.searchParams.set('auth_code', loginToken) + + logger.info( + { + authCodeTokenHashPrefix: getCliAuthCodeHashPrefix(loginToken), + authCodeTokenLength: loginToken.length, + fingerprintIdPrefix: fingerprintId.slice(0, 24), + fingerprintIdLength: fingerprintId.length, + expiresAt, + loginUrlOrigin: loginUrl.origin, + requestOrigin: new URL(req.url).origin, + requestHost: req.headers.get('host'), + forwardedHost: req.headers.get('x-forwarded-host'), + forwardedProto: req.headers.get('x-forwarded-proto'), + originHeader: req.headers.get('origin'), + configuredAppUrl: env.NEXT_PUBLIC_CODEBUFF_APP_URL, + environment: env.NEXT_PUBLIC_CB_ENVIRONMENT, + }, + 'Issued Freebuff CLI auth code token', + ) + + return NextResponse.json({ + fingerprintId, + fingerprintHash, + loginUrl: loginUrl.toString(), + expiresAt, + }) + } catch (error) { + logger.error({ error }, 'Error generating login code') + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/freebuff/web/src/app/api/auth/cli/status/_db.ts b/freebuff/web/src/app/api/auth/cli/status/_db.ts new file mode 100644 index 0000000000..49cbb04b5c --- /dev/null +++ b/freebuff/web/src/app/api/auth/cli/status/_db.ts @@ -0,0 +1,44 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { and, eq, gt } from 'drizzle-orm' + +export interface LoginStatusUser { + id: string + email: string | null + name: string | null + authToken: string +} + +export interface LoginStatusDb { + getCliSessionForAuth( + fingerprintId: string, + fingerprintHash: string, + ): Promise +} + +export function createLoginStatusDb(): LoginStatusDb { + return { + getCliSessionForAuth: async (fingerprintId, fingerprintHash) => { + const users = await db + .select({ + id: schema.user.id, + email: schema.user.email, + name: schema.user.name, + authToken: schema.session.sessionToken, + }) + .from(schema.session) + .innerJoin(schema.user, eq(schema.session.userId, schema.user.id)) + .where( + and( + eq(schema.session.fingerprint_id, fingerprintId), + eq(schema.session.cli_auth_hash, fingerprintHash), + eq(schema.session.type, 'cli'), + gt(schema.session.expires, new Date()), + ), + ) + .limit(1) + + return users[0] ?? null + }, + } +} diff --git a/freebuff/web/src/app/api/auth/cli/status/_get.ts b/freebuff/web/src/app/api/auth/cli/status/_get.ts new file mode 100644 index 0000000000..9816e2780d --- /dev/null +++ b/freebuff/web/src/app/api/auth/cli/status/_get.ts @@ -0,0 +1,101 @@ +import { genAuthCode } from '@codebuff/common/util/credentials' +import { NextResponse } from 'next/server' +import { z } from 'zod/v4' + +import type { LoginStatusDb } from './_db' +import type { Logger } from '@codebuff/common/types/contracts/logger' + +export type { LoginStatusDb } from './_db' + +interface GetLoginStatusDeps { + req: Request + db: LoginStatusDb + logger: Logger + secret: string + now?: () => number +} + +const reqSchema = z.object({ + fingerprintId: z.string(), + fingerprintHash: z.string(), + expiresAt: z.coerce.number().finite().int().positive(), +}) + +export async function getLoginStatus({ + req, + db, + logger, + secret, + now = Date.now, +}: GetLoginStatusDeps): Promise { + const { searchParams } = new URL(req.url) + const result = reqSchema.safeParse({ + fingerprintId: searchParams.get('fingerprintId'), + fingerprintHash: searchParams.get('fingerprintHash'), + expiresAt: searchParams.get('expiresAt'), + }) + if (!result.success) { + return NextResponse.json( + { error: 'Invalid query parameters' }, + { status: 400 }, + ) + } + + const { fingerprintId, fingerprintHash, expiresAt } = result.data + + if (now() > expiresAt) { + logger.info( + { fingerprintId, fingerprintHash, expiresAt }, + 'Auth code expired', + ) + return NextResponse.json( + { error: 'Authentication failed' }, + { status: 401 }, + ) + } + + const expectedHash = genAuthCode(fingerprintId, expiresAt.toString(), secret) + if (fingerprintHash !== expectedHash) { + logger.info( + { fingerprintId, fingerprintHash, expectedHash }, + 'Invalid auth code', + ) + return NextResponse.json( + { error: 'Authentication failed' }, + { status: 401 }, + ) + } + + try { + const user = await db.getCliSessionForAuth(fingerprintId, fingerprintHash) + + if (!user) { + logger.info( + { fingerprintId, fingerprintHash }, + 'No active CLI session found for login auth code', + ) + return NextResponse.json( + { error: 'Authentication failed' }, + { status: 401 }, + ) + } + + return NextResponse.json({ + user: { + id: user.id, + name: user.name, + email: user.email, + authToken: user.authToken, + fingerprintId, + fingerprintHash, + }, + message: 'Authentication successful!', + }) + } catch (error) { + logger.error({ error }, 'Error checking login status') + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ) + } +} diff --git a/freebuff/web/src/app/api/auth/cli/status/route.ts b/freebuff/web/src/app/api/auth/cli/status/route.ts new file mode 100644 index 0000000000..bba1274b7c --- /dev/null +++ b/freebuff/web/src/app/api/auth/cli/status/route.ts @@ -0,0 +1,14 @@ +import { env } from '@codebuff/internal/env' + +import { createLoginStatusDb } from './_db' +import { getLoginStatus } from './_get' +import { logger } from '@/util/logger' + +export async function GET(req: Request) { + return getLoginStatus({ + req, + db: createLoginStatusDb(), + logger, + secret: env.NEXTAUTH_SECRET, + }) +} diff --git a/freebuff/web/src/app/api/live/route.ts b/freebuff/web/src/app/api/live/route.ts new file mode 100644 index 0000000000..16f33a0dbd --- /dev/null +++ b/freebuff/web/src/app/api/live/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server' + +import { getFreebuffLiveStats } from '@/server/live-stats' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export async function GET() { + const stats = await getFreebuffLiveStats() + return NextResponse.json(stats, { + headers: { + 'Cache-Control': + 'public, max-age=0, s-maxage=60, stale-while-revalidate=30', + }, + }) +} diff --git a/freebuff/web/src/app/get-started/get-started-client.tsx b/freebuff/web/src/app/get-started/get-started-client.tsx new file mode 100644 index 0000000000..f4f98e72a1 --- /dev/null +++ b/freebuff/web/src/app/get-started/get-started-client.tsx @@ -0,0 +1,333 @@ +'use client' + +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { AnimatePresence, motion } from 'framer-motion' +import { + ChevronDown, + ChevronUp, + ExternalLink, + Rocket, +} from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import posthog from 'posthog-js' +import { useEffect, useState } from 'react' + +import { BackgroundBeams } from '@/components/background-beams' +import { CopyButton } from '@/components/copy-button' +import { HeroGrid } from '@/components/hero-grid' +import { Icons } from '@/components/icons' +import { cn } from '@/lib/utils' + +const INSTALL_COMMAND = 'npm install -g freebuff' + +const editors = [ + { name: 'VS Code', icon: '/logos/visual-studio.png' }, + { name: 'Cursor', icon: '/logos/cursor.png' }, + { + name: 'IntelliJ', + icon: '/logos/intellij.png', + needsWhiteBg: true, + }, + { + name: "Good ol' Terminal", + icon: '/logos/terminal.svg', + }, +] + +type OS = 'windows' | 'macos' | 'linux' + +const detectOS = (): OS => { + if (typeof window !== 'undefined') { + const userAgent = window.navigator.userAgent.toLowerCase() + if (userAgent.includes('mac')) return 'macos' + if (userAgent.includes('win')) return 'windows' + } + return 'linux' +} + +function StepBadge({ number }: { number: number }) { + return ( +
+ {number} +
+ ) +} + +function StepContainer({ + children, + isLast = false, +}: { + children: React.ReactNode + isLast?: boolean +}) { + return ( + + {!isLast && ( +
+ )} + {children} + + ) +} + +function CommandBlock({ command }: { command: string }) { + return ( +
+ + {command} + + +
+ ) +} + +interface GetStartedClientProps { + referrerName: string | null +} + +export default function GetStartedClient({ + referrerName, +}: GetStartedClientProps) { + const [os, setOs] = useState('linux') + const [helpExpanded, setHelpExpanded] = useState(false) + + useEffect(() => { + setOs(detectOS()) + posthog.capture(AnalyticsEvent.FREEBUFF_GET_STARTED_VIEWED, { + referrer: referrerName, + }) + if (referrerName) { + localStorage.setItem('freebuff_referrer', referrerName) + } + }, [referrerName]) + + return ( +
+ {/* Background layers */} +
+
+ + + + {/* Main content */} +
+
+
+ {/* Header */} + +

+ {referrerName + ? `${referrerName} invited you to try Freebuff!` + : 'Welcome to Freebuff! 🎉'} +

+

+ {referrerName + ? 'Get set up in under a minute — it\'s completely free.' + : 'The free coding agent. Get set up in under a minute.'} +

+
+ + {/* Steps */} +
+ {/* Step 1: Install */} + +
+ +
+

Install Freebuff

+ + + {/* Collapsible help */} +
+ + + {helpExpanded && ( + +
+
+

+ Open your IDE or Terminal +

+

+ Choose your preferred development + environment: +

+
+ {editors.map((editor) => ( + + ))} +
+
+ +
+
+

+ + Check your Node.js installation: + {' '} + Open your terminal and run: +

+
+ + node --version + +
+
+
+ + {os === 'windows' && ( +
+

+ Windows users: You may need + to run your terminal as Administrator for + global npm installs. +

+
+ )} + +
+

+ Need Node.js? +

+ + Download Node.js{' '} + + +
+
+
+ )} +
+
+
+
+
+ + {/* Step 2: Navigate to project */} + +
+ +
+

+ Navigate to your project +

+

+ Open any terminal and cd{' '} + into the project you want to work on. +

+ +
+
+
+ + {/* Step 3: Run Freebuff */} + +
+ +
+

Run Freebuff

+

+ That's it — start chatting with the AI to build + faster. +

+ +
+
+
+
+ + {/* Footer */} + +
+ +

+ No subscription needed. No configuration. Just works. +

+
+
+
+
+
+
+ ) +} diff --git a/freebuff/web/src/app/get-started/page.tsx b/freebuff/web/src/app/get-started/page.tsx new file mode 100644 index 0000000000..3ae797f624 --- /dev/null +++ b/freebuff/web/src/app/get-started/page.tsx @@ -0,0 +1,39 @@ +import GetStartedClient from './get-started-client' + +import type { Metadata } from 'next' + +import { siteConfig } from '@/lib/constant' + +function normalizeReferrer(raw: string | undefined): string | null { + if (!raw) return null + const trimmed = raw.trim().slice(0, 50) + return trimmed || null +} + +export async function generateMetadata({ + searchParams, +}: { + searchParams: Promise<{ referrer?: string }> +}): Promise { + const resolvedSearchParams = await searchParams + const referrerName = normalizeReferrer(resolvedSearchParams.referrer) + const title = referrerName + ? `${referrerName} invited you to try Freebuff!` + : 'Get Started with Freebuff' + + return { + title, + description: siteConfig.description, + } +} + +export default async function GetStartedPage({ + searchParams, +}: { + searchParams: Promise<{ referrer?: string }> +}) { + const resolvedSearchParams = await searchParams + const referrerName = normalizeReferrer(resolvedSearchParams.referrer) + + return +} diff --git a/freebuff/web/src/app/global-error.tsx b/freebuff/web/src/app/global-error.tsx new file mode 100644 index 0000000000..cb81e33fa1 --- /dev/null +++ b/freebuff/web/src/app/global-error.tsx @@ -0,0 +1,25 @@ +'use client' + +export default function GlobalError({ + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return ( + + +
+

500

+

Something went wrong

+ +
+ + + ) +} diff --git a/freebuff/web/src/app/home-client.tsx b/freebuff/web/src/app/home-client.tsx new file mode 100644 index 0000000000..c24fac2092 --- /dev/null +++ b/freebuff/web/src/app/home-client.tsx @@ -0,0 +1,575 @@ +'use client' + +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { AnimatePresence, motion } from 'framer-motion' +import { Check, ChevronDown, Copy } from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import posthog from 'posthog-js' +import { useMemo, useState } from 'react' + +import { BackgroundBeams } from '@/components/background-beams' +import { CopyButton } from '@/components/copy-button' +import { HeroGrid } from '@/components/hero-grid' +import { Icons } from '@/components/icons' +import { cn } from '@/lib/utils' +import { HomepageLiveStats } from './live/live-summary' + +const INSTALL_COMMAND = 'npm install -g freebuff' + +const headlineWords = ['The', 'free', 'coding', 'agent'] + +const faqs = [ + { + question: 'How can it be free?', + answer: 'Freebuff is supported by text ads shown in the CLI.', + }, + { + question: 'What models do you use?', + answer: + 'In full mode, you can choose from:\n\n- DeepSeek V4 Pro: smartest. Its API collects data for training.\n- Kimi K2.6: balanced.\n- DeepSeek V4 Flash: most efficient. Its API also collects data for training.\n- MiniMax M2.7: fastest.\n\nLimited mode uses DeepSeek V4 Flash only.\n\nAlso, Gemini 3.1 Flash Lite handles file finding and research. Connect your ChatGPT subscription to unlock GPT-5.4 for deep thinking.', + }, + { + question: 'Which countries is Freebuff available in?', + answer: + 'All countries. Freebuff is available in "full" or "limited" mode. The following countries have full access:\n\nUnited States, Canada, United Kingdom, Australia, New Zealand, Norway, Sweden, Netherlands, Denmark, Germany, France, Italy, Spain, Portugal, Finland, Belgium, Luxembourg, Liechtenstein, Switzerland, Austria, Singapore, Malta, Israel, Ireland, and Iceland.\n\nIf you are outside those countries or using a VPN, Freebuff still works in limited mode.', + }, + { + question: 'What is limited mode?', + answer: + 'Limited mode lets you use Freebuff outside the full-access countries, or while using a VPN. It includes DeepSeek V4 Flash only, with 5 one-hour sessions per day.', + }, + { + question: 'Are you training on my data?', + answer: + "No. We do not share your data with third parties that would train on it or use it for another purpose, unless you choose a model clearly labeled as 'Collects data for training'.", + }, + { + question: 'What data do you store?', + answer: + "We don't store your codebase. We only collect minimal logs for debugging purposes.", + }, + { + question: 'What else is cool in Freebuff?', + answer: `Freebuff comes with 9 specialized subagents: +- file-picker finds relevant files across your codebase +- code-reviewer gives critical feedback on your changes +- browser-use lets the AI control a real browser to test your app +- thinker-gpt does deep reasoning (connect your ChatGPT subscription) +- and more. + +After every response, it generates 3 clickable follow-up suggestions so you always know what to do next. + +For big tasks, try the commands /interview → /plan → (implement) → /review to go from idea to polished code.`, + }, +] + +const setupSteps = [ + { + label: 'Open your terminal', + description: + 'Use any terminal — within VS Code, plain terminal, PowerShell, etc.', + }, + { + label: 'Navigate to your project', + command: 'cd /path/to/your-repo', + }, + { + label: 'Install Freebuff', + command: 'npm install -g freebuff', + }, + { + label: 'Run Freebuff', + command: 'freebuff', + }, +] + +function SetupGuide() { + const [isOpen, setIsOpen] = useState(false) + + return ( +
+ + + + {isOpen && ( + +
+
    + {setupSteps.map((step, i) => ( +
  1. + + {i + 1} + +
    +

    + {step.label} +

    + {'description' in step && step.description && ( +

    + {step.description} +

    + )} + {'command' in step && step.command && ( +
    + + {step.command} + + +
    + )} +
    +
  2. + ))} +
+
+
+ )} +
+
+ ) +} + +const PARTICLE_COUNT = 14 + +function InstallCommand({ className }: { className?: string }) { + const [copied, setCopied] = useState(false) + const [copyCount, setCopyCount] = useState(0) + + const particles = useMemo( + () => + Array.from({ length: PARTICLE_COUNT }).map((_, i) => ({ + angle: (i / PARTICLE_COUNT) * 360 + (Math.random() - 0.5) * 25, + distance: 35 + Math.random() * 35, + size: 3 + Math.random() * 4, + durationExtra: Math.random() * 0.3, + })), + [copyCount], + ) + + const handleCopy = () => { + navigator.clipboard.writeText(INSTALL_COMMAND) + setCopied(true) + setCopyCount((c) => c + 1) + posthog.capture(AnalyticsEvent.FREEBUFF_HOME_INSTALL_COMMAND_COPIED) + setTimeout(() => setCopied(false), 1800) + } + + return ( +
+
+ $ + + {INSTALL_COMMAND} + + +
+ + {/* Celebration particles */} + + {copied && + particles.map((p, i) => { + const rad = (p.angle * Math.PI) / 180 + return ( + + ) + })} + +
+ ) +} + +function FAQList() { + const [openIndex, setOpenIndex] = useState(null) + + return ( +
+ {faqs.map((faq, i) => { + const isOpen = openIndex === i + return ( + + + + {isOpen && ( + +
+ +
+

+ {faq.answer} +

+
+
+
+ )} +
+
+ ) + })} +
+ ) +} + +const PHILOSOPHY_WORDS = [ + { word: 'SIMPLE', description: 'No modes. No config. Just works.' }, + { + word: 'FAST', + description: '2–5x speed up via fast models and quick context gathering.', + }, + { + word: 'LOADED', + description: + '9 specialized subagents: code review, browser use, deep thinking with your ChatGPT subscription, and more.', + }, +] + +function PhilosophySection() { + const [litWords, setLitWords] = useState>(new Set()) + + const lightUp = (i: number) => { + setLitWords((prev) => { + const next = new Set(prev) + next.add(i) + return next + }) + } + + const dimDown = (i: number) => { + setLitWords((prev) => { + const next = new Set(prev) + next.delete(i) + return next + }) + } + + return ( +
+ {PHILOSOPHY_WORDS.map((item, i) => ( + + lightUp(i)} + onViewportLeave={() => dimDown(i)} + viewport={{ margin: '0px 0px -50% 0px' }} + className={cn( + 'font-dm-mono text-7xl md:text-[8rem] lg:text-[6rem] xl:text-[8rem] font-medium leading-[0.85] tracking-tighter select-none transition-all duration-500', + litWords.has(i) ? 'keyword-filled' : 'keyword-hollow', + )} + > + {item.word} + +

+ {item.description} +

+
+ ))} +
+ ) +} + +const wordVariant = { + initial: { opacity: 0, y: 30, filter: 'blur(8px)' }, + animate: { + opacity: 1, + y: 0, + filter: 'blur(0px)', + transition: { + duration: 0.6, + ease: [0.165, 0.84, 0.44, 1], + }, + }, +} + +export default function HomeClient() { + return ( +
+ {/* ─── Hero + Philosophy: unified section ─── */} +
+ {/* Shared layered backgrounds */} +
+
+
+ + + + + {/* Inline nav overlay */} + + + Freebuff + + freebuff + + + + + + + {/* Hero content */} +
+ {/* Headline with staggered word animation */} + + + {headlineWords.map((word, i) => ( + + {word} + + ))} + + + + {/* Subheadline */} + + No subscription. No configuration. Start in seconds. + + + {/* Install command */} + + + + + + + +
+ + {/* ─── Philosophy + FAQ: side-by-side on large screens ─── */} +
+
+ {/* Philosophy — left side */} +
+ +
+ + {/* FAQ — right side (sticky on lg) */} +
+ +

FAQ

+
+ + +
+
+
+
+ + +
+ ) +} diff --git a/freebuff/web/src/app/layout.tsx b/freebuff/web/src/app/layout.tsx new file mode 100644 index 0000000000..5b753be959 --- /dev/null +++ b/freebuff/web/src/app/layout.tsx @@ -0,0 +1,68 @@ +import '@/styles/globals.css' + +import type { Metadata } from 'next' + +import { Footer } from '@/components/footer' +import { ReferrerTracker } from '@/components/referrer-tracker' +import { ThemeProvider } from '@/components/theme-provider' +import { siteConfig } from '@/lib/constant' +import { fonts } from '@/lib/fonts' +import { PostHogProvider } from '@/lib/PostHogProvider' +import SessionProvider from '@/lib/SessionProvider' +import { cn } from '@/lib/utils' + +export const generateMetadata = (): Metadata => ({ + metadataBase: new URL(siteConfig.url()), + title: { + default: siteConfig.title, + template: `%s | ${siteConfig.title}`, + }, + description: siteConfig.description, + keywords: siteConfig.keywords(), + robots: { index: true, follow: true }, + icons: { + icon: '/favicon/favicon-32x32.ico', + shortcut: '/favicon/favicon-16x16.ico', + apple: '/favicon/apple-touch-icon.png', + }, + openGraph: { + url: siteConfig.url(), + title: siteConfig.title, + description: siteConfig.description, + siteName: siteConfig.title, + type: 'website', + locale: 'en', + }, + twitter: { + card: 'summary_large_image', + title: siteConfig.title, + description: siteConfig.description, + }, +}) + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + + + +
{children}
+