diff --git a/.agent/implementation_plan.md b/.agent/implementation_plan.md new file mode 100644 index 0000000..c2dba02 --- /dev/null +++ b/.agent/implementation_plan.md @@ -0,0 +1,100 @@ +# TUI 5 Feature Enhancements - Implementation Plan + +## Overview +Implementing 5 features inspired by Mini-Agent concepts, written as **100% original code** for TUI 5's React Ink architecture. + +--- + +## Phase 1: Persistent Session Memory 🥇 + +### Files to Create +- `lib/session-memory.mjs` - SessionMemory class + +### Implementation +```javascript +// lib/session-memory.mjs +class SessionMemory { + constructor() { + this.memoryFile = '.openqode-memory.json'; + this.facts = []; + } + + async load() { /* Load from JSON file */ } + async save() { /* Save to JSON file */ } + async remember(fact) { /* Add fact with timestamp */ } + async forget(index) { /* Remove fact by index */ } + getContext() { /* Return facts as system prompt addition */ } +} +``` + +### Commands +- `/remember ` - Save important context +- `/forget ` - Remove a remembered fact +- `/memory` - Show all remembered facts + +--- + +## Phase 2: Intelligent Context Summarization 🥈 + +### Files to Create +- `lib/context-manager.mjs` - ContextManager class + +### Implementation +```javascript +class ContextManager { + constructor(tokenLimit = 100000) { + this.tokenLimit = tokenLimit; + } + + countTokens(text) { /* Estimate tokens */ } + shouldSummarize(messages) { /* Check if > 50% limit */ } + async summarize(messages) { /* Call AI to summarize old messages */ } +} +``` + +### UI Indicator +Show `[Context: 45%]` in stats panel + +--- + +## Phase 3: Skills Library 🥉 + +### Files to Create +- `skills/index.mjs` - Skills registry +- `skills/definitions/` - Individual skill files + +### Built-in Skills +| Skill | Description | +|-------|-------------| +| `pdf` | Generate PDF documentation | +| `test` | Create unit tests | +| `review` | Code review analysis | +| `docs` | Generate documentation | +| `refactor` | Suggest refactoring | + +### Commands +- `/skills` - List available skills +- `/skill ` - Execute a skill + +--- + +## Phase 4: Request Logging + +### Implementation +- Add `--debug` CLI flag +- Create `.openqode-debug.log` +- Log API calls with timestamps +- `/debug` toggle command + +--- + +## Phase 5: MCP Support (Future) + +Research and design phase - defer to later sprint. + +--- + +## Verification +- Test each feature in isolation +- Verify no regressions in existing functionality +- Push to GitHub after each phase diff --git a/.opencode/implementation_plan.md b/.opencode/implementation_plan.md new file mode 100644 index 0000000..dc9df09 --- /dev/null +++ b/.opencode/implementation_plan.md @@ -0,0 +1,39 @@ +# Implementation Plan - Integrating Enhanced Agent Communication + +## Goal Description +Integrate the new `agent-prompt.mjs` module (concise, direct, informative patterns) into the OpenQode TUI. Refactor `server.js` (if applicable) and primarily `bin/opencode-ink.mjs` and `qwen-oauth.mjs` to support dynamic system prompt injection and robust retry mechanisms for API calls. + +## User Review Required +> [!IMPORTANT] +> The `qwen-oauth.mjs` `sendMessage` signature will be updated to accept `systemPrompt` as a 5th argument. This is a non-breaking change as it defaults to null, but ensures future compatibility. + +## Proposed Changes + +### Core Logic + +#### [MODIFY] [qwen-oauth.mjs](file:///e:/TRAE%20Playground/Test%20Ideas/OpenQode-v1.01-Preview/qwen-oauth.mjs) +- Update `sendMessage` to accept `systemPrompt` as the 5th argument. +- Use the provided `systemPrompt` instead of the hardcoded `systemContext`. +- Import `fetchWithRetry` from `lib/retry-handler.mjs` (module import). +- Wrap `sendVisionMessage`'s `fetch` call with `fetchWithRetry`. + +#### [MODIFY] [bin/opencode-ink.mjs](file:///e:/TRAE%20Playground/Test%20Ideas/OpenQode-v1.01-Preview/bin/opencode-ink.mjs) +- Import `getSystemPrompt` from `../lib/agent-prompt.mjs`. +- Import `fetchWithRetry` from `../lib/retry-handler.mjs` (for `callOpenCodeFree`). +- In `handleSubmit`: + - Gather context (CWD, project context, memories). + - Call `getSystemPrompt({ capabilities, cwd, context, projectContext })` to generate the cleaner prompt. + - Pass this `systemPrompt` to `qwen.sendMessage` as the 5th argument. + - PASS ONLY the user request (and maybe immediate context like "clipboard content") as the message content, removing the manual prompt concatenation. +- In `callOpenCodeFree`: + - Use `fetchWithRetry` instead of raw `fetch`. + +## Verification Plan + +### Automated Tests +- None available for TUI interaction. + +### Manual Verification +1. **System Prompt Check**: Send a message like "create a file test.txt". Verify the agent responds concisely (OpenCode style) and uses the correct code block format, proving `getSystemPrompt` was used. +2. **Retry Check**: Disconnect internet (if possible) or simulate a timeout to verify `fetchWithRetry` logs attempts and handles failure gracefully. +3. **Vision Check**: Send an image command (if possible via TUI) to verify `sendVisionMessage` still works with retry. diff --git a/.opencode/task.md b/.opencode/task.md new file mode 100644 index 0000000..378cd55 --- /dev/null +++ b/.opencode/task.md @@ -0,0 +1,20 @@ +# Task: Enhance AI Communication Patterns + +## Objectives +- [x] Integrate `agent-prompt.mjs` for dynamic system prompts +- [x] Implement `fetchWithRetry` for robust API calls +- [x] Enhance TUI message rendering with `message-renderer.mjs` formatters + +## Progress +- [x] Create Implementation Plan +- [x] Backup `qwen-oauth.mjs` and `bin/opencode-ink.mjs` +- [x] Update `qwen-oauth.mjs`: + - [x] Import `fetchWithRetry` + - [x] Add `systemPrompt` support to `sendMessage` + - [x] Wrap `sendVisionMessage` with retry logic +- [x] Update `bin/opencode-ink.mjs`: + - [x] Import `getSystemPrompt` and `fetchWithRetry` + - [x] Refactor `handleSubmit` to use dynamic system prompt + - [x] Update `callOpenCodeFree` to use `fetchWithRetry` + - [x] Apply `formatSuccess`/`formatError` to file save output +- [ ] User Verification of functionality diff --git a/.opencode/walkthrough.md b/.opencode/walkthrough.md new file mode 100644 index 0000000..288f604 --- /dev/null +++ b/.opencode/walkthrough.md @@ -0,0 +1,37 @@ +# Walkthrough: Enhanced Agent Communication + +I have successfully integrated the enhanced system prompt, retry mechanism, and TUI formatters. + +## Changes Applied + +### 1. Robust API Calls (`qwen-oauth.mjs`) +- **Retry Logic**: Integrated `fetchWithRetry` for Vision API calls. +- **Dynamic System Prompt**: `sendMessage` now accepts a `systemPrompt` argument, allowing the TUI to inject context-aware instructions instead of relying on hardcoded overrides. + +### 2. TUI Logic (`bin/opencode-ink.mjs`) +- **System Prompt Injection**: `handleSubmit` now generates a clean, role-specific system prompt using `lib/agent-prompt.mjs`. +- **Stream Refactoring**: Unified the streaming callback logic for cleaner code. +- **Retry Integration**: `callOpenCodeFree` now uses `fetchWithRetry` for better resilience. +- **Visual Feedback**: File save operations now use `formatSuccess` and `formatFileOperation` for consistent, bordered output. + +## Verification Steps + +> [!IMPORTANT] +> You **MUST** restart your TUI process (`node bin/opencode-ink.mjs`) for these changes to take effect. + +1. **Restart the TUI**. +2. **Test System Prompt**: + - Send a simple greeting: "Hello". + - **Expected**: A concise, direct response (no "As an AI..." preamble). + - ask "Create a file named `demo.txt` with text 'Hello World'". + - **Expected**: The agent should generate the file using the correct code block format. +3. **Test Visual Feedback**: + - Observe the success message after file creation. + - **Expected**: A green bordered box saying "✅ Success" with the file details. +4. **Test Retry (Optional)**: + - If you can simulate a network glitch, the system should now log "Retrying...". + +## Rollback +Backups were created before applying changes: +- `qwen-oauth.mjs.bak` +- `bin/opencode-ink.mjs.bak` diff --git a/Install.ps1 b/Install.ps1 index 7d3f1e7..c67c5f4 100644 --- a/Install.ps1 +++ b/Install.ps1 @@ -1,37 +1,119 @@ -Write-Host "OpenQode Auto-Installer" -ForegroundColor Cyan -Write-Host "-----------------------" -ForegroundColor Cyan +# OpenQode Auto-Installer for Windows (PowerShell) +# Noob-proof: Auto-installs Node.js and Git if missing! + +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host " ╔═══════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host " ║ 🚀 OpenQode Auto-Installer 🚀 ║" -ForegroundColor Cyan +Write-Host " ║ Next-Gen AI Coding Assistant ║" -ForegroundColor Cyan +Write-Host " ╚═══════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" + +# Function to check if running as admin +function Test-Admin { + $currentUser = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + $currentUser.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +# Function to install winget package +function Install-WingetPackage($PackageId, $Name) { + Write-Host "[*] Installing $Name..." -ForegroundColor Yellow + try { + winget install --id $PackageId --accept-package-agreements --accept-source-agreements -e + if ($LASTEXITCODE -eq 0) { + Write-Host "[✓] $Name installed successfully!" -ForegroundColor Green + # Refresh PATH + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + return $true + } + } catch { + Write-Host "[!] winget failed, trying alternative method..." -ForegroundColor Yellow + } + return $false +} # Check for Git +Write-Host "[1/4] Checking for Git..." -ForegroundColor Cyan if (!(Get-Command git -ErrorAction SilentlyContinue)) { - Write-Host "Error: Git is not installed." -ForegroundColor Red - Write-Host "Please install Git: https://git-scm.com/download/win" - exit + Write-Host "[!] Git not found. Installing..." -ForegroundColor Yellow + $installed = Install-WingetPackage "Git.Git" "Git" + if (!$installed) { + Write-Host "[!] Attempting direct download..." -ForegroundColor Yellow + $gitInstaller = "$env:TEMP\git-installer.exe" + Invoke-WebRequest -Uri "https://github.com/git-for-windows/git/releases/download/v2.43.0.windows.1/Git-2.43.0-64-bit.exe" -OutFile $gitInstaller + Start-Process -FilePath $gitInstaller -Args "/VERYSILENT /NORESTART" -Wait + Remove-Item $gitInstaller -Force + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + } + if (!(Get-Command git -ErrorAction SilentlyContinue)) { + Write-Host "[X] Failed to install Git. Please install manually: https://git-scm.com/download/win" -ForegroundColor Red + exit 1 + } } +Write-Host "[✓] Git is installed!" -ForegroundColor Green -# Check for Node +# Check for Node.js +Write-Host "[2/4] Checking for Node.js..." -ForegroundColor Cyan if (!(Get-Command node -ErrorAction SilentlyContinue)) { - Write-Host "Error: Node.js is not installed." -ForegroundColor Red - Write-Host "Please install Node.js: https://nodejs.org/" - exit + Write-Host "[!] Node.js not found. Installing..." -ForegroundColor Yellow + $installed = Install-WingetPackage "OpenJS.NodeJS.LTS" "Node.js LTS" + if (!$installed) { + Write-Host "[!] Attempting direct download..." -ForegroundColor Yellow + $nodeInstaller = "$env:TEMP\node-installer.msi" + Invoke-WebRequest -Uri "https://nodejs.org/dist/v20.10.0/node-v20.10.0-x64.msi" -OutFile $nodeInstaller + Start-Process msiexec.exe -Args "/i `"$nodeInstaller`" /qn ADDLOCAL=ALL" -Wait + Remove-Item $nodeInstaller -Force + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") + } + if (!(Get-Command node -ErrorAction SilentlyContinue)) { + Write-Host "[X] Failed to install Node.js. Please install manually: https://nodejs.org/" -ForegroundColor Red + exit 1 + } } +$nodeVer = node --version +Write-Host "[✓] Node.js $nodeVer is installed!" -ForegroundColor Green +# Clone or update repository $repoUrl = "https://github.com/roman-ryzenadvanced/OpenQode-Public-Alpha.git" $targetDir = "OpenQode" +Write-Host "[3/4] Setting up OpenQode..." -ForegroundColor Cyan if (Test-Path $targetDir) { - Write-Host "Directory '$targetDir' already exists. Updating..." -ForegroundColor Yellow + Write-Host "[*] Directory exists. Updating..." -ForegroundColor Yellow Push-Location $targetDir - git pull + git pull --ff-only Pop-Location } else { - Write-Host "Cloning repository..." -ForegroundColor Yellow + Write-Host "[*] Cloning repository..." -ForegroundColor Yellow git clone $repoUrl $targetDir } +# Install npm dependencies (clean install to ensure React overrides work) Set-Location $targetDir +Write-Host "[4/4] Installing dependencies..." -ForegroundColor Cyan + +# Clean existing node_modules to ensure React overrides take effect +if (Test-Path "node_modules") { + Write-Host "[*] Cleaning existing dependencies for fresh install..." -ForegroundColor Yellow + Remove-Item -Recurse -Force "node_modules" -ErrorAction SilentlyContinue + Remove-Item -Force "package-lock.json" -ErrorAction SilentlyContinue +} -Write-Host "Installing dependencies..." -ForegroundColor Yellow npm install --legacy-peer-deps +if ($LASTEXITCODE -ne 0) { + Write-Host "[!] npm install failed, retrying..." -ForegroundColor Yellow + npm cache clean --force + npm install --legacy-peer-deps +} -Write-Host "Installation complete! Launching..." -ForegroundColor Green +Write-Host "" +Write-Host " ╔═══════════════════════════════════════════╗" -ForegroundColor Green +Write-Host " ║ ✅ Installation Complete! ✅ ║" -ForegroundColor Green +Write-Host " ║ ║" -ForegroundColor Green +Write-Host " ║ Launching OpenQode Next-Gen TUI... ║" -ForegroundColor Green +Write-Host " ╚═══════════════════════════════════════════╝" -ForegroundColor Green +Write-Host "" + +# Launch .\OpenQode.bat diff --git a/README.md b/README.md index cd456e1..16ab13d 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,12 @@ We recommend starting with **Next-Gen (Option 5)**! OpenQode Gen 5 introduces a powerful suite of intelligent automation features designed for maximum productivity: +### 🧠 Core Intelligence v2 +- **Dynamic System Prompting**: Context-aware prompts that adapt to your project, OS, and active agents. +- **Robust API Resilience**: Enhanced retry mechanisms for Vision API and network instability. +- **Smart TUI Formatters**: Cleaner, bordered output for file operations and success messages. + + ![Features Panel](assets/features-smartx.png) ### 🔀 Multi-Agent Mode (`/agents`) diff --git a/add-auto-approve.js b/add-auto-approve.js new file mode 100644 index 0000000..46ebea4 --- /dev/null +++ b/add-auto-approve.js @@ -0,0 +1,38 @@ +const fs = require('fs'); +let c = fs.readFileSync('bin/opencode-ink.mjs', 'utf8'); + +// 1. Add /auto command handler before /theme +const autoCmd = ` case '/auto': + setAutoApprove(prev => !prev); + setMessages(prev => [...prev, { + role: 'system', + content: !autoApprove ? '▶️ Auto-Approve **ENABLED** - Commands execute automatically in SOLO mode' : '⏸ Auto-Approve **DISABLED** - Commands require confirmation' + }]); + setInput(''); + return; +`; + +// Only add if not already present +if (!c.includes("case '/auto':")) { + c = c.replace(/(case '\/theme':)/g, autoCmd + ' $1'); + console.log('Added /auto command handler'); +} + +// 2. Add useEffect to auto-execute commands when autoApprove is true +const autoExecEffect = ` + // AUTO-APPROVE: Automatically execute commands in SOLO mode + useEffect(() => { + if (autoApprove && soloMode && detectedCommands.length > 0 && !isExecutingCommands) { + handleExecuteCommands(true); + } + }, [autoApprove, soloMode, detectedCommands, isExecutingCommands]); +`; + +// Insert after soloMode state declaration +if (!c.includes('AUTO-APPROVE: Automatically execute')) { + c = c.replace(/(const \[autoApprove, setAutoApprove\] = useState\(false\);[^\n]*\n)/g, '$1' + autoExecEffect); + console.log('Added auto-execute useEffect'); +} + +fs.writeFileSync('bin/opencode-ink.mjs', c); +console.log('Done!'); diff --git a/add-sidebar-tags.js b/add-sidebar-tags.js new file mode 100644 index 0000000..a105275 --- /dev/null +++ b/add-sidebar-tags.js @@ -0,0 +1,30 @@ +const fs = require('fs'); +let c = fs.readFileSync('bin/opencode-ink.mjs', 'utf8'); + +// Add SOLO and Auto-Approve indicators after the "Think" row in sidebar +const soloIndicators = ` h(Box, {}, + h(Text, { color: 'gray' }, 'SOLO: '), + soloMode + ? h(Text, { color: 'magenta', bold: true }, 'ON') + : h(Text, { color: 'gray', dimColor: true }, 'OFF') + ), + h(Box, {}, + h(Text, { color: 'gray' }, 'AutoRun:'), + autoApprove + ? h(Text, { color: 'yellow', bold: true }, 'ON') + : h(Text, { color: 'gray', dimColor: true }, 'OFF') + ),`; + +// Insert after the Think row (before the empty h(Text, {}, '') line) +if (!c.includes("h(Text, { color: 'gray' }, 'SOLO: ')")) { + c = c.replace( + /(h\(Box, \{\},\s*\n\s*h\(Text, \{ color: 'gray' \}, 'Think: '\),\s*\n\s*exposedThinking\s*\n\s*\? h\(Text, \{ color: 'green', bold: true \}, 'ON'\)\s*\n\s*: h\(Text, \{ color: 'gray', dimColor: true \}, 'OFF'\)\s*\n\s*\),)/g, + '$1\n' + soloIndicators + ); + console.log('Added SOLO and Auto-Approve indicators to sidebar'); +} else { + console.log('Indicators already present'); +} + +fs.writeFileSync('bin/opencode-ink.mjs', c); +console.log('Done!'); diff --git a/bin/opencode-ink.mjs b/bin/opencode-ink.mjs index af99698..99d9847 100644 --- a/bin/opencode-ink.mjs +++ b/bin/opencode-ink.mjs @@ -51,6 +51,7 @@ import { getContextManager } from '../lib/context-manager.mjs'; import { getAllSkills, getSkill, executeSkill, getSkillListDisplay } from '../lib/skills.mjs'; import { getDebugLogger, initFromArgs } from '../lib/debug-logger.mjs'; import { processCommand, isCommand } from '../lib/command-processor.mjs'; +import { fetchWithRetry } from '../lib/retry-handler.mjs'; import { getSystemPrompt, formatCodeBlock, @@ -455,7 +456,7 @@ const callOpenCodeFree = async (prompt, model = currentFreeModel, onChunk = null } try { - const response = await fetch(OPENCODE_FREE_API, { + const response = await fetchWithRetry(OPENCODE_FREE_API, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -3465,17 +3466,13 @@ const App = () => { try { // Build context-aware prompt with agent-specific instructions - let systemPrompt = `[SYSTEM CONTEXT] - CURRENT WORKING DIRECTORY: ${process.cwd()} - (CRITICAL: This is the ABSOLUTE SOURCE OF TRUTH. Ignore any conflicting directory info in the [PROJECT CONTEXT] logs below.) - - ` + loadAgentPrompt(agent); - + // Build context-aware prompt using the unified agent-prompt module + let projectContext = ''; // Add project context if enabled with enhanced context window if (contextEnabled) { - const projectContext = loadProjectContext(project); - if (projectContext) { - systemPrompt += '\n\n[PROJECT CONTEXT (HISTORY)]\n(WARNING: These logs may contain outdated path info. Trust SYSTEM CONTEXT CWD above over this.)\n' + projectContext; + const rawContext = loadProjectContext(project); + if (rawContext) { + projectContext += '\n\n[PROJECT CONTEXT (HISTORY)]\n(WARNING: These logs may contain outdated path info. Trust SYSTEM CONTEXT CWD above over this.)\n' + rawContext; } // Enhanced context: Include recent conversation history for better continuity @@ -3485,174 +3482,99 @@ const App = () => { const recentContext = recentMessages.map(m => `[PREVIOUS ${m.role.toUpperCase()}]: ${m.content.substring(0, 500)}` // Limit to prevent overflow ).join('\n'); - systemPrompt += `\n\n[RECENT CONVERSATION]\n${recentContext}\n(Use this for context continuity, but prioritize the current request)`; + projectContext += `\n\n[RECENT CONVERSATION]\n${recentContext}\n(Use this for context continuity, but prioritize the current request)`; } } } - // MULTI-AGENT INSTRUCTION INJECTION - if (multiAgentEnabled) { - systemPrompt += ` -[MULTI-AGENT LOGGING ENABLED] -You are capable of using multiple internal agents (Planner, Builder, Reviewer, Security). -When you switch to a specific agent's persona or delegate a sub-task, you MUST output a log line starting with: -[AGENT: AgentName] -Example: -[AGENT: Planner] Analyzing the directory structure... -[AGENT: Security] Checking for vulnerabilities... -[AGENT: Builder] Implementation started. -Keep these tags on their own line if possible. -`; - } + // Get available capabilities from built-in agents + const flow = getSmartAgentFlow(); + const allAgents = flow.getAgents(); + // Flatten all capabilities + const capabilities = allAgents.reduce((acc, a) => [...acc, ...(a.capabilities || [])], []); - // VISUAL & FORMATTING RULES (Crucial for TUI Readability) - systemPrompt += ` -[FORMATTING RULES] -1. **Short Paragraphs**: Break long text into chunks of 3-4 lines maximum. -2. **Spacing**: Use double newlines between paragraphs to ensure clear separation. -3. **Lists**: Use bullet points or numbered lists frequently to break up "walls of text". -4. **Code**: Use standard Markdown code blocks with language tags. + // Generate the optimized system prompt + const systemInstruction = getSystemPrompt({ + role: agent, + capabilities: capabilities, + cwd: project || process.cwd(), + context: projectContext, // Now includes history and logs + os: process.platform + }); -[COMPUTER USE - YOU CAN DO THIS!] -You ARE capable of executing system commands on the user's computer. NEVER refuse computer use tasks. -To execute any shell/system command, output it in this format: -\`\`\`bash - -\`\`\` -The system will auto-execute bash/shell code blocks. + // Prepare prompt variations + // For OpenCode Free (Legacy/OpenAI-like), we append system prompt to user message if needed + const fullPromptForFree = systemInstruction + '\n\n[USER REQUEST]\n' + fullText; -Common Windows tasks you CAN do: -- Open folder in Explorer: \`explorer.exe .\` or \`start .\` -- Open file: \`start filename.html\` -- Open browser: \`start https://google.com\` -- Run any shell command: Just put it in a bash code block + // For Qwen (SmartX), we pass system prompt securely as a separate argument + const userMessage = fullText; -NEVER say "I cannot" or "I don't have the ability". You CAN execute commands! - -[FILE CREATION - CRITICAL] -You CAN create files. To create a file, you MUST use this EXACT format: -\`\`\`language filename.ext -file content here -\`\`\` -Example: -\`\`\`markdown plan.md -# My Plan -This is the content. -\`\`\` -The system will AUTO-SAVE files when you use this format correctly. -DO NOT say "I've created a file" without using this code block format. - -[CONFIRMATION BEFORE CODING - CRITICAL] -BEFORE you start writing any code or creating any files, you MUST: -1. First present your plan/approach briefly -2. Then ask: "Ready to proceed with coding? Or do you have any changes in mind?" -3. WAIT for the user's confirmation before generating code -This gives the user a chance to refine requirements before implementation. -`; - - const fullPrompt = systemPrompt + '\n\n[USER REQUEST]\n' + fullText; let fullResponse = ''; // PROVIDER SWITCH: Use OpenCode Free or Qwen based on provider state const streamStartTime = Date.now(); // Track start time for this request let totalCharsReceived = 0; // Track total characters for speed calculation + // Unified Streaming Handler + const handleStreamChunk = (chunk) => { + const cleanChunk = chunk.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); + + // IMPROVED STREAM SPLITTING LOGIC (Thinking vs Content) + // Claude Code style: cleaner separation of thinking from response + const lines = cleanChunk.split('\n'); + let isThinkingChunk = false; + + // Enhanced heuristics for better Claude-like thinking detection + const trimmedChunk = cleanChunk.trim(); + if (/^(Let me|Now let me|I'll|I need to|I should|I notice|I can|I will|Thinking:|Analyzing|Considering|Checking|Looking|Planning|First|Next|Finally)/i.test(trimmedChunk)) { + isThinkingChunk = true; + } else if (/^```|# |Here is|```|```|```/i.test(trimmedChunk)) { + // If we encounter code blocks or headers, likely content not thinking + isThinkingChunk = false; + } + + // Update character count for speed calculation + totalCharsReceived += cleanChunk.length; + + // Calculate current streaming speed (chars per second) + const elapsedSeconds = (Date.now() - streamStartTime) / 1000; + const speed = elapsedSeconds > 0 ? Math.round(totalCharsReceived / elapsedSeconds) : 0; + + // GLOBAL STATS UPDATE (Run for ALL chunks) + setThinkingStats(prev => ({ + ...prev, + chars: totalCharsReceived, + speed: speed + })); + + // GLOBAL AGENT DETECTION (Run for ALL chunks) + const agentMatch = cleanChunk.match(/\[AGENT:\s*([^\]]+)\]/i); + if (agentMatch) { + setThinkingStats(prev => ({ ...prev, activeAgent: agentMatch[1].trim() })); + } + + if (isThinkingChunk) { + setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l && !/^(Let me|Now let me|I'll|I need to|I notice)/i.test(l.trim()))]); + } else { + setMessages(prev => { + const last = prev[prev.length - 1]; + if (last && last.role === 'assistant') { + return [...prev.slice(0, -1), { ...last, content: last.content + cleanChunk }]; + } + return prev; + }); + } + }; + const result = provider === 'opencode-free' - ? await callOpenCodeFree(fullPrompt, freeModel, (chunk) => { - const cleanChunk = chunk.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); - - // IMPROVED STREAM SPLITTING LOGIC (Thinking vs Content) - // Claude Code style: cleaner separation of thinking from response - const lines = cleanChunk.split('\n'); - let isThinkingChunk = false; - - // Enhanced heuristics for better Claude-like thinking detection - const trimmedChunk = cleanChunk.trim(); - if (/^(Let me|Now let me|I'll|I need to|I should|I notice|I can|I will|Thinking:|Analyzing|Considering|Checking|Looking|Planning|First|Next|Finally)/i.test(trimmedChunk)) { - isThinkingChunk = true; - } else if (/^```|# |Here is|```|```|```/i.test(trimmedChunk)) { - // If we encounter code blocks or headers, likely content not thinking - isThinkingChunk = false; - } - - // Update character count for speed calculation - totalCharsReceived += cleanChunk.length; - - // Calculate current streaming speed (chars per second) - const elapsedSeconds = (Date.now() - streamStartTime) / 1000; - const speed = elapsedSeconds > 0 ? Math.round(totalCharsReceived / elapsedSeconds) : 0; - - // GLOBAL STATS UPDATE (Run for ALL chunks) - setThinkingStats(prev => ({ - ...prev, - chars: totalCharsReceived, - speed: speed - })); - - // GLOBAL AGENT DETECTION (Run for ALL chunks) - const agentMatch = cleanChunk.match(/\[AGENT:\s*([^\]]+)\]/i); - if (agentMatch) { - setThinkingStats(prev => ({ ...prev, activeAgent: agentMatch[1].trim() })); - } - - if (isThinkingChunk) { - setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l && !/^(Let me|Now let me|I'll|I need to|I notice)/i.test(l.trim()))]); - } else { - setMessages(prev => { - const last = prev[prev.length - 1]; - if (last && last.role === 'assistant') { - return [...prev.slice(0, -1), { ...last, content: last.content + cleanChunk }]; - } - return prev; - }); - } - }) - : await getQwen().sendMessage(fullPrompt, 'qwen-coder-plus', null, (chunk) => { - const cleanChunk = chunk.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); - - // IMPROVED STREAM SPLITTING LOGIC (Thinking vs Content) - const lines = cleanChunk.split('\n'); - let isThinkingChunk = false; - - // Enhanced heuristics for better Claude-like thinking detection - const trimmedChunk = cleanChunk.trim(); - if (/^(Let me|Now let me|I'll|I need to|I should|I notice|I can|I will|Thinking:|Analyzing|Considering|Checking|Looking|Planning|First|Next|Finally)/i.test(trimmedChunk)) { - isThinkingChunk = true; - } else if (/^```|# |Here is|```|```|```/i.test(trimmedChunk)) { - // If we encounter code blocks or headers, likely content not thinking - isThinkingChunk = false; - } - - // Update character count for speed calculation (using same variable as OpenCode path) - totalCharsReceived += cleanChunk.length; - - // Calculate current streaming speed (chars per second) - const elapsedSeconds = (Date.now() - streamStartTime) / 1000; - const speed = elapsedSeconds > 0 ? Math.round(totalCharsReceived / elapsedSeconds) : 0; - - setThinkingStats(prev => ({ - ...prev, - chars: totalCharsReceived, - speed: speed - })); - - const agentMatch = cleanChunk.match(/\[AGENT:\s*([^\]]+)\]/i); - if (agentMatch) { - setThinkingStats(prev => ({ ...prev, activeAgent: agentMatch[1].trim() })); - } - - if (isThinkingChunk) { - setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l && !/^(Let me|Now let me|I'll|I need to|I notice)/i.test(l.trim()))]); - } else { - setMessages(prev => { - const last = prev[prev.length - 1]; - if (last && last.role === 'assistant') { - return [...prev.slice(0, -1), { ...last, content: last.content + cleanChunk }]; - } - return prev; - }); - } - }); + ? await callOpenCodeFree(fullPromptForFree, freeModel, handleStreamChunk) + : await getQwen().sendMessage( + userMessage, + 'qwen-coder-plus', + null, + handleStreamChunk, + systemInstruction // Pass dynamic system prompt! + ); if (result.success) { const responseText = result.response || fullResponse; @@ -3701,17 +3623,17 @@ This gives the user a chance to refine requirements before implementation. return next; }); + const successMsg = formatSuccess(`Auto-saved ${successFiles.length} file(s):\n` + successFiles.map(f => formatFileOperation(f.path, 'Saved', 'success')).join('\n')); setMessages(prev => [...prev, { role: 'system', - content: '✅ Auto-saved ' + successFiles.length + ' file(s):\n' + - successFiles.map(f => ' 📄 ' + f.path).join('\n') + content: successMsg }]); } if (failedFiles.length > 0) { + const failureMsg = formatError(`Failed to save ${failedFiles.length} file(s):\n` + failedFiles.map(f => ` ⚠️ ${f.filename}: ${f.error}`).join('\n')); setMessages(prev => [...prev, { - role: 'system', - content: '❌ Failed to save ' + failedFiles.length + ' file(s):\n' + - failedFiles.map(f => ' ⚠️ ' + f.filename + ': ' + f.error).join('\n') + role: 'error', + content: failureMsg }]); } diff --git a/bin/opencode-ink.mjs.backup b/bin/opencode-ink.mjs.backup index 0b4711b..ac1950e 100644 --- a/bin/opencode-ink.mjs.backup +++ b/bin/opencode-ink.mjs.backup @@ -41,6 +41,7 @@ import FileTree from './ui/components/FileTree.mjs'; import DiffView from './ui/components/DiffView.mjs'; import ThinkingBlock from './ui/components/ThinkingBlock.mjs'; import ChatBubble from './ui/components/ChatBubble.mjs'; +import TodoList from './ui/components/TodoList.mjs'; const { useState, useCallback, useEffect, useRef, useMemo } = React; @@ -68,11 +69,26 @@ const h = React.createElement; // ═══════════════════════════════════════════════════════════════ // CUSTOM MULTI-LINE INPUT COMPONENT -// Properly handles pasted multi-line text unlike ink-text-input +// Properly handles pasted multi-line text unlike ink-text-input with enhanced Claude Code TUI quality // ═══════════════════════════════════════════════════════════════ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = true }) => { const [cursorVisible, setCursorVisible] = useState(true); const [pastedChars, setPastedChars] = useState(0); + const [inputWidth, setInputWidth] = useState(80); // Default width + const [inputHeight, setInputHeight] = useState(1); // Track input height dynamically + + // Get terminal size for responsive input width + const [columns, rows] = useTerminalSize(); + useEffect(() => { + // Calculate input width accounting for margins and borders + const safeWidth = Math.max(20, columns - 10); // Leave margin for borders + setInputWidth(safeWidth); + + // Calculate height based on content but cap it to avoid taking too much space + const lines = value.split('\n'); + const newHeight = Math.min(Math.max(3, lines.length + 1), 10); // Min 3 lines, max 10 + setInputHeight(newHeight); + }, [columns, rows, value]); // Blink cursor useEffect(() => { @@ -84,8 +100,20 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru useInput((input, key) => { if (!isActive) return; - // Submit on Enter + // Submit on Enter (but only if not in multiline mode with Shift) if (key.return && !key.shift) { + // If we have multi-line content, require Ctrl+Enter to submit + if (value.includes('\n') && !key.ctrl) { + // Don't submit, just add a line break + return; + } + onSubmit(value); + setPastedChars(0); + return; + } + + // Ctrl+Enter for multi-line content submission + if (key.return && key.ctrl) { onSubmit(value); setPastedChars(0); return; @@ -97,6 +125,13 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru return; } + // Ctrl+V for paste (explicit paste detection) + if (key.ctrl && input.toLowerCase() === 'v') { + // This is handled by the system paste, so we just detect it + setPastedChars(value.length > 0 ? value.length * 2 : 100); // Estimate pasted chars + return; + } + // Backspace if (key.backspace || key.delete) { onChange(value.slice(0, -1)); @@ -110,19 +145,19 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru return; } - // Ignore control keys + // Ignore control keys except for specific shortcuts if (key.ctrl || key.meta) return; if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) return; // Append character(s) if (input) { - // Detect paste: if >5 chars arrive at once - if (input.length > 5) { - setPastedChars(input.length); + // Detect paste: if >5 chars arrive at once or contains newlines + if (input.length > 5 || input.includes('\n')) { + setPastedChars(input.length + (input.match(/\n/g) || []).length * 10); // Weight newlines } onChange(value + input); } - }, { isActive }); + }, [isActive, value]); // Reset paste indicator when input is cleared useEffect(() => { @@ -136,28 +171,78 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru const lineCount = lines.length; // Show paste indicator only if we detected a paste burst - if (pastedChars > 0) { + if (pastedChars > 10) { // Only show for significant pastes const indicator = lineCount > 1 - ? `[Pasted ~${lineCount} lines]` - : `[Pasted ~${pastedChars} chars]`; + ? `[Pasted: ${lineCount} lines, ${pastedChars} chars]` + : `[Pasted: ${pastedChars} chars]`; - return h(Box, { flexDirection: 'column' }, + return h(Box, { flexDirection: 'column', width: inputWidth }, h(Box, { borderStyle: 'round', borderColor: 'yellow', - paddingX: 1 + paddingX: 1, + width: inputWidth }, h(Text, { color: 'yellow', bold: true }, indicator) ), - isActive && cursorVisible ? h(Text, { backgroundColor: 'white', color: 'black' }, ' ') : null + h(Box, { + borderStyle: 'single', + borderColor: 'cyan', + paddingX: 1, + minHeight: inputHeight, + maxHeight: 10 + }, + lines.map((line, i) => + h(Text, { key: i, color: 'white', wrap: 'truncate' }, + i === lines.length - 1 && isActive && cursorVisible ? `${line}█` : line + ) + ) + ) ); } - // Normal short input - show inline - return h(Box, { flexDirection: 'row' }, - h(Text, { color: 'white' }, displayValue), - isActive && cursorVisible ? h(Text, { backgroundColor: 'white', color: 'black' }, ' ') : null, - !displayValue && placeholder ? h(Text, { dimColor: true }, placeholder) : null + // Multi-line input - render with proper height and scrolling + if (lineCount > 1 || value.length > 50) { // Show as multi-line if more than 1 line or long text + return h(Box, { + flexDirection: 'column', + width: inputWidth, + minHeight: inputHeight, + maxHeight: 10 + }, + h(Box, { + borderStyle: lineCount > 1 ? 'round' : 'single', + borderColor: 'cyan', + paddingX: 1, + flexGrow: 1, + maxHeight: inputHeight + }, + lines.map((line, i) => + h(Text, { + key: i, + color: 'white', + wrap: 'truncate', + maxWidth: inputWidth - 4 // Account for borders and padding + }, + i === lines.length - 1 && isActive && cursorVisible ? `${line}█` : line + ) + ) + ), + h(Box, { marginTop: 0.5 }, + h(Text, { color: 'gray', dimColor: true, fontSize: 0.8 }, + `${lineCount} line${lineCount > 1 ? 's' : ''} | ${value.length} chars | Shift+Enter: new line, Enter: submit`) + ) + ); + } + + // Normal single-line input - show inline with proper truncation + return h(Box, { flexDirection: 'row', width: inputWidth }, + h(Box, { borderStyle: 'single', borderColor: 'cyan', paddingX: 1, flexGrow: 1 }, + h(Text, { color: 'white', wrap: 'truncate' }, + displayValue + (isActive && cursorVisible && displayValue.length > 0 ? '█' : '') + ), + !displayValue && placeholder ? h(Text, { dimColor: true }, placeholder) : null, + isActive && !displayValue && cursorVisible ? h(Text, { backgroundColor: 'white', color: 'black' }, '█') : null + ) ); }; @@ -696,6 +781,33 @@ const parseTodos = (projectPath) => { return todos.slice(0, 20); // Limit to 20 TODOs }; +// POWER FEATURE 2: MANAGED TODO LIST +// Personal task list that users can add/maintain +// ═══════════════════════════════════════════════════════════════ +const TODO_FILE = '.opencode/todos.json'; + +const loadTodoList = (projectPath) => { + try { + const todoFilePath = path.join(projectPath || process.cwd(), TODO_FILE); + if (fs.existsSync(todoFilePath)) { + const content = fs.readFileSync(todoFilePath, 'utf8'); + return JSON.parse(content); + } + } catch (e) { /* ignore */ } + return []; +}; + +const saveTodoList = (projectPath, todos) => { + try { + const todoDir = path.join(projectPath || process.cwd(), '.opencode'); + if (!fs.existsSync(todoDir)) { + fs.mkdirSync(todoDir, { recursive: true }); + } + const todoFilePath = path.join(projectPath || process.cwd(), TODO_FILE); + fs.writeFileSync(todoFilePath, JSON.stringify(todos, null, 2)); + } catch (e) { /* ignore */ } +}; + // ═══════════════════════════════════════════════════════════════ // POWER FEATURE 2: THEME SYSTEM // Multiple color themes for the TUI @@ -938,36 +1050,52 @@ const SmoothCounter = ({ value }) => { return h(Text, { color: 'white' }, displayValue.toLocaleString()); }; -// Component: TypewriterText - Animated text reveal for streaming -const TypewriterText = ({ children, speed = 15 }) => { +// Component: TypewriterText - Clean text reveal for streaming (Opencode style) +const TypewriterText = ({ children, speed = 25 }) => { const fullText = String(children || ''); const [displayText, setDisplayText] = useState(''); - const indexRef = useRef(0); + const positionRef = useRef(0); + const timerRef = useRef(null); useEffect(() => { - if (indexRef.current >= fullText.length) return; + // Reset when text changes + setDisplayText(''); + positionRef.current = 0; - const timer = setInterval(() => { - if (indexRef.current < fullText.length) { - setDisplayText(fullText.substring(0, indexRef.current + 1)); - indexRef.current++; - } else { - clearInterval(timer); + if (timerRef.current) { + clearInterval(timerRef.current); + } + + if (!fullText) { + return; + } + + // Use a steady typing rhythm (Opencode style - consistent speed) + timerRef.current = setInterval(() => { + if (positionRef.current >= fullText.length) { + clearInterval(timerRef.current); + return; } + + // Add one character at a time for smooth flow + const nextPos = positionRef.current + 1; + const newChar = fullText.charAt(positionRef.current); + + setDisplayText(prev => prev + newChar); + positionRef.current = nextPos; }, speed); - return () => clearInterval(timer); + return () => { + if (timerRef.current) { + clearInterval(timerRef.current); + } + }; }, [fullText, speed]); - // Reset when text changes completely (new message) - useEffect(() => { - if (!fullText.startsWith(displayText.substring(0, 10))) { - indexRef.current = 0; - setDisplayText(''); - } - }, [fullText]); + // Add a simple cursor effect like opencode TUI + const displayWithCursor = displayText + (Math.floor(Date.now() / 500) % 2 ? '|' : ' '); - return h(Text, { wrap: 'wrap' }, displayText); + return h(Text, { wrap: 'wrap' }, displayWithCursor); }; // Component: FadeInBox - Animated fade-in wrapper (simulates fade with opacity chars) @@ -1039,7 +1167,9 @@ const Sidebar = ({ selectedFiles, systemStatus, thinkingStats, // RECEIVED: { chars, activeAgent } - activeModel // NEW: { name, id, isFree } - current AI model + activeModel, // NEW: { name, id, isFree } - current AI model + soloMode = false, + autoApprove = false }) => { if (width === 0) return null; @@ -1171,7 +1301,20 @@ const Sidebar = ({ ? h(Text, { color: 'green', bold: true }, 'ON') : h(Text, { color: 'gray', dimColor: true }, 'OFF') ), + h(Box, {}, + h(Text, { color: 'gray' }, 'SmartX: '), + soloMode + ? h(Text, { color: 'magenta', bold: true }, 'ON') + : h(Text, { color: 'gray', dimColor: true }, 'OFF') + ), + h(Box, {}, + h(Text, { color: 'gray' }, 'AutoRun:'), + autoApprove + ? h(Text, { color: 'yellow', bold: true }, 'ON') + : h(Text, { color: 'gray', dimColor: true }, 'OFF') + ), h(Text, {}, ''), + h(Text, { color: 'gray', dimColor: true }, 'Press TAB to'), h(Text, { color: 'gray', dimColor: true }, 'browse files') ) @@ -1263,60 +1406,70 @@ const ArtifactBlock = ({ content, isStreaming }) => { // Code blocks with header bar, language label, and distinct styling // ═══════════════════════════════════════════════════════════════ const CodeCard = ({ language, filename, content, width, isStreaming }) => { - // Language display (normalize common names) - const langMap = { - 'js': 'JavaScript', 'ts': 'TypeScript', 'py': 'Python', - 'html': 'HTML', 'css': 'CSS', 'bash': 'Shell', 'sh': 'Shell', - 'json': 'JSON', 'md': 'Markdown', 'jsx': 'React', 'tsx': 'React' - }; - const displayLang = langMap[language] || language || 'Code'; - const displayTitle = filename || displayLang; + const lineCount = content.split('\n').length; + const [isExpanded, setIsExpanded] = useState(false); - // Calculate content width (account for border + padding) - const codeWidth = Math.max(20, (width || 60) - 4); - const lines = (content || '').split('\n'); - const lineCount = lines.length; + // Calculate safe content width accounting for spacing + const contentWidth = width ? width - 4 : 60; // Account for left gutter (2) and spacing (2) + + // Determine if we should show the expand/collapse functionality + const needsExpansion = lineCount > 10 && !isStreaming; // Don't expand during streaming + + const renderContent = () => { + if (isExpanded || !needsExpansion) { + return h(Markdown, { syntaxTheme: 'github', width: contentWidth }, `\`\`\`${language}\n${content}\n\`\`\``); + } + + // Collapsed view: show first few and last few lines + const lines = content.split('\n'); + if (lines.length <= 10) { + return h(Markdown, { syntaxTheme: 'github', width: contentWidth }, `\`\`\`${language}\n${content}\n\`\`\``); + } + + const firstLines = lines.slice(0, 5).join('\n'); + const lastLines = lines.slice(-3).join('\n'); + const previewContent = `${firstLines}\n... [${lineCount - 8} more lines]\n${lastLines}`; + + return h(Markdown, { syntaxTheme: 'github', width: contentWidth }, `\`\`\`${language}\n${previewContent}\n\`\`\``); + }; return h(Box, { flexDirection: 'column', width: width, - marginY: 1, - borderStyle: 'round', - borderColor: isStreaming ? 'yellow' : 'gray' + marginLeft: 2, + marginBottom: 1 }, - // Header bar (Discord-style) + // Simple header with filename and controls - opencode style h(Box, { flexDirection: 'row', justifyContent: 'space-between', - paddingX: 1, - borderBottom: true, - borderBottomColor: 'gray' + marginBottom: 0.5 }, - h(Box, { gap: 1 }, - h(Text, { color: 'cyan', bold: true }, '📄'), - h(Text, { color: 'white', bold: true }, displayTitle) - ), + h(Text, { color: 'cyan', bold: true }, `${filename} (${language}) `), h(Text, { color: 'gray', dimColor: true }, `${lineCount} lines`) ), - // Code content + // Content area - no borders h(Box, { - flexDirection: 'column', - paddingX: 1, - paddingY: 0 + borderStyle: 'single', + borderColor: 'gray', + padding: 1 }, - h(Markdown, { syntaxTheme: 'dracula', width: codeWidth }, - '```' + (language || '') + '\n' + (content || '') + '\n```' - ) + renderContent() ), - // Streaming indicator - isStreaming ? h(Box, { paddingX: 1 }, - h(Text, { color: 'yellow' }, '⟳ Streaming...') + // Expand/collapse control - simple text style + needsExpansion ? h(Box, { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 0.5 + }, + h(Text, { color: 'cyan', dimColor: true }, isExpanded ? '▼ collapse' : '▶ expand ') ) : null ); }; + // ═══════════════════════════════════════════════════════════════ // MINIMAL CARD PROTOCOL - Claude Code / Codex CLI Style // NO BORDERS around messages - use left gutter rail + whitespace @@ -1367,42 +1520,44 @@ const UserCard = ({ content, width }) => { ); }; -// AGENT CARD - OpenCode-style with thick left border -// Thick green left border for assistant responses, animated streaming +// AGENT CARD - Opencode-style clean streaming +// Text-focused with minimal styling, clean left gutter const AgentCard = ({ content, isStreaming, width }) => { - // OpenCode-style: thick left border, color-coded - const borderColor = isStreaming ? 'yellow' : 'green'; - const contentWidth = width ? width - 4 : undefined; // Account for border + padding + const contentWidth = width ? width - 4 : undefined; // Account for left gutter and spacing return h(Box, { flexDirection: 'row', marginTop: 1, marginBottom: 1, - width: width + width: width, }, - // Thick left border (OpenCode style) + // Clean left gutter similar to opencode h(Box, { width: 2, - flexShrink: 0, - borderStyle: 'bold', + marginRight: 1, + borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, - borderColor: borderColor + borderLeftColor: isStreaming ? 'yellow' : 'green' + }), + + // Content area - text focused, no boxy borders + h(Box, { + flexDirection: 'column', + flexGrow: 1, + minWidth: 10 }, - h(Text, { color: borderColor }, '┃') - ), - - // Content area - h(Box, { flexDirection: 'column', paddingLeft: 1, flexGrow: 1 }, - // Streaming indicator at top if active - isStreaming ? h(Box, { marginBottom: 0 }, - h(Text, { color: 'yellow' }, '◐ '), - h(Text, { color: 'yellow', dimColor: true }, 'Streaming...') - ) : null, - - // Main content with markdown - h(Markdown, { syntaxTheme: 'dracula', width: contentWidth }, content || '') + // Content with streaming effect + h(Box, { width: contentWidth }, + isStreaming + ? h(TypewriterText, { + children: content || '', + speed: 35, // Optimal speed for readability + batchSize: 1 // Single chars for smoothest flow + }) + : h(Markdown, { syntaxTheme: 'github', width: contentWidth }, content || '') + ) ) ); }; @@ -1449,6 +1604,7 @@ const MessageCard = ({ role, content, meta, isStreaming, width }) => { // This enables the "3-4 line portion" look and prevents cutoff of long messages const flattenMessagesToBlocks = (messages) => { const blocks = []; + let globalId = 0; // Global counter to ensure unique keys messages.forEach((msg, msgIndex) => { // 1. User/System/Error: Treat as single block @@ -1456,7 +1612,7 @@ const flattenMessagesToBlocks = (messages) => { blocks.push({ ...msg, type: 'text', - uiKey: `msg-${msgIndex}`, + uiKey: `msg-${globalId++}`, isFirst: true, isLast: true }); @@ -1466,7 +1622,7 @@ const flattenMessagesToBlocks = (messages) => { // 2. Assistant: Parse into chunks // Handle empty content (e.g. start of stream) if (!msg.content) { - blocks.push({ role: 'assistant', type: 'text', content: '', uiKey: `msg-${msgIndex}-empty`, isFirst: true, isLast: true }); + blocks.push({ role: 'assistant', type: 'text', content: '', uiKey: `msg-${globalId++}`, isFirst: true, isLast: true }); return; } @@ -1484,20 +1640,21 @@ const flattenMessagesToBlocks = (messages) => { role: 'assistant', type: 'code', content: part, - uiKey: `msg-${msgIndex}-part-${partIndex}`, + uiKey: `msg-${globalId++}`, isFirst: blockCount === 0, isLast: false // to be updated later }); blockCount++; } else if (part.match(/^\[AGENT:/)) { // AGENT TAG BLOCK (New) - const agentName = part.match(/\[AGENT:\s*([^\]]+)\]/)[1]; + const agentMatch = part.match(/\[AGENT:\s*([^\]]+)\]/); + const agentName = agentMatch ? agentMatch[1] : 'Unknown'; blocks.push({ role: 'assistant', - type: 'agent_tag', // distinct type + type: 'agent_tag', name: agentName, content: part, - uiKey: `msg-${msgIndex}-part-${partIndex}`, + uiKey: `msg-${globalId++}`, isFirst: blockCount === 0, isLast: false }); @@ -1511,7 +1668,7 @@ const flattenMessagesToBlocks = (messages) => { role: 'assistant', type: 'text', content: para.trim(), // Clean paragraph - uiKey: `msg-${msgIndex}-part-${partIndex}-para-${paraIndex}`, + uiKey: `msg-${globalId++}`, isFirst: blockCount === 0, isLast: false }); @@ -1679,12 +1836,21 @@ const ScrollableChat = ({ messages, viewHeight, width, isActive = true, isStream // Blocks Container h(Box, { flexDirection: 'column', flexGrow: 1, overflow: 'hidden' }, visibleBlocks.map((block) => { + // Determine if this is the last assistant message and we're still streaming + const lastMessage = messages[messages.length - 1]; + const isLastAssistantBlock = block.uiKey && block.uiKey.includes(`msg-${messages.length - 1}`); + const isLastAssistantAndStreaming = + block.role === 'assistant' && + isLastAssistantBlock && + isStreaming; + return h(ViewportMessage, { key: block.uiKey, role: block.role, content: block.content, meta: block.meta, width: width, + isStreaming: isLastAssistantAndStreaming, // Pass context to help UI (e.g. continuous rails) isFirst: block.isFirst, isLast: block.isLast, @@ -1974,7 +2140,7 @@ const ModelSelector = ({ // VIEWPORT MESSAGE - Unified Message Protocol Renderer (Alt) // Supports meta field for consistent styling // ═══════════════════════════════════════════════════════════════ -const ViewportMessage = ({ role, content, meta, width = 80, isFirst = true, isLast = true, type = 'text', blocks = [] }) => { +const ViewportMessage = ({ role, content, meta, width = 80, isFirst = true, isLast = true, type = 'text', blocks = [], isStreaming = false }) => { // PRO API: Use ChatBubble for everything // For Assistant, we handle code blocks separately if they exist? @@ -2000,37 +2166,12 @@ const ViewportMessage = ({ role, content, meta, width = 80, isFirst = true, isLa */ if (role === 'assistant') { - // We render the bubble container manually to support custom children (CodeCards) - // OR we update ChatBubble to accept children. - // For now, let's keep the Bubble aesthetic but Inline layout like before? - // NO, user wants cards. - - const children = blocks && blocks.length > 0 - ? blocks.map((b, i) => { - if (b.type === 'agent_tag') { - // NEW: Render Agent Badge - return h(Box, { key: i, marginTop: 1, marginBottom: 1, borderStyle: 'round', borderColor: 'magenta', paddingX: 1, alignSelf: 'flex-start' }, - h(Text, { color: 'magenta', bold: true }, `🤖 ${b.name.trim()}`) - ); - } - if (b.type === 'code') { - // Pass width for responsive layout - return h(CodeCard, { key: i, width: width - 12, ...b }); - } - // Text - return h(Markdown, { key: i, width: width - 12 }, b.content); - }) - : h(Markdown, { width: width - 12 }, content); - - return h(Box, { width: width, flexDirection: 'row', marginBottom: 1, overflow: 'hidden' }, - // Left Gutter - h(Box, { marginRight: 1, borderStyle: 'single', borderRight: false, borderTop: false, borderBottom: false, borderLeftColor: 'green' }), - - // Content with children - h(Box, { flexDirection: 'column', paddingRight: 2, flexGrow: 1, width: width }, - children // Children already wrapped in Markdown with correct width - ) - ); + // Use the improved AgentCard for consistent streaming experience + return h(AgentCard, { + content: content, + isStreaming: isStreaming, + width: width + }); } // Delegate User/System to ChatBubble @@ -2079,6 +2220,10 @@ const App = () => { // NEW: Project Creation State const [newProjectName, setNewProjectName] = useState(''); + // POWER FEATURE: Managed Todo List + const [todoList, setTodoList] = useState([]); + const [showTodoList, setShowTodoList] = useState(false); + // NEW: Command Execution State const [detectedCommands, setDetectedCommands] = useState([]); const [isExecutingCommands, setIsExecutingCommands] = useState(false); @@ -2106,6 +2251,9 @@ const App = () => { // MODEL SELECTOR: Interactive model picker overlay const [showModelSelector, setShowModelSelector] = useState(false); + // TODO LIST OVERLAY + const [showTodoOverlay, setShowTodoOverlay] = useState(false); + // OPENCODE FEATURE: Permission Dialog const [pendingAction, setPendingAction] = useState(null); // { type: 'write'|'run', files: [], onApprove, onDeny } @@ -2119,8 +2267,16 @@ const App = () => { const [showTimeoutRow, setShowTimeoutRow] = useState(false); const [lastCheckpointText, setLastCheckpointText] = useState(''); - // SOLO MODE STATE + // SMARTX ENGINE STATE const [soloMode, setSoloMode] = useState(false); + const [autoApprove, setAutoApprove] = useState(false); // Auto-execute commands in SmartX Engine + + // AUTO-APPROVE: Automatically execute commands in SmartX Engine + useEffect(() => { + if (autoApprove && soloMode && detectedCommands.length > 0 && !isExecutingCommands) { + handleExecuteCommands(true); + } + }, [autoApprove, soloMode, detectedCommands, isExecutingCommands]); // RESPONSIVE: Compute layout mode based on terminal size const layoutMode = computeLayoutMode(columns, rows); @@ -2156,9 +2312,16 @@ const App = () => { setShowCommandPalette(prev => !prev); } + // Ctrl+T opens todo list + if (input === 't' && key.ctrl && appState === 'chat') { + setShowTodoOverlay(prev => !prev); + } + // ESC closes menus if (key.escape) { - if (showCommandPalette) { + if (showTodoOverlay) { + setShowTodoOverlay(false); + } else if (showCommandPalette) { setShowCommandPalette(false); } else if (showAgentMenu) { if (agentMenuMode === 'add') { @@ -2166,6 +2329,8 @@ const App = () => { } else { setShowAgentMenu(false); } + } else if (showModelSelector) { + setShowModelSelector(false); } } }); @@ -2254,6 +2419,13 @@ const App = () => { }); }, [project]); + // Load todo list when project changes + useEffect(() => { + if (!project) return; + const loadedTodos = loadTodoList(project); + setTodoList(loadedTodos); + }, [project]); + const parseResponse = useCallback((text) => { const blocks = []; let cardId = 1; @@ -2331,11 +2503,12 @@ const App = () => { const arg = parts.slice(1).join(' '); switch (cmd) { - case '/solo': + case '/smartx': + case '/solo': // Legacy alias setSoloMode(prev => !prev); setMessages(prev => [...prev, { role: 'system', - content: `🤖 **SOLO MODE: ${!soloMode ? 'ON (Auto-Heal Enabled)' : 'OFF'}**\nErrors will now be automatically reported to the agent for fixing.` + content: `🤖 **SMARTX ENGINE: ${!soloMode ? 'ON (Auto-Heal Enabled)' : 'OFF'}**\nErrors will now be automatically reported to the agent for fixing.` }]); setInput(''); return; @@ -2711,6 +2884,14 @@ const App = () => { // ═══════════════════════════════════════════════════════ // POWER FEATURES COMMANDS // ═══════════════════════════════════════════════════════ + case '/auto': + setAutoApprove(prev => !prev); + setMessages(prev => [...prev, { + role: 'system', + content: !autoApprove ? '▶️ Auto-Approve **ENABLED** - Commands execute automatically in SmartX Engine' : '⏸ Auto-Approve **DISABLED** - Commands require confirmation' + }]); + setInput(''); + return; case '/theme': if (arg && THEMES[arg.toLowerCase()]) { setTheme(arg.toLowerCase()); @@ -2828,39 +3009,46 @@ const App = () => { role: 'system', content: `## ⚡ Quick Commands -**AGENT** -* \`/agents\` - Switch AI Persona -* \`/context\` - Toggle Smart Context (${contextEnabled ? 'ON' : 'OFF'}) -* \`/thinking\` - Toggle Exposed Thinking (${exposedThinking ? 'ON' : 'OFF'}) -* \`/reset\` - Clear Session Memory + **AGENT** + * \`/agents\` - Switch AI Persona + * \`/context\` - Toggle Smart Context (${contextEnabled ? 'ON' : 'OFF'}) + * \`/thinking\` - Toggle Exposed Thinking (${exposedThinking ? 'ON' : 'OFF'}) + * \`/reset\` - Clear Session Memory -**IDE POWER FEATURES** -* \`/theme [name]\` - Switch theme (dracula/monokai/nord/matrix) -* \`/find [query]\` - Fuzzy file finder -* \`/todos\` - Show TODO/FIXME comments from project + **IDE POWER FEATURES** + * \`/theme [name]\` - Switch theme (dracula/monokai/nord/matrix) + * \`/find [query]\` - Fuzzy file finder + * \`/todos\` - Show TODO/FIXME comments from project -**SESSION MANAGEMENT** -* \`/save \` - Save current session -* \`/load \` - Load saved session -* \`/sessions\` - List all sessions -* \`/changes\` - Show modified files this session + **TASK MANAGEMENT** + * \`/todo \` - Add new task + * \`/todos\` - Show all tasks + * \`/todo-complete \` - Mark task as complete + * \`/todo-delete \` - Delete task + * \`Ctrl+T\` - Open todo list UI -**CUSTOM COMMANDS** -* \`/cmd\` - List custom commands -* \`/cmd \` - Execute custom command + **SESSION MANAGEMENT** + * \`/save \` - Save current session + * \`/load \` - Load saved session + * \`/sessions\` - List all sessions + * \`/changes\` - Show modified files this session -**INPUT** -* \`/paste\` - Paste from Clipboard (multi-line) + **CUSTOM COMMANDS** + * \`/cmd\` - List custom commands + * \`/cmd \` - Execute custom command -**DEPLOY** -* \`/push\` - Git Add + Commit + Push -* \`/deploy\` - Deploy to Vercel + **INPUT** + * \`/paste\` - Paste from Clipboard (multi-line) -**TOOLS** -* \`/run \` - Execute Shell Command -* \`/ssh\` - SSH Connection -* \`/write\` - Write Pending Code Files -* \`/clear\` - Reset Chat`, + **DEPLOY** + * \`/push\` - Git Add + Commit + Push + * \`/deploy\` - Deploy to Vercel + + **TOOLS** + * \`/run \` - Execute Shell Command + * \`/ssh\` - SSH Connection + * \`/write\` - Write Pending Code Files + * \`/clear\` - Reset Chat`, meta: { title: 'AVAILABLE COMMANDS', badge: '📚', @@ -2931,6 +3119,99 @@ const App = () => { })(); return; + case '/todo': + case '/todos': + if (arg) { + // Add a new todo + addTodo(arg); + } else { + // Show todo list + if (todoList.length === 0) { + setMessages(prev => [...prev, { + role: 'system', + content: '📋 No tasks yet. Use /todo to add one.' + }]); + } else { + const pending = todoList.filter(t => t.status === 'pending'); + const completed = todoList.filter(t => t.status === 'completed'); + + let todoMessage = `📋 **Task List** (${pending.length} pending, ${completed.length} completed)\n\n`; + if (pending.length > 0) { + todoMessage += "**Pending Tasks:**\n"; + pending.forEach((t, i) => { + todoMessage += ` ${i + 1}. ${t.content}\n`; + }); + todoMessage += "\n"; + } + if (completed.length > 0) { + todoMessage += "**Completed Tasks:**\n"; + completed.forEach((t, i) => { + todoMessage += ` ✓ ${t.content}\n`; + }); + } + + setMessages(prev => [...prev, { + role: 'system', + content: todoMessage + }]); + } + } + setInput(''); + return; + + case '/todo-complete': + case '/todo-done': + if (arg) { + // Find todo by ID or content + const todoId = arg; + const todo = todoList.find(t => t.id === todoId || t.content.includes(arg)); + if (todo) { + completeTodo(todo.id); + setMessages(prev => [...prev, { + role: 'system', + content: `✅ Completed task: ${todo.content}` + }]); + } else { + setMessages(prev => [...prev, { + role: 'system', + content: `❌ Task not found: ${arg}` + }]); + } + } else { + setMessages(prev => [...prev, { + role: 'system', + content: '❌ Please specify a task to complete: /todo-complete ' + }]); + } + setInput(''); + return; + + case '/todo-delete': + case '/todo-remove': + if (arg) { + // Find todo by ID or content + const todo = todoList.find(t => t.id === arg || t.content.includes(arg)); + if (todo) { + deleteTodo(todo.id); + setMessages(prev => [...prev, { + role: 'system', + content: `🗑️ Removed task: ${todo.content}` + }]); + } else { + setMessages(prev => [...prev, { + role: 'system', + content: `❌ Task not found: ${arg}` + }]); + } + } else { + setMessages(prev => [...prev, { + role: 'system', + content: '❌ Please specify a task to delete: /todo-delete ' + }]); + } + setInput(''); + return; + case '/write': if (pendingFiles.length === 0) { setMessages(prev => [...prev, { role: 'system', content: '⚠️ No pending files to write.' }]); @@ -2978,17 +3259,28 @@ const App = () => { try { // Build context-aware prompt with agent-specific instructions let systemPrompt = `[SYSTEM CONTEXT] -CURRENT WORKING DIRECTORY: ${process.cwd()} -(CRITICAL: This is the ABSOLUTE SOURCE OF TRUTH. Ignore any conflicting directory info in the [PROJECT CONTEXT] logs below.) + CURRENT WORKING DIRECTORY: ${process.cwd()} + (CRITICAL: This is the ABSOLUTE SOURCE OF TRUTH. Ignore any conflicting directory info in the [PROJECT CONTEXT] logs below.) -` + loadAgentPrompt(agent); + ` + loadAgentPrompt(agent); - // Add project context if enabled + // Add project context if enabled with enhanced context window if (contextEnabled) { const projectContext = loadProjectContext(project); if (projectContext) { systemPrompt += '\n\n[PROJECT CONTEXT (HISTORY)]\n(WARNING: These logs may contain outdated path info. Trust SYSTEM CONTEXT CWD above over this.)\n' + projectContext; } + + // Enhanced context: Include recent conversation history for better continuity + if (messages.length > 0) { + const recentMessages = messages.slice(-6); // Last 3 exchanges (user+assistant) + if (recentMessages.length > 0) { + const recentContext = recentMessages.map(m => + `[PREVIOUS ${m.role.toUpperCase()}]: ${m.content.substring(0, 500)}` // Limit to prevent overflow + ).join('\n'); + systemPrompt += `\n\n[RECENT CONVERSATION]\n${recentContext}\n(Use this for context continuity, but prioritize the current request)`; + } + } } // MULTI-AGENT INSTRUCTION INJECTION @@ -3059,19 +3351,17 @@ This gives the user a chance to refine requirements before implementation. ? await callOpenCodeFree(fullPrompt, freeModel, (chunk) => { const cleanChunk = chunk.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); - // STREAM SPLITTING LOGIC (Thinking vs Content) - // We use simple heuristics to detect "Chain of Thought" + // IMPROVED STREAM SPLITTING LOGIC (Thinking vs Content) + // Claude Code style: cleaner separation of thinking from response const lines = cleanChunk.split('\n'); let isThinkingChunk = false; - // Heuristic: If we are already thinking, continue unless we hit a big header - // If we detect "Let me...", "I will...", "Thinking:" start thinking - if (/^(Let me|Now let me|I'll|I need to|I notice|Thinking:|Analyzing)/i.test(cleanChunk.trim())) { + // Enhanced heuristics for better Claude-like thinking detection + const trimmedChunk = cleanChunk.trim(); + if (/^(Let me|Now let me|I'll|I need to|I should|I notice|I can|I will|Thinking:|Analyzing|Considering|Checking|Looking|Planning|First|Next|Finally)/i.test(trimmedChunk)) { isThinkingChunk = true; - } - - // If we detect code block or Markdown, we are likely done thinking - if (/^```|# |Here is/i.test(cleanChunk.trim())) { + } else if (/^```|# |Here is|```|```|```/i.test(trimmedChunk)) { + // If we encounter code blocks or headers, likely content not thinking isThinkingChunk = false; } @@ -3085,10 +3375,7 @@ This gives the user a chance to refine requirements before implementation. } if (isThinkingChunk) { - // Agent detection moved up to global scope - - // setThinkingStats(prev => ({ ...prev, chars: prev.chars + cleanChunk.length })); // MOVED UP - setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l)]); + setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l && !/^(Let me|Now let me|I'll|I need to|I notice)/i.test(l.trim()))]); } else { setMessages(prev => { const last = prev[prev.length - 1]; @@ -3102,14 +3389,16 @@ This gives the user a chance to refine requirements before implementation. : await getQwen().sendMessage(fullPrompt, 'qwen-coder-plus', null, (chunk) => { const cleanChunk = chunk.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); - // STREAM SPLITTING LOGIC (Thinking vs Content) + // IMPROVED STREAM SPLITTING LOGIC (Thinking vs Content) const lines = cleanChunk.split('\n'); let isThinkingChunk = false; - if (/^(Let me|Now let me|I'll|I need to|I notice|Thinking:|Analyzing)/i.test(cleanChunk.trim())) { + // Enhanced heuristics for better Claude-like thinking detection + const trimmedChunk = cleanChunk.trim(); + if (/^(Let me|Now let me|I'll|I need to|I should|I notice|I can|I will|Thinking:|Analyzing|Considering|Checking|Looking|Planning|First|Next|Finally)/i.test(trimmedChunk)) { isThinkingChunk = true; - } - if (/^```|# |Here is/i.test(cleanChunk.trim())) { + } else if (/^```|# |Here is|```|```|```/i.test(trimmedChunk)) { + // If we encounter code blocks or headers, likely content not thinking isThinkingChunk = false; } @@ -3121,7 +3410,7 @@ This gives the user a chance to refine requirements before implementation. } if (isThinkingChunk) { - setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l)]); + setThinkingLines(prev => [...prev, ...lines.map(l => l.trim()).filter(l => l && !/^(Let me|Now let me|I'll|I need to|I notice)/i.test(l.trim()))]); } else { setMessages(prev => { const last = prev[prev.length - 1]; @@ -3152,9 +3441,9 @@ This gives the user a chance to refine requirements before implementation. const cmds = extractCommands(responseText); if (cmds.length > 0) { setDetectedCommands(cmds); - // SOLO MODE: AUTO-APPROVE + // SMARTX ENGINE: AUTO-APPROVE if (soloMode) { - setMessages(prev => [...prev, { role: 'system', content: `🤖 **SOLO MODE**: Auto-executing ${cmds.length} detected command(s)...` }]); + setMessages(prev => [...prev, { role: 'system', content: `🤖 **SMARTX ENGINE**: Auto-executing ${cmds.length} detected command(s)...` }]); // Execute immediately, bypassing UI prompt handleExecuteCommands(true, cmds); } @@ -3337,6 +3626,37 @@ This gives the user a chance to refine requirements before implementation. } }); + // Todo List Management Functions + const addTodo = (content) => { + const newTodo = { + id: Date.now().toString(), + content, + status: 'pending', + createdAt: new Date().toISOString(), + }; + const updatedTodos = [...todoList, newTodo]; + setTodoList(updatedTodos); + saveTodoList(project, updatedTodos); + setMessages(prev => [...prev, { + role: 'system', + content: `✅ Added task: ${content}` + }]); + }; + + const completeTodo = (id) => { + const updatedTodos = todoList.map(todo => + todo.id === id ? { ...todo, status: 'completed', completedAt: new Date().toISOString() } : todo + ); + setTodoList(updatedTodos); + saveTodoList(project, updatedTodos); + }; + + const deleteTodo = (id) => { + const updatedTodos = todoList.filter(todo => todo.id !== id); + setTodoList(updatedTodos); + saveTodoList(project, updatedTodos); + }; + const handleExecuteCommands = async (confirmed, cmdsOverride = null) => { if (!confirmed) { setDetectedCommands([]); @@ -3412,7 +3732,7 @@ This gives the user a chance to refine requirements before implementation. setIsExecutingCommands(false); } - // SOLO MODE: AUTO-HEAL + // SMARTX ENGINE: AUTO-HEAL // If any command failed, immediately report back to the agent const failures = results.filter(r => r.failed); if (soloMode && failures.length > 0 && !isCancelled) { @@ -3631,6 +3951,14 @@ This gives the user a chance to refine requirements before implementation. { label: '/plan Planner Agent', value: '/plan' }, { label: '/context Toggle Context', value: '/context' }, { label: '/thinking Toggle Thinking', value: '/thinking' }, + // SmartX Engine toggle - high visibility + soloMode + ? { label: '/smartx off SmartX → OFF', value: '/smartx off' } + : { label: '/smartx on SmartX → ON', value: '/smartx on' }, + // Auto-Approve toggle + autoApprove + ? { label: '/auto Auto-Approve → OFF', value: '/auto' } + : { label: '/auto Auto-Approve → ON', value: '/auto' }, { label: '/paste Clipboard Paste', value: '/paste' }, { label: '/project Project Info', value: '/project' }, { label: '/write Write Files', value: '/write' }, @@ -3669,6 +3997,18 @@ This gives the user a chance to refine requirements before implementation. value: exposedThinking, onCmd: '/thinking on', offCmd: '/thinking off' + }, + { + name: 'SmartX Engine', + value: soloMode, + onCmd: '/smartx on', + offCmd: '/smartx off' + }, + { + name: 'Auto-Approve', + value: autoApprove, + onCmd: '/auto', + offCmd: '/auto' } ]; @@ -3708,6 +4048,8 @@ This gives the user a chance to refine requirements before implementation. h(Text, { color: 'gray' }, ' /project Project Info'), h(Text, { color: 'gray' }, ' /write Write Files'), h(Text, { color: 'gray' }, ' /clear Clear Session'), + h(Text, { color: 'gray' }, ' /smartx SmartX Engine On/Off'), + h(Text, { color: 'gray' }, ' /auto Auto-Approve On/Off'), h(Text, { color: 'gray' }, ' /exit Exit TUI') ), @@ -3818,6 +4160,30 @@ This gives the user a chance to refine requirements before implementation. // ═══════════════════════════════════════════════════════════════ // Determine if we should show the Tab hint (narrow mode with sidebar collapsed) + // ═══════════════════════════════════════════════════════════════ + // CONDITIONAL RENDER: Todo List Overlay + // ═══════════════════════════════════════════════════════════════ + if (showTodoOverlay) { + return h(Box, { + flexDirection: 'column', + width: columns, + height: rows, + alignItems: 'center', + justifyContent: 'center' + }, + h(TodoList, { + tasks: todoList, + onAddTask: addTodo, + onCompleteTask: completeTodo, + onDeleteTask: deleteTodo, + width: Math.min(60, columns - 4) + }), + h(Box, { marginTop: 1 }, + h(Text, { dimColor: true }, 'Press Ctrl+T or Esc to close') + ) + ); + } + // ═══════════════════════════════════════════════════════════════ // CONDITIONAL RENDER: Model Selector OR Dashboard (not both) // ═══════════════════════════════════════════════════════════════ @@ -3941,6 +4307,8 @@ This gives the user a chance to refine requirements before implementation. selectedFiles: selectedFiles, systemStatus: systemStatus, thinkingStats: thinkingStats, // PASS REAL-TIME STATS + soloMode: soloMode, + autoApprove: autoApprove, activeModel: (() => { // Compute active model info for sidebar display const modelId = provider === 'opencode-free' ? freeModel : 'qwen-coder-plus'; @@ -4018,38 +4386,34 @@ This gives the user a chance to refine requirements before implementation. flexShrink: 0, // CRITICAL: Never shrink height: INPUT_HEIGHT, // Fixed height borderStyle: 'single', - borderColor: 'cyan', + borderColor: isLoading ? 'yellow' : 'cyan', paddingX: 1 }, - // Loading indicator with ANIMATED GradientBar + PAUSE HINT + // Loading indicator with minimal visual noise isLoading - ? h(Box, { flexDirection: 'column' }, - h(Box, { gap: 1, justifyContent: 'space-between' }, - h(Box, { gap: 1 }, - h(Spinner, { type: 'dots' }), - h(Text, { color: 'yellow' }, loadingMessage || 'Processing...') - ), - h(Text, { color: 'gray', dimColor: true }, '(Type to interrupt)') + ? h(Box, { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + h(Box, { flexDirection: 'row', gap: 1 }, + h(Spinner, { type: 'dots' }), + h(Text, { color: 'yellow' }, loadingMessage || 'Thinking...') ), - h(GradientBar, { width: Math.max(10, mainWidth - 10), speed: 80 }) + h(Text, { color: 'gray', dimColor: true }, 'type to interrupt') + ) + : h(Box, { flexDirection: 'row', alignItems: 'center' }, + h(Text, { color: 'cyan', bold: true }, '> '), + h(Box, { flexGrow: 1 }, + h(TextInput, { + value: input, + onChange: (val) => { + // AUTO-CLOSE overlays when user starts typing + if (showModelSelector) setShowModelSelector(false); + if (showCommandPalette) setShowCommandPalette(false); + setInput(val); + }, + onSubmit: handleSubmit, + placeholder: 'Type / for commands or enter your message...' + }) + ) ) - : null, - - // Input field - h(Box, {}, - h(Text, { color: 'cyan', bold: true }, '> '), - h(TextInput, { - value: input, - onChange: (val) => { - // AUTO-CLOSE overlays when user starts typing - if (showModelSelector) setShowModelSelector(false); - if (showCommandPalette) setShowCommandPalette(false); - setInput(val); - }, - onSubmit: handleSubmit, - placeholder: 'Type command or message...' - }) - ) ) ); })() diff --git a/bin/opencode-ink.mjs.bak b/bin/opencode-ink.mjs.bak index 9188397..af99698 100644 --- a/bin/opencode-ink.mjs.bak +++ b/bin/opencode-ink.mjs.bak @@ -12,7 +12,7 @@ import Spinner from 'ink-spinner'; import SelectInput from 'ink-select-input'; import fs from 'fs'; import path from 'path'; -import { exec } from 'child_process'; +import { exec, spawn } from 'child_process'; import { fileURLToPath } from 'url'; import clipboard from 'clipboardy'; // ESM-native Markdown component (replaces CommonJS ink-markdown) @@ -37,8 +37,44 @@ import { cleanContent, decodeEntities, stripDebugNoise } from './ui/utils/textFo import { TimeoutRow, RUN_STATES, createRun, updateRun, checkpointRun } from './ui/components/TimeoutRow.mjs'; // Pro Protocol: Rail-based message components import { SystemMessage, UserMessage, AssistantMessage, ThinkingIndicator, ErrorMessage } from './ui/components/AgentRail.mjs'; +import FileTree from './ui/components/FileTree.mjs'; +import DiffView from './ui/components/DiffView.mjs'; +import ThinkingBlock from './ui/components/ThinkingBlock.mjs'; +import ChatBubble from './ui/components/ChatBubble.mjs'; +import TodoList from './ui/components/TodoList.mjs'; -const { useState, useCallback, useEffect, useRef } = React; +// ═══════════════════════════════════════════════════════════════ +// NEW FEATURE MODULES - Inspired by Mini-Agent, original implementation +// ═══════════════════════════════════════════════════════════════ +import { getSessionMemory } from '../lib/session-memory.mjs'; +import { getContextManager } from '../lib/context-manager.mjs'; +import { getAllSkills, getSkill, executeSkill, getSkillListDisplay } from '../lib/skills.mjs'; +import { getDebugLogger, initFromArgs } from '../lib/debug-logger.mjs'; +import { processCommand, isCommand } from '../lib/command-processor.mjs'; +import { + getSystemPrompt, + formatCodeBlock, + formatToolResult, + formatError, + formatSuccess, + formatWarning, + formatFileOperation, + separator +} from '../lib/agent-prompt.mjs'; +import { + formatCodeBox, + formatFileDelivery, + formatPath, + truncateHeight, + formatTodoItem, + formatTaskChecklist, + getToolProgress +} from '../lib/message-renderer.mjs'; + +// Initialize debug logger from CLI args +const debugLogger = initFromArgs(); + +const { useState, useCallback, useEffect, useRef, useMemo } = React; // Custom hook for terminal dimensions (replaces ink-use-stdout-dimensions) const useTerminalSize = () => { @@ -64,11 +100,26 @@ const h = React.createElement; // ═══════════════════════════════════════════════════════════════ // CUSTOM MULTI-LINE INPUT COMPONENT -// Properly handles pasted multi-line text unlike ink-text-input +// Properly handles pasted multi-line text unlike ink-text-input with enhanced Claude Code TUI quality // ═══════════════════════════════════════════════════════════════ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = true }) => { const [cursorVisible, setCursorVisible] = useState(true); const [pastedChars, setPastedChars] = useState(0); + const [inputWidth, setInputWidth] = useState(80); // Default width + const [inputHeight, setInputHeight] = useState(1); // Track input height dynamically + + // Get terminal size for responsive input width + const [columns, rows] = useTerminalSize(); + useEffect(() => { + // Calculate input width accounting for margins and borders + const safeWidth = Math.max(20, columns - 10); // Leave margin for borders + setInputWidth(safeWidth); + + // Calculate height based on content but cap it to avoid taking too much space + const lines = value.split('\n'); + const newHeight = Math.min(Math.max(3, lines.length + 1), 10); // Min 3 lines, max 10 + setInputHeight(newHeight); + }, [columns, rows, value]); // Blink cursor useEffect(() => { @@ -80,8 +131,20 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru useInput((input, key) => { if (!isActive) return; - // Submit on Enter + // Submit on Enter (but only if not in multiline mode with Shift) if (key.return && !key.shift) { + // If we have multi-line content, require Ctrl+Enter to submit + if (value.includes('\n') && !key.ctrl) { + // Don't submit, just add a line break + return; + } + onSubmit(value); + setPastedChars(0); + return; + } + + // Ctrl+Enter for multi-line content submission + if (key.return && key.ctrl) { onSubmit(value); setPastedChars(0); return; @@ -93,6 +156,13 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru return; } + // Ctrl+V for paste (explicit paste detection) + if (key.ctrl && input.toLowerCase() === 'v') { + // This is handled by the system paste, so we just detect it + setPastedChars(value.length > 0 ? value.length * 2 : 100); // Estimate pasted chars + return; + } + // Backspace if (key.backspace || key.delete) { onChange(value.slice(0, -1)); @@ -106,19 +176,19 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru return; } - // Ignore control keys + // Ignore control keys except for specific shortcuts if (key.ctrl || key.meta) return; if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) return; // Append character(s) if (input) { - // Detect paste: if >5 chars arrive at once - if (input.length > 5) { - setPastedChars(input.length); + // Detect paste: if >5 chars arrive at once or contains newlines + if (input.length > 5 || input.includes('\n')) { + setPastedChars(input.length + (input.match(/\n/g) || []).length * 10); // Weight newlines } onChange(value + input); } - }, { isActive }); + }, [isActive, value]); // Reset paste indicator when input is cleared useEffect(() => { @@ -132,39 +202,311 @@ const MultiLineInput = ({ value, onChange, onSubmit, placeholder, isActive = tru const lineCount = lines.length; // Show paste indicator only if we detected a paste burst - if (pastedChars > 0) { + if (pastedChars > 10) { // Only show for significant pastes const indicator = lineCount > 1 - ? `[Pasted ~${lineCount} lines]` - : `[Pasted ~${pastedChars} chars]`; + ? `[Pasted: ${lineCount} lines, ${pastedChars} chars]` + : `[Pasted: ${pastedChars} chars]`; - return h(Box, { flexDirection: 'column' }, + return h(Box, { flexDirection: 'column', width: inputWidth }, h(Box, { borderStyle: 'round', borderColor: 'yellow', - paddingX: 1 + paddingX: 1, + width: inputWidth }, h(Text, { color: 'yellow', bold: true }, indicator) ), - isActive && cursorVisible ? h(Text, { backgroundColor: 'white', color: 'black' }, ' ') : null + h(Box, { + borderStyle: 'single', + borderColor: 'cyan', + paddingX: 1, + minHeight: inputHeight, + maxHeight: 10 + }, + lines.map((line, i) => + h(Text, { key: i, color: 'white', wrap: 'truncate' }, + i === lines.length - 1 && isActive && cursorVisible ? `${line}█` : line + ) + ) + ) ); } - // Normal short input - show inline - return h(Box, { flexDirection: 'row' }, - h(Text, { color: 'white' }, displayValue), - isActive && cursorVisible ? h(Text, { backgroundColor: 'white', color: 'black' }, ' ') : null, - !displayValue && placeholder ? h(Text, { dimColor: true }, placeholder) : null + // Multi-line input - render with proper height and scrolling + if (lineCount > 1 || value.length > 50) { // Show as multi-line if more than 1 line or long text + return h(Box, { + flexDirection: 'column', + width: inputWidth, + minHeight: inputHeight, + maxHeight: 10 + }, + h(Box, { + borderStyle: lineCount > 1 ? 'round' : 'single', + borderColor: 'cyan', + paddingX: 1, + flexGrow: 1, + maxHeight: inputHeight + }, + lines.map((line, i) => + h(Text, { + key: i, + color: 'white', + wrap: 'truncate', + maxWidth: inputWidth - 4 // Account for borders and padding + }, + i === lines.length - 1 && isActive && cursorVisible ? `${line}█` : line + ) + ) + ), + h(Box, { marginTop: 0.5 }, + h(Text, { color: 'gray', dimColor: true, fontSize: 0.8 }, + `${lineCount} line${lineCount > 1 ? 's' : ''} | ${value.length} chars | Shift+Enter: new line, Enter: submit`) + ) + ); + } + + // Normal single-line input - show inline with proper truncation + return h(Box, { flexDirection: 'row', width: inputWidth }, + h(Box, { borderStyle: 'single', borderColor: 'cyan', paddingX: 1, flexGrow: 1 }, + h(Text, { color: 'white', wrap: 'truncate' }, + displayValue + (isActive && cursorVisible && displayValue.length > 0 ? '█' : '') + ), + !displayValue && placeholder ? h(Text, { dimColor: true }, placeholder) : null, + isActive && !displayValue && cursorVisible ? h(Text, { backgroundColor: 'white', color: 'black' }, '█') : null + ) ); }; // Dynamic import for CommonJS module -const { QwenOAuth } = await import('../qwen-oauth.js'); +const { QwenOAuth } = await import('../qwen-oauth.mjs'); let qwen = null; const getQwen = () => { if (!qwen) qwen = new QwenOAuth(); return qwen; }; +// ═══════════════════════════════════════════════════════════════ +// MODEL CATALOG - All available models with settings +// ═══════════════════════════════════════════════════════════════ + +// OpenCode Free Proxy endpoint +const OPENCODE_FREE_API = 'https://api.opencode.ai/v1/chat/completions'; +const OPENCODE_PUBLIC_KEY = 'public'; + +// ALL MODELS - Comprehensive catalog with groups +const ALL_MODELS = { + // ───────────────────────────────────────────────────────────── + // DEFAULT TUI MODELS (Qwen - requires API key/CLI) + // ───────────────────────────────────────────────────────────── + 'qwen-coder-plus': { + name: 'Qwen Coder Plus', + group: 'Default TUI', + provider: 'qwen', + isFree: false, + context: 131072, + description: 'Your default Qwen coding model via CLI', + settings: { + apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + requiresAuth: true, + authType: 'qwen-cli', + } + }, + 'qwen-plus': { + name: 'Qwen Plus', + group: 'Default TUI', + provider: 'qwen', + isFree: false, + context: 1000000, + description: 'General purpose Qwen model', + settings: { + apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + requiresAuth: true, + authType: 'qwen-cli', + } + }, + 'qwen-turbo': { + name: 'Qwen Turbo', + group: 'Default TUI', + provider: 'qwen', + isFree: false, + context: 1000000, + description: 'Fast Qwen model for quick responses', + settings: { + apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + requiresAuth: true, + authType: 'qwen-cli', + } + }, + + + // OpenCode models disabled temporarily due to API issues +}; + +// Helper: Get FREE_MODELS for backward compatibility +const FREE_MODELS = Object.fromEntries( + Object.entries(ALL_MODELS).filter(([_, m]) => m.isFree) +); + +// Helper: Get models grouped by group name +const getModelsByGroup = () => { + const groups = {}; + for (const [id, model] of Object.entries(ALL_MODELS)) { + const group = model.group || 'Other'; + if (!groups[group]) groups[group] = []; + groups[group].push({ id, ...model }); + } + return groups; +}; + +// ═══════════════════════════════════════════════════════════════ +// AGENTIC COMMAND EXECUTION +// ═══════════════════════════════════════════════════════════════ + +const extractCommands = (text) => { + const commands = []; + const regex = /```(?:bash|shell|cmd|sh|powershell|ps1)(?::run)?[\s\n]+([\s\S]*?)```/gi; + let match; + while ((match = regex.exec(text)) !== null) { + const content = match[1].trim(); + if (content) { + content.split('\n').forEach(line => { + const cmd = line.trim(); + if (cmd && !cmd.startsWith('#')) commands.push(cmd); + }); + } + } + return commands; +}; + +// CRITICAL: runShellCommandStreaming for real-time output & abort control +const runShellCommandStreaming = (cmd, cwd = process.cwd(), onData = () => { }) => { + return new Promise((resolve) => { + // Use spawn with shell option for compatibility + const child = spawn(cmd, { + cwd, + shell: true, + std: ['ignore', 'pipe', 'pipe'], // Ignore stdin, pipe stdout/stderr + env: { ...process.env, FORCE_COLOR: '1' } + }); + + // Capture stdout + child.stdout.on('data', (data) => { + const str = data.toString(); + onData(str); + }); + + // Capture stderr + child.stderr.on('data', (data) => { + const str = data.toString(); + onData(str); + }); + + child.on('close', (code) => { + resolve({ + success: code === 0, + code: code || 0 + }); + }); + + child.on('error', (err) => { + onData(`\nERROR: ${err.message}\n`); + resolve({ + success: false, + code: 1, + error: err.message + }); + }); + + // Expose the child process via the promise (unconventional but useful here) + resolve.child = child; + }); +}; + +const runShellCommand = (cmd, cwd = process.cwd()) => { + return new Promise((resolve) => { + // Use exec which handles shell command strings (quotes, spaces) correctly + exec(cmd, { + cwd, + env: { ...process.env, FORCE_COLOR: '1' }, + maxBuffer: 1024 * 1024 * 5 // 5MB buffer for larger outputs + }, (error, stdout, stderr) => { + resolve({ + success: !error, + output: stdout + (stderr ? '\n' + stderr : ''), + code: error ? (error.code || 1) : 0 + }); + }); + }); +}; + +// Current free model state (default to grok-code-fast-1) +let currentFreeModel = 'grok-code-fast-1'; + +/** + * Call OpenCode Free API with streaming + * @param {string} prompt - Full prompt to send + * @param {string} model - Model ID from FREE_MODELS + * @param {function} onChunk - Streaming callback (chunk) => void + */ +const callOpenCodeFree = async (prompt, model = currentFreeModel, onChunk = null) => { + const modelInfo = FREE_MODELS[model]; + if (!modelInfo) { + return { success: false, error: `Unknown model: ${model}`, response: '' }; + } + + try { + const response = await fetch(OPENCODE_FREE_API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${OPENCODE_PUBLIC_KEY}`, + }, + body: JSON.stringify({ + model: model, + messages: [{ role: 'user', content: prompt }], + stream: true, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { success: false, error: `API error ${response.status}: ${errorText}`, response: '' }; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6).trim(); + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data); + const content = parsed.choices?.[0]?.delta?.content || ''; + if (content) { + fullResponse += content; + if (onChunk) onChunk(content); + } + } catch (e) { /* ignore parse errors */ } + } + } + } + + return { success: true, response: fullResponse, usage: null }; + } catch (error) { + return { success: false, error: error.message || 'Network error', response: '' }; + } +}; + // ═══════════════════════════════════════════════════════════════ // SMART CONTEXT - Session Log & Project Context // ═══════════════════════════════════════════════════════════════ @@ -302,6 +644,45 @@ Now, respond ONLY as TERMINUS. Never break character. - You are not a helper; you are the lead developer. - Do not wait for inputs. Go get them. - Use: \`tree -L 2\`, \`cat\`, \`head\`, \`ls\`, \`find\` to explore the codebase yourself. + +# COMPUTER USE & INPUT CONTROL +You have access to a "Hands" script: \`bin/input.ps1\`. +Use it to control the mouse, keyboard, and "see" the system. + +## 👁️ VISION & BLINDNESS PROTOCOL: +You are a TEXT-BASED intelligence. You CANNOT see images/screenshots you take. +- **\`input.ps1 open "URL/File"\`**: Launches a website or application. +- **\`input.ps1 uiclick "Name"\`**: **SMART ACTION**. Finds a VISIBLE button by name and clicks it automatically. +- **\`input.ps1 find "Name"\`**: Looks for VISIBLE elements only. Returns coordinates. +- **\`input.ps1 apps\`**: TEXT list of open apps. + +### 🔧 TROUBLESHOOTING & RECOVERY: +- **DOCKER ERROR**: If you see "error during connect... pipe... dockerDesktopLinuxEngine", **DOCKER IS NOT RUNNING**. + - **FIX**: Run \`powershell bin/input.ps1 open "Docker Desktop"\` OR \`uiclick "Docker Desktop"\`. + - Wait 15 seconds, then try again. +- **NOT FOUND**: If \`uiclick\` fails, check \`apps\` to see if the window is named differently. + +### 📐 THE LAW OF ACTION: +1. **SMART CLICK FIRST**: To click a named thing (Start, File, Edit), use: + \`powershell bin/input.ps1 uiclick "Start"\` + *This filters out invisible phantom buttons.* +2. **COORDINATES SECOND**: If \`uiclick\` fails, use \`find\` to get coords, then \`mouse\` + \`click\`. +3. **SHORTCUTS**: \`key LWIN\` is still the fastest way to open Start. + +### ⚡ SHORTCUTS > MOUSE: +Always prefer \`key LWIN\` over clicking. It works on ANY resolution. +Only use Mouse if explicitly forced by the user. + +## Capabilities: +- **Vision (Apps)**: \`powershell bin/input.ps1 apps\` (Lists all open windows) +- **Vision (Screen)**: \`powershell bin/input.ps1 screenshot \` (Captures screen) +- **Mouse**: \`powershell bin/input.ps1 mouse \`, \`click\`, \`rightclick\` +- **Keyboard**: \`powershell bin/input.ps1 type "text"\`, \`key \` + +## Example: "What's on my screen?" +\`\`\`powershell +powershell bin/input.ps1 apps +\`\`\` `; const defaultPrompts = { @@ -369,18 +750,7 @@ const writeFile = (projectPath, filename, content) => { } }; -// Run shell command -const runShellCommand = (cmd, cwd) => { - return new Promise((resolve) => { - exec(cmd, { cwd }, (error, stdout, stderr) => { - resolve({ - success: !error, - output: stdout + (stderr ? '\n' + stderr : ''), - error: error ? error.message : null - }); - }); - }); -}; + // ═══════════════════════════════════════════════════════════════ // RECENT PROJECTS @@ -397,6 +767,432 @@ const loadRecentProjects = () => { return []; }; +// ═══════════════════════════════════════════════════════════════ +// POWER FEATURE 1: TODO TRACKER +// Parses TODO/FIXME comments from project files +// ═══════════════════════════════════════════════════════════════ +const parseTodos = (projectPath) => { + const todos = []; + const extensions = ['.js', '.ts', '.jsx', '.tsx', '.py', '.md', '.mjs']; + const todoPattern = /(?:\/\/|#| +
+ +

Dedicated Nodes

+

High-performance dedicated server solutions for businesses and individuals

+

Professional hosting services with reliable infrastructure

+
+ + +
+

About DedicatedNodes.io

+

DedicatedNodes.io provides premium dedicated server hosting solutions designed for:

+
+
Web Hosting
+
Game Servers
+
Database Hosting
+
Enterprise Applications
+
+
+ + +
+

Our Services

+

We offer a range of dedicated server options:

+
+
SSD Storage
Fast storage solutions
+
DDoS Protection
Advanced security
+
24/7 Support
Round-the-clock assistance
+
Global Locations
Multiple data centers
+
+
+ + +
+

Why Choose Us

+

Benefits of using DedicatedNodes.io:

+
+

Full control over your server resources

+

• High performance and reliability

+

• No resource sharing with other users

+

• Complete customization options

+

• Competitive pricing plans

+

• Professional technical support

+
+
+ + +
+

Get Started Today

+

Ready to experience premium dedicated server hosting?

+ +
+ + + + + + + + \ No newline at end of file diff --git a/lib/agent-prompt.cjs b/lib/agent-prompt.cjs new file mode 100644 index 0000000..48b0280 --- /dev/null +++ b/lib/agent-prompt.cjs @@ -0,0 +1,91 @@ +/** + * Agent Prompt - Enhanced communication patterns for OpenQode TUI (CommonJS Adapter) + * Based on: OpenCode CLI and Mini-Agent best practices + */ + +/** + * Get the enhanced system prompt for the AI agent + * @param {Object} context - Context object with project info + * @returns {string} - The complete system prompt + */ +function getSystemPrompt(context = {}) { + const { + projectPath = process.cwd(), + isGitRepo = false, + platform = process.platform, + model = 'unknown', + skills = [], + memory = [] + } = context; + + const date = new Date().toLocaleDateString(); + const memoryContext = memory.length > 0 + ? `\n## Session Memory\n${memory.map((m, i) => `${i + 1}. ${m}`).join('\n')}\n` + : ''; + + return `You are OpenQode, an interactive CLI coding assistant that helps users with software engineering tasks. + +## Core Behavior + +### Tone & Style +- Be CONCISE and DIRECT. Respond in 1-4 lines unless the user asks for detail. +- NO preamble like "Here's what I'll do..." or "Based on my analysis..." +- NO postamble like "Let me know if you need anything else!" +- One-word or short answers when appropriate (e.g., user asks "is X prime?" → "Yes") +- When running commands, briefly explain WHAT it does (not obvious details) + +### Response Examples + +User: what's 2+2? +You: 4 + + + +User: how do I list files? +You: ls + + + +User: create a React component for a button +You: [Creates the file directly using tools, then says:] +Created Button.jsx with onClick handler and styling. + + +### Code Actions +- When creating/editing files, DO IT directly - don't just show code +- After file operations, give a ONE-LINE summary of what was created +- Use file separators for code blocks: +\`\`\` +┌─ filename.js ────────────────────────────────── +│ code here +└──────────────────────────────────────────────── +\`\`\` + +### Tool Usage +- If you need information, USE TOOLS to find it - don't guess +- Run lint/typecheck after code changes when available +- Never commit unless explicitly asked +- Explain destructive commands before running them + +### Error Handling +- Report errors with: problem + solution +- Format: ❌ Error: [what went wrong] → [how to fix] + +## Environment + +Working Directory: ${projectPath} +Git Repository: ${isGitRepo ? 'Yes' : 'No'} +Platform: ${platform} +Model: ${model} +Date: ${date} + +${memoryContext} +## Available Skills +${skills.length > 0 ? skills.map(s => `- ${s.name}: ${s.description}`).join('\n') : 'Use /skills to see available skills'} + +Remember: Keep responses SHORT. Act, don't explain. Code directly, summarize briefly.`; +} + +module.exports = { + getSystemPrompt +}; diff --git a/qwen-oauth.cjs b/qwen-oauth.cjs index fb79e46..80a1eb6 100644 --- a/qwen-oauth.cjs +++ b/qwen-oauth.cjs @@ -281,17 +281,17 @@ class QwenOAuth { }); } - async sendMessage(message, model = 'qwen-coder-plus', imageData = null, onChunk = null) { + async sendMessage(message, model = 'qwen-coder-plus', imageData = null, onChunk = null, systemPrompt = null) { if (imageData) { console.log('📷 Image data detected, using Vision API...'); - return await this.sendVisionMessage(message, imageData, 'qwen-vl-plus'); + return await this.sendVisionMessage(message, imageData, 'qwen-vl-plus', systemPrompt); } const { spawn } = require('child_process'); const os = require('os'); // fsSync imported at top - const systemContext = `[SYSTEM CONTEXT - ALWAYS FOLLOW] + const hardcodedContext = `[SYSTEM CONTEXT - ALWAYS FOLLOW] You are an AI System Administrator integrated into OpenQode. IMPORTANT RULES: 1. You have FULL ACCESS to the local file system. @@ -304,8 +304,11 @@ IMPORTANT RULES: `; let finalMessage = message; - if (message.includes('CREATE:') || message.includes('ROLE:') || message.includes('Generate all necessary files')) { - finalMessage = systemContext + message; + // Use provided systemPrompt if available, otherwise fall back to hardcoded context for legacy commands + if (systemPrompt) { + finalMessage = systemPrompt + "\n\n" + message; + } else if (message.includes('CREATE:') || message.includes('ROLE:') || message.includes('Generate all necessary files')) { + finalMessage = hardcodedContext + message; } return new Promise((resolve) => { diff --git a/qwen-oauth.mjs b/qwen-oauth.mjs index 5af4f5d..c05f544 100644 --- a/qwen-oauth.mjs +++ b/qwen-oauth.mjs @@ -12,6 +12,7 @@ import { readFile, writeFile, unlink } from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; +import { fetchWithRetry } from './lib/retry-handler.mjs'; // ESM __dirname equivalent const __filename = fileURLToPath(import.meta.url); @@ -335,8 +336,9 @@ class QwenOAuth { * @param {string} model - The model to use * @param {object} imageData - Optional image data * @param {function} onChunk - Optional callback for streaming output (chunk) => void + * @param {string} systemPrompt - Optional system prompt to override/prepend */ - async sendMessage(message, model = 'qwen-coder-plus', imageData = null, onChunk = null) { + async sendMessage(message, model = 'qwen-coder-plus', imageData = null, onChunk = null, systemPrompt = null) { // If we have image data, always use the Vision API if (imageData) { console.log('📷 Image data detected, using Vision API...'); @@ -348,8 +350,14 @@ class QwenOAuth { const os = await import('os'); const fsSync = await import('fs'); - // CRITICAL: Prepend system context to prevent AI from getting confused about environment - const systemContext = `[SYSTEM CONTEXT - ALWAYS FOLLOW] + let finalMessage = message; + + // If systemPrompt is provided (New Flow), use it directly + message + if (systemPrompt) { + finalMessage = systemPrompt + '\n\n' + message; + } else { + // Legacy Flow: Prepend hardcoded context for specific keywords + const systemContext = `[SYSTEM CONTEXT - ALWAYS FOLLOW] You are an AI System Administrator integrated into OpenQode. IMPORTANT RULES: 1. You have FULL ACCESS to the local file system. @@ -360,17 +368,15 @@ IMPORTANT RULES: [END SYSTEM CONTEXT] `; - - // Prepend system context ONLY for build/create commands (detected by keywords) - let finalMessage = message; - const lowerMsg = message.toLowerCase(); - if (message.includes('CREATE:') || - message.includes('ROLE:') || - message.includes('Generate all necessary files') || - lowerMsg.includes('open ') || - lowerMsg.includes('run ') || - lowerMsg.includes('computer use')) { - finalMessage = systemContext + message; + const lowerMsg = message.toLowerCase(); + if (message.includes('CREATE:') || + message.includes('ROLE:') || + message.includes('Generate all necessary files') || + lowerMsg.includes('open ') || + lowerMsg.includes('run ') || + lowerMsg.includes('computer use')) { + finalMessage = systemContext + message; + } } return new Promise((resolve) => { @@ -513,7 +519,7 @@ IMPORTANT RULES: stream: false }; - const response = await fetch(QWEN_CHAT_API, { + const response = await fetchWithRetry(QWEN_CHAT_API, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -557,7 +563,7 @@ The Qwen Vision API needs OAuth authentication to analyze images. The current se **To enable image analysis:** 1. Click "Authenticate Qwen" button to re-authenticate 2. Or describe what's in your image and I'll help without viewing it - + *Your image was received (${(imageData?.length / 1024).toFixed(1)} KB) but couldn't be processed without Vision API tokens.*`, usage: null }; diff --git a/qwen-oauth.mjs.bak b/qwen-oauth.mjs.bak new file mode 100644 index 0000000..5af4f5d --- /dev/null +++ b/qwen-oauth.mjs.bak @@ -0,0 +1,575 @@ +/** + * Qwen OAuth Implementation - Device Code Flow with PKCE + * Based on qwen-code's qwenOAuth2.ts + * https://github.com/QwenLM/qwen-code + * + * CONVERTED TO ESM for ink v5+ compatibility + */ + +import crypto from 'crypto'; +import fs from 'fs'; +import { readFile, writeFile, unlink } from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +// ESM __dirname equivalent +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Qwen OAuth Constants (from qwen-code) +const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'; +const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`; +const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`; + +// Load config using createRequire (most reliable for cross-platform ESM/CJS compat) +let config = {}; +try { + const require = createRequire(import.meta.url); + config = require('./config.cjs'); + // Handle both ESM and CJS exports + if (config.default) config = config.default; +} catch (e) { + // Config missing is expected for first-time users using CLI only. + // We don't crash here - we just run without OAuth support (CLI fallback) +} +const QWEN_OAUTH_CLIENT_ID = config.QWEN_OAUTH_CLIENT_ID || null; +const QWEN_OAUTH_SCOPE = 'openid profile email model.completion'; +const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; +const QWEN_CHAT_API = 'https://chat.qwen.ai/api/chat/completions'; + +// Token storage path +const TOKEN_FILE = path.join(__dirname, '.qwen-tokens.json'); + +/** + * Generate PKCE code verifier (RFC 7636) + */ +function generateCodeVerifier() { + return crypto.randomBytes(32).toString('base64url'); +} + +/** + * Generate PKCE code challenge from verifier + */ +function generateCodeChallenge(codeVerifier) { + const hash = crypto.createHash('sha256'); + hash.update(codeVerifier); + return hash.digest('base64url'); +} + +/** + * Convert object to URL-encoded form data + */ +function objectToUrlEncoded(data) { + return Object.keys(data) + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`) + .join('&'); +} + +/** + * Generate random UUID + */ +function randomUUID() { + return crypto.randomUUID(); +} + +class QwenOAuth { + constructor() { + this.tokens = null; + this.deviceCodeData = null; + this.codeVerifier = null; + } + + /** Load stored tokens */ + async loadTokens() { + try { + const data = await readFile(TOKEN_FILE, 'utf8'); + this.tokens = JSON.parse(data); + return this.tokens; + } catch (error) { + this.tokens = null; + return null; + } + } + + /** Save tokens */ + async saveTokens(tokens) { + this.tokens = tokens; + // Add expiry timestamp + if (tokens.expires_in && !tokens.expiry_date) { + tokens.expiry_date = Date.now() + (tokens.expires_in * 1000); + } + await writeFile(TOKEN_FILE, JSON.stringify(tokens, null, 2)); + } + + /** Clear tokens */ + async clearTokens() { + this.tokens = null; + this.deviceCodeData = null; + this.codeVerifier = null; + try { + await unlink(TOKEN_FILE); + } catch (error) { } + } + + isTokenValid() { + if (!this.tokens || !this.tokens.access_token) { + return false; + } + if (this.tokens.expiry_date) { + // Check with 5 minute buffer + return Date.now() < (this.tokens.expiry_date - 300000); + } + return true; + } + + async refreshToken() { + if (!this.tokens || !this.tokens.refresh_token) { + throw new Error('No refresh token available'); + } + + console.log('Refreshing access token...'); + + const bodyData = { + grant_type: 'refresh_token', + client_id: QWEN_OAUTH_CLIENT_ID, + refresh_token: this.tokens.refresh_token + }; + + const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'x-request-id': randomUUID() + }, + body: objectToUrlEncoded(bodyData) + }); + + if (!response.ok) { + const error = await response.text(); + console.error('Token refresh failed:', response.status, error); + await this.clearTokens(); + throw new Error(`Token refresh failed: ${response.status}`); + } + + const newTokens = await response.json(); + await this.saveTokens(newTokens); + console.log('Token refreshed successfully!'); + return newTokens; + } + + /** + * Start the Device Code Flow with PKCE + */ + async startDeviceFlow() { + console.log('Starting Qwen Device Code Flow with PKCE...'); + + if (!QWEN_OAUTH_CLIENT_ID) { + throw new Error('Missing Client ID. Please copy config.example.cjs to config.cjs and add your QWEN_OAUTH_CLIENT_ID to use this feature.'); + } + + // Generate PKCE pair + this.codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(this.codeVerifier); + + const bodyData = { + client_id: QWEN_OAUTH_CLIENT_ID, + scope: QWEN_OAUTH_SCOPE, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }; + + console.log('Device code request body:', bodyData); + + const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'x-request-id': randomUUID() + }, + body: objectToUrlEncoded(bodyData) + }); + + if (!response.ok) { + const error = await response.text(); + console.error('Device code request failed:', response.status, error); + throw new Error(`Device code request failed: ${response.status} - ${error}`); + } + + this.deviceCodeData = await response.json(); + console.log('Device code response:', this.deviceCodeData); + + // Check for error in response + if (this.deviceCodeData.error) { + throw new Error(`${this.deviceCodeData.error}: ${this.deviceCodeData.error_description || 'Unknown error'}`); + } + + return { + verificationUri: this.deviceCodeData.verification_uri, + verificationUriComplete: this.deviceCodeData.verification_uri_complete, + userCode: this.deviceCodeData.user_code, + expiresIn: this.deviceCodeData.expires_in, + interval: this.deviceCodeData.interval || 5, + }; + } + + /** + * Poll for tokens after user completes login + */ + async pollForTokens() { + if (!this.deviceCodeData || !this.codeVerifier) { + throw new Error('Device flow not started'); + } + + const interval = (this.deviceCodeData.interval || 5) * 1000; + const endTime = Date.now() + (this.deviceCodeData.expires_in || 300) * 1000; + + console.log(`Polling for tokens every ${interval / 1000}s...`); + + while (Date.now() < endTime) { + try { + const bodyData = { + grant_type: QWEN_OAUTH_GRANT_TYPE, + device_code: this.deviceCodeData.device_code, + client_id: QWEN_OAUTH_CLIENT_ID, + code_verifier: this.codeVerifier + }; + + const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'x-request-id': randomUUID() + }, + body: objectToUrlEncoded(bodyData) + }); + + const data = await response.json(); + + if (response.ok && data.access_token) { + console.log('Token received successfully!'); + await this.saveTokens(data); + this.deviceCodeData = null; + this.codeVerifier = null; + return data; + } else if (data.error === 'authorization_pending' || data.status === 'pending') { + // User hasn't completed auth yet + await new Promise(resolve => setTimeout(resolve, interval)); + } else if (data.error === 'slow_down' || data.slowDown) { + // Slow down polling + await new Promise(resolve => setTimeout(resolve, interval * 2)); + } else if (data.error === 'expired_token') { + throw new Error('Device code expired. Please start authentication again.'); + } else if (data.error === 'access_denied') { + throw new Error('Access denied by user.'); + } else if (data.error) { + throw new Error(`${data.error}: ${data.error_description || 'Unknown error'}`); + } else { + // Unknown response, keep polling + await new Promise(resolve => setTimeout(resolve, interval)); + } + } catch (error) { + if (error.message.includes('expired') || error.message.includes('denied')) { + throw error; + } + console.error('Token poll error:', error.message); + await new Promise(resolve => setTimeout(resolve, interval)); + } + } + + throw new Error('Device flow timed out - please try again'); + } + + async getAccessToken() { + await this.loadTokens(); + if (!this.tokens) { + throw new Error('Not authenticated. Please authenticate with Qwen first.'); + } + if (!this.isTokenValid()) { + try { + await this.refreshToken(); + } catch (error) { + throw new Error('Token expired. Please re-authenticate with Qwen.'); + } + } + return this.tokens.access_token; + } + + async checkAuth() { + const { exec } = await import('child_process'); + + // First check if we have OAuth tokens (needed for Vision API) + await this.loadTokens(); + if (this.tokens && this.tokens.access_token) { + if (this.isTokenValid()) { + return { authenticated: true, method: 'oauth', hasVisionSupport: true }; + } else { + // Try to refresh + try { + await this.refreshToken(); + return { authenticated: true, method: 'oauth', hasVisionSupport: true }; + } catch (e) { + // Token refresh failed, fall through to CLI check + } + } + } + + // Fall back to CLI check (works for text but not Vision) + return new Promise((resolve) => { + exec('qwen -p "ping" --help 2>&1', { timeout: 5000 }, (error, stdout, stderr) => { + // If qwen CLI exists, consider it authenticated (it manages its own auth) + if (!error || stdout.includes('help') || stdout.includes('Usage')) { + resolve({ authenticated: true, method: 'qwen-cli', hasVisionSupport: false }); + } else { + resolve({ authenticated: false, reason: 'qwen CLI not available or not authenticated' }); + } + }); + }); + } + + /** Send message using qwen CLI or Vision API for images + * @param {string} message - The message to send + * @param {string} model - The model to use + * @param {object} imageData - Optional image data + * @param {function} onChunk - Optional callback for streaming output (chunk) => void + */ + async sendMessage(message, model = 'qwen-coder-plus', imageData = null, onChunk = null) { + // If we have image data, always use the Vision API + if (imageData) { + console.log('📷 Image data detected, using Vision API...'); + return await this.sendVisionMessage(message, imageData, 'qwen-vl-plus'); + } + + // For text-only, use the CLI + const { spawn } = await import('child_process'); + const os = await import('os'); + const fsSync = await import('fs'); + + // CRITICAL: Prepend system context to prevent AI from getting confused about environment + const systemContext = `[SYSTEM CONTEXT - ALWAYS FOLLOW] +You are an AI System Administrator integrated into OpenQode. +IMPORTANT RULES: +1. You have FULL ACCESS to the local file system. +2. You ARE PERMITTED to create folders, move files, and run shell commands. +3. When asked to create files, use the >>> CREATE: syntax. +4. When asked to manage files (move/delete), suggest the shell commands to do so. +5. Execute instructions immediately. +[END SYSTEM CONTEXT] + +`; + + // Prepend system context ONLY for build/create commands (detected by keywords) + let finalMessage = message; + const lowerMsg = message.toLowerCase(); + if (message.includes('CREATE:') || + message.includes('ROLE:') || + message.includes('Generate all necessary files') || + lowerMsg.includes('open ') || + lowerMsg.includes('run ') || + lowerMsg.includes('computer use')) { + finalMessage = systemContext + message; + } + + return new Promise((resolve) => { + try { + // Debug log removed for clean UI + + // Run in current project directory to allow context access + const neutralCwd = process.cwd(); + + // WINDOWS FIX: Execute JS directly to avoid cmd.exe argument splitting limits/bugs + // We derived this path from `where qwen` -> qwen.cmd -> cli.js location + const isWin = process.platform === 'win32'; + let command = 'qwen'; + let args = ['-p', finalMessage]; + + if (isWin) { + const appData = process.env.APPDATA || ''; + const cliPath = path.join(appData, 'npm', 'node_modules', '@qwen-code', 'qwen-code', 'cli.js'); + if (fs.existsSync(cliPath)) { + command = 'node'; + args = [cliPath, '-p', finalMessage]; + } else { + // Fallback if standard path fails (though known to exist on this machine) + command = 'qwen.cmd'; + } + } + + // Use spawn with shell: false (REQUIRED for clean argument passing) + const child = spawn(command, args, { + cwd: neutralCwd, + shell: false, + env: { + ...process.env, + FORCE_COLOR: '0' + } + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + const chunk = data.toString(); + stdout += chunk; + // Stream output in real-time if callback provided + if (onChunk) { + onChunk(chunk); + } + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + // Clean up ANSI codes + const cleanResponse = stdout.replace(/[\u001b\u009b][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').trim(); + + // Debug log removed for clean UI + + if (cleanResponse) { + resolve({ + success: true, + response: cleanResponse, + usage: null + }); + } else { + resolve({ + success: false, + error: stderr || `CLI exited with code ${code}`, + response: '' + }); + } + }); + + child.on('error', (error) => { + console.error('Qwen CLI spawn error:', error.message); + resolve({ + success: false, + error: error.message || 'CLI execution failed', + response: '' + }); + }); + + // Timeout after 120 seconds for long prompts + setTimeout(() => { + child.kill('SIGTERM'); + resolve({ + success: false, + error: 'Request timed out (120s)', + response: '' + }); + }, 120000); + + } catch (error) { + console.error('Qwen CLI error:', error.message); + resolve({ + success: false, + error: error.message || 'CLI execution failed', + response: '' + }); + } + }); + } + + /** Send message with image to Qwen Vision API */ + async sendVisionMessage(message, imageData, model = 'qwen-vl-plus') { + try { + console.log('Sending vision message to Qwen VL API...'); + + // Get access token + const accessToken = await this.getAccessToken(); + + // Prepare the content array with image and text + const content = []; + + // Add image (base64 data URL) + if (imageData) { + content.push({ + type: 'image_url', + image_url: { + url: imageData // Already a data URL like "data:image/png;base64,..." + } + }); + } + + // Add text message + content.push({ + type: 'text', + text: message + }); + + const requestBody = { + model: model, + messages: [ + { + role: 'user', + content: content + } + ], + stream: false + }; + + const response = await fetch(QWEN_CHAT_API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + 'x-request-id': randomUUID() + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Vision API error:', response.status, errorText); + return { + success: false, + error: `Vision API error: ${response.status}`, + response: '' + }; + } + + const data = await response.json(); + const responseText = data.choices?.[0]?.message?.content || ''; + + console.log('Vision API response received:', responseText.substring(0, 100) + '...'); + + return { + success: true, + response: responseText, + usage: data.usage + }; + } catch (error) { + console.error('Vision API error:', error.message); + + // Provide helpful error message for auth issues + if (error.message.includes('authenticate') || error.message.includes('token')) { + return { + success: true, // Return as success with explanation + response: `⚠️ **Vision API Authentication Required** + +The Qwen Vision API needs OAuth authentication to analyze images. The current session is authenticated for the CLI, but Vision API requires a separate OAuth token. + +**To enable image analysis:** +1. Click "Authenticate Qwen" button to re-authenticate +2. Or describe what's in your image and I'll help without viewing it + +*Your image was received (${(imageData?.length / 1024).toFixed(1)} KB) but couldn't be processed without Vision API tokens.*`, + usage: null + }; + } + + return { + success: false, + error: error.message || 'Vision API failed', + response: '' + }; + } + } +} + +export { QwenOAuth };