From 787740620bf65c2ebcb849483be15f282e6a0a0f Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 5 Dec 2025 21:17:52 -0600 Subject: [PATCH] I'd be happy to help you create a clean update summary for your GitHub project! However, I don't see any input provided after "INPUT:" in your message. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Could you please share the changelog, commit history, or description of what has changed in your project since the last release? This could include: - Git commit messages - Pull request descriptions - A changelog file - A summary of new features, bug fixes, and improvements - Any other relevant information about the changes Once you provide this information, I'll create an exciting CHANGES section with clean, 10-word bullets and relevant emojis for each update! 🚀 --- README.md | 42 +-- package.json | 14 +- scripts/build-cli.mjs | 2 +- src/cli/commands/run-playbook.ts | 5 +- src/cli/commands/show-agent.ts | 104 +++++++ src/cli/index.ts | 18 +- src/cli/output/formatter.ts | 110 +++++++ .../components/AutoRunnerHelpModal.tsx | 58 +++- src/renderer/components/TabSwitcherModal.tsx | 3 +- .../TemplateAutocompleteDropdown.tsx | 95 ++++++ src/renderer/hooks/useTemplateAutocomplete.ts | 273 ++++++++++++++++++ 11 files changed, 687 insertions(+), 37 deletions(-) create mode 100644 src/cli/commands/show-agent.ts create mode 100644 src/renderer/components/TemplateAutocompleteDropdown.tsx create mode 100644 src/renderer/hooks/useTemplateAutocomplete.ts diff --git a/README.md b/README.md index 248bfda2..6581ff66 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ You can run separate batch processes in different Maestro sessions simultaneousl ## Command Line Interface -Maestro includes a CLI tool (`maestro-playbook`) for running playbooks from the command line, cron jobs, or CI/CD pipelines. The CLI requires Node.js (which you already have if you're using Claude Code). +Maestro includes a CLI tool (`maestro-cli`) for managing agents and running playbooks from the command line, cron jobs, or CI/CD pipelines. The CLI requires Node.js (which you already have if you're using Claude Code). ### Installation @@ -354,44 +354,50 @@ The CLI is bundled with Maestro as a JavaScript file. Create a shell wrapper to ```bash # macOS (after installing Maestro.app) -echo '#!/bin/bash\nnode "/Applications/Maestro.app/Contents/Resources/maestro-playbook.js" "$@"' | sudo tee /usr/local/bin/maestro-playbook && sudo chmod +x /usr/local/bin/maestro-playbook +echo '#!/bin/bash\nnode "/Applications/Maestro.app/Contents/Resources/maestro-cli.js" "$@"' | sudo tee /usr/local/bin/maestro-cli && sudo chmod +x /usr/local/bin/maestro-cli # Linux (deb/rpm installs to /opt) -echo '#!/bin/bash\nnode "/opt/Maestro/resources/maestro-playbook.js" "$@"' | sudo tee /usr/local/bin/maestro-playbook && sudo chmod +x /usr/local/bin/maestro-playbook +echo '#!/bin/bash\nnode "/opt/Maestro/resources/maestro-cli.js" "$@"' | sudo tee /usr/local/bin/maestro-cli && sudo chmod +x /usr/local/bin/maestro-cli # Windows (PowerShell as Administrator) - create a batch file @" @echo off -node "%ProgramFiles%\Maestro\resources\maestro-playbook.js" %* -"@ | Out-File -FilePath "$env:ProgramFiles\Maestro\maestro-playbook.cmd" -Encoding ASCII +node "%ProgramFiles%\Maestro\resources\maestro-cli.js" %* +"@ | Out-File -FilePath "$env:ProgramFiles\Maestro\maestro-cli.cmd" -Encoding ASCII ``` Alternatively, run directly with Node.js: ```bash -node "/Applications/Maestro.app/Contents/Resources/maestro-playbook.js" list groups +node "/Applications/Maestro.app/Contents/Resources/maestro-cli.js" list groups ``` ### Usage ```bash # List all groups -maestro-playbook list groups +maestro-cli list groups # List all agents -maestro-playbook list agents -maestro-playbook list agents --group +maestro-cli list agents +maestro-cli list agents --group + +# Show agent details (history, usage stats, cost) +maestro-cli show agent # List playbooks for an agent -maestro-playbook list playbooks --agent +maestro-cli list playbooks --agent + +# Show playbook details +maestro-cli show playbook # Run a playbook -maestro-playbook run --agent --playbook +maestro-cli run # Dry run (shows what would be executed) -maestro-playbook run --agent --playbook --dry-run +maestro-cli run --dry-run # Run without writing to history -maestro-playbook run --agent --playbook --no-history +maestro-cli run --no-history ``` ### JSON Output @@ -400,7 +406,7 @@ By default, commands output human-readable formatted text. Use `--json` for mach ```bash # Human-readable output (default) -maestro-playbook list groups +maestro-cli list groups GROUPS (2) 🎨 Frontend @@ -409,12 +415,12 @@ GROUPS (2) group-def456 # JSON output for scripting -maestro-playbook list groups --json +maestro-cli list groups --json {"type":"group","id":"group-abc123","name":"Frontend","emoji":"🎨","timestamp":...} {"type":"group","id":"group-def456","name":"Backend","emoji":"⚙️","timestamp":...} # Running a playbook with JSON streams events -maestro-playbook run -a -p --json +maestro-cli run --json {"type":"start","timestamp":...,"playbook":{...}} {"type":"document_start","timestamp":...,"document":"tasks.md","taskCount":5} {"type":"task_start","timestamp":...,"taskIndex":0} @@ -427,7 +433,7 @@ maestro-playbook run -a -p --json ```bash # Run a playbook every hour (use --json for log parsing) -0 * * * * /usr/local/bin/maestro-playbook run -a -p --json >> /var/log/maestro.jsonl 2>&1 +0 * * * * /usr/local/bin/maestro-cli run --json >> /var/log/maestro.jsonl 2>&1 ``` ### Requirements @@ -485,4 +491,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, architecture detai ## License -[MIT License](LICENSE) +[AGPL-3.0 License](LICENSE) diff --git a/package.json b/package.json index d909dd39..76976e4a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "url": "https://github.com/yourusername/maestro.git" }, "bin": { - "maestro-playbook": "./dist/cli/maestro-playbook.js" + "maestro-cli": "./dist/cli/maestro-cli.js" }, "scripts": { "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", @@ -67,8 +67,8 @@ "icon": "build/icon.icns", "extraResources": [ { - "from": "dist/cli/maestro-playbook.js", - "to": "maestro-playbook.js" + "from": "dist/cli/maestro-cli.js", + "to": "maestro-cli.js" } ] }, @@ -90,8 +90,8 @@ "icon": "build/icon.ico", "extraResources": [ { - "from": "dist/cli/maestro-playbook.js", - "to": "maestro-playbook.js" + "from": "dist/cli/maestro-cli.js", + "to": "maestro-cli.js" } ] }, @@ -105,8 +105,8 @@ "icon": "build/icon.png", "extraResources": [ { - "from": "dist/cli/maestro-playbook.js", - "to": "maestro-playbook.js" + "from": "dist/cli/maestro-cli.js", + "to": "maestro-cli.js" } ] }, diff --git a/scripts/build-cli.mjs b/scripts/build-cli.mjs index 6ee0aa70..518385c6 100644 --- a/scripts/build-cli.mjs +++ b/scripts/build-cli.mjs @@ -15,7 +15,7 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); -const outfile = path.join(rootDir, 'dist/cli/maestro-playbook.js'); +const outfile = path.join(rootDir, 'dist/cli/maestro-cli.js'); async function build() { console.log('Building CLI with esbuild...'); diff --git a/src/cli/commands/run-playbook.ts b/src/cli/commands/run-playbook.ts index 82862e6d..4c20b64f 100644 --- a/src/cli/commands/run-playbook.ts +++ b/src/cli/commands/run-playbook.ts @@ -9,14 +9,13 @@ import { emitError } from '../output/jsonl'; import { formatRunEvent, formatError, formatInfo, RunEvent } from '../output/formatter'; interface RunPlaybookOptions { - playbook: string; dryRun?: boolean; history?: boolean; // commander uses --no-history which becomes history: false json?: boolean; debug?: boolean; } -export async function runPlaybook(options: RunPlaybookOptions): Promise { +export async function runPlaybook(playbookId: string, options: RunPlaybookOptions): Promise { const useJson = options.json; try { @@ -36,7 +35,7 @@ export async function runPlaybook(options: RunPlaybookOptions): Promise { // Find playbook across all agents try { - const result = findPlaybookById(options.playbook); + const result = findPlaybookById(playbookId); playbook = result.playbook; agentId = result.agentId; } catch (error) { diff --git a/src/cli/commands/show-agent.ts b/src/cli/commands/show-agent.ts new file mode 100644 index 00000000..46022869 --- /dev/null +++ b/src/cli/commands/show-agent.ts @@ -0,0 +1,104 @@ +// Show agent command +// Displays detailed information about a specific agent including history and usage stats + +import { getSessionById, readHistory, readGroups } from '../services/storage'; +import { formatAgentDetail, formatError } from '../output/formatter'; + +interface ShowAgentOptions { + json?: boolean; +} + +export function showAgent(agentId: string, options: ShowAgentOptions): void { + try { + const agent = getSessionById(agentId); + + if (!agent) { + throw new Error(`Agent not found: ${agentId}`); + } + + // Get group name if agent belongs to a group + const groups = readGroups(); + const group = agent.groupId ? groups.find((g) => g.id === agent.groupId) : undefined; + + // Get history entries for this agent + const history = readHistory(undefined, agent.id); + + // Calculate aggregate stats from history + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheCreationTokens = 0; + let totalCost = 0; + let totalElapsedMs = 0; + let successCount = 0; + let failureCount = 0; + + for (const entry of history) { + if (entry.usageStats) { + totalInputTokens += entry.usageStats.inputTokens || 0; + totalOutputTokens += entry.usageStats.outputTokens || 0; + totalCacheReadTokens += entry.usageStats.cacheReadInputTokens || 0; + totalCacheCreationTokens += entry.usageStats.cacheCreationInputTokens || 0; + totalCost += entry.usageStats.totalCostUsd || 0; + } + if (entry.elapsedTimeMs) { + totalElapsedMs += entry.elapsedTimeMs; + } + if (entry.success === true) { + successCount++; + } else if (entry.success === false) { + failureCount++; + } + } + + // Get recent history (last 10 entries) + const recentHistory = history + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 10); + + const output = { + id: agent.id, + name: agent.name, + toolType: agent.toolType, + cwd: agent.cwd, + projectRoot: agent.projectRoot, + groupId: agent.groupId, + groupName: group?.name, + autoRunFolderPath: agent.autoRunFolderPath, + stats: { + historyEntries: history.length, + successCount, + failureCount, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheCreationTokens, + totalCost, + totalElapsedMs, + }, + recentHistory: recentHistory.map((entry) => ({ + id: entry.id, + type: entry.type, + timestamp: entry.timestamp, + summary: entry.summary, + success: entry.success, + elapsedTimeMs: entry.elapsedTimeMs, + cost: entry.usageStats?.totalCostUsd, + })), + }; + + if (options.json) { + console.log(JSON.stringify(output, null, 2)); + } else { + console.log(formatAgentDetail(output)); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + if (options.json) { + console.error(JSON.stringify({ error: message })); + } else { + console.error(formatError(message)); + } + process.exit(1); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index ee12a052..b7242829 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,19 +1,20 @@ #!/usr/bin/env node -// Maestro Playbook CLI -// Run Maestro playbooks from the command line +// Maestro CLI +// Command-line interface for Maestro import { Command } from 'commander'; import { listGroups } from './commands/list-groups'; import { listAgents } from './commands/list-agents'; import { listPlaybooks } from './commands/list-playbooks'; import { showPlaybook } from './commands/show-playbook'; +import { showAgent } from './commands/show-agent'; import { runPlaybook } from './commands/run-playbook'; const program = new Command(); program - .name('maestro-playbook') - .description('CLI for running Maestro playbooks') + .name('maestro-cli') + .description('Command-line interface for Maestro') .version('0.1.0'); // List commands @@ -42,6 +43,12 @@ list // Show command const show = program.command('show').description('Show details of a resource'); +show + .command('agent ') + .description('Show agent details including history and usage stats') + .option('--json', 'Output as JSON (for scripting)') + .action(showAgent); + show .command('playbook ') .description('Show detailed information about a playbook') @@ -50,9 +57,8 @@ show // Run command program - .command('run') + .command('run ') .description('Run a playbook') - .requiredOption('-p, --playbook ', 'Playbook ID') .option('--dry-run', 'Show what would be executed without running') .option('--no-history', 'Do not write history entries') .option('--json', 'Output as JSON lines (for scripting)') diff --git a/src/cli/output/formatter.ts b/src/cli/output/formatter.ts index fbf27c6c..af45c29d 100644 --- a/src/cli/output/formatter.ts +++ b/src/cli/output/formatter.ts @@ -380,6 +380,116 @@ export function formatRunEvent(event: RunEvent): string { } } +// Agent detail formatting +export interface AgentDetailDisplay { + id: string; + name: string; + toolType: string; + cwd: string; + projectRoot: string; + groupId?: string; + groupName?: string; + autoRunFolderPath?: string; + stats: { + historyEntries: number; + successCount: number; + failureCount: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheCreationTokens: number; + totalCost: number; + totalElapsedMs: number; + }; + recentHistory: { + id: string; + type: string; + timestamp: number; + summary: string; + success?: boolean; + elapsedTimeMs?: number; + cost?: number; + }[]; +} + +function formatTokens(count: number): string { + if (count >= 1_000_000) { + return `${(count / 1_000_000).toFixed(1)}M`; + } else if (count >= 1_000) { + return `${(count / 1_000).toFixed(1)}K`; + } + return count.toString(); +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`; + return `${(ms / 3600_000).toFixed(1)}h`; +} + +export function formatAgentDetail(agent: AgentDetailDisplay): string { + const lines: string[] = []; + + // Header + lines.push(bold(c('cyan', 'AGENT'))); + lines.push(''); + + // Basic info + lines.push(` ${c('white', 'Name:')} ${agent.name}`); + lines.push(` ${c('white', 'ID:')} ${agent.id}`); + lines.push(` ${c('white', 'Type:')} ${c('green', agent.toolType)}`); + lines.push(` ${c('white', 'Directory:')} ${dim(agent.cwd)}`); + + if (agent.groupName) { + lines.push(` ${c('white', 'Group:')} ${agent.groupName}`); + } + + if (agent.autoRunFolderPath) { + lines.push(` ${c('white', 'Auto Run:')} ${dim(agent.autoRunFolderPath)}`); + } + + lines.push(''); + + // Stats + lines.push(bold(c('cyan', 'USAGE STATS'))); + lines.push(''); + + const { stats } = agent; + const successRate = stats.historyEntries > 0 + ? ((stats.successCount / stats.historyEntries) * 100).toFixed(0) + : '0'; + + lines.push(` ${c('white', 'Sessions:')} ${stats.historyEntries} total ${dim(`(${stats.successCount} success, ${stats.failureCount} failed, ${successRate}% success rate)`)}`); + lines.push(` ${c('white', 'Total Cost:')} ${c('yellow', `$${stats.totalCost.toFixed(4)}`)}`); + lines.push(` ${c('white', 'Total Time:')} ${formatDuration(stats.totalElapsedMs)}`); + lines.push(''); + lines.push(` ${c('white', 'Tokens:')} ${dim('Input:')} ${formatTokens(stats.totalInputTokens)} ${dim('Output:')} ${formatTokens(stats.totalOutputTokens)}`); + lines.push(` ${c('white', 'Cache:')} ${dim('Read:')} ${formatTokens(stats.totalCacheReadTokens)} ${dim('Created:')} ${formatTokens(stats.totalCacheCreationTokens)}`); + + // Recent history + if (agent.recentHistory.length > 0) { + lines.push(''); + lines.push(bold(c('cyan', 'RECENT HISTORY')) + dim(` (last ${agent.recentHistory.length})`)); + lines.push(''); + + for (const entry of agent.recentHistory) { + const date = new Date(entry.timestamp); + const dateStr = date.toLocaleDateString(); + const timeStr = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const icon = entry.success === true ? c('green', '✓') : entry.success === false ? c('red', '✗') : c('gray', '•'); + const typeLabel = c('gray', `[${entry.type}]`); + const summary = truncate(entry.summary, 50); + const costStr = entry.cost !== undefined ? dim(` $${entry.cost.toFixed(4)}`) : ''; + const timeElapsed = entry.elapsedTimeMs ? dim(` ${formatDuration(entry.elapsedTimeMs)}`) : ''; + + lines.push(` ${icon} ${dim(`${dateStr} ${timeStr}`)} ${typeLabel} ${summary}${costStr}${timeElapsed}`); + } + } + + return lines.join('\n'); +} + // Error formatting export function formatError(message: string): string { return `${c('red', '✗')} ${c('red', 'Error:')} ${message}`; diff --git a/src/renderer/components/AutoRunnerHelpModal.tsx b/src/renderer/components/AutoRunnerHelpModal.tsx index a1f37974..e9a3d76f 100644 --- a/src/renderer/components/AutoRunnerHelpModal.tsx +++ b/src/renderer/components/AutoRunnerHelpModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { X, FolderOpen, FileText, CheckSquare, Play, Settings, History, Eye, Square, Keyboard, Repeat, RotateCcw, BookMarked, GitBranch, Image } from 'lucide-react'; +import { X, FolderOpen, FileText, CheckSquare, Play, Settings, History, Eye, Square, Keyboard, Repeat, RotateCcw, BookMarked, GitBranch, Image, Variable } from 'lucide-react'; import type { Theme } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -208,6 +208,62 @@ export function AutoRunnerHelpModal({ theme, onClose }: AutoRunnerHelpModalProps + {/* Template Variables */} +
+
+ +

Template Variables

+
+
+

+ Use template variables in your documents and agent prompts to inject dynamic values + at runtime. Variables are replaced with actual values before being sent to the AI. +

+
+ + + Quick Insert: Type{' '} + + {'{{'} + {' '} + to open an autocomplete dropdown with all available variables. + +
+

+ Available variables: +

+
+
{'{{SESSION_NAME}}'} — Agent/session name
+
{'{{PROJECT_PATH}}'} — Project directory path
+
{'{{GIT_BRANCH}}'} — Current git branch
+
{'{{DATE}}'} — Current date (YYYY-MM-DD)
+
{'{{LOOP_NUMBER}}'} — Current loop iteration
+
{'{{DOCUMENT_NAME}}'} — Current document name
+
...and more
+
+

+ Variables work in both the agent prompt (in Playbook settings) + and within document content. Use them to create + reusable templates that adapt to different contexts. +

+
+
+ {/* Running Single Document */}
diff --git a/src/renderer/components/TabSwitcherModal.tsx b/src/renderer/components/TabSwitcherModal.tsx index 6d997bfb..6e8e8eb7 100644 --- a/src/renderer/components/TabSwitcherModal.tsx +++ b/src/renderer/components/TabSwitcherModal.tsx @@ -5,6 +5,7 @@ import { fuzzyMatchWithScore } from '../utils/search'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { getContextColor } from '../utils/theme'; +import { formatShortcutKeys } from '../utils/shortcutFormatter'; /** Named session from the store (not currently open) */ interface NamedSession { @@ -418,7 +419,7 @@ export function TabSwitcherModal({
{shortcut && ( - {shortcut.keys.join('+')} + {formatShortcutKeys(shortcut.keys)} )}
void; +} + +/** + * Dropdown component that displays template variable suggestions. + * Used by both AgentPromptComposerModal and AutoRun document editor. + */ +export const TemplateAutocompleteDropdown = forwardRef( + function TemplateAutocompleteDropdown({ theme, state, onSelect }, ref) { + if (!state.isOpen || state.filteredVariables.length === 0) { + return null; + } + + return ( +
+
+ {state.filteredVariables.map((item, index) => ( +
onSelect(item.variable)} + onMouseEnter={(e) => { + // Update visual selection on hover + const target = e.currentTarget; + target.style.backgroundColor = theme.colors.bgActivity; + }} + onMouseLeave={(e) => { + // Reset unless this is the selected item + const target = e.currentTarget; + if (index !== state.selectedIndex) { + target.style.backgroundColor = 'transparent'; + } + }} + > + + {item.variable} + + + {item.description} + +
+ ))} +
+
+ ↑↓ + {' '}navigate{' '} + Tab + {' '}select{' '} + Esc + {' '}close +
+
+ ); + } +); diff --git a/src/renderer/hooks/useTemplateAutocomplete.ts b/src/renderer/hooks/useTemplateAutocomplete.ts new file mode 100644 index 00000000..d7a03432 --- /dev/null +++ b/src/renderer/hooks/useTemplateAutocomplete.ts @@ -0,0 +1,273 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { TEMPLATE_VARIABLES } from '../utils/templateVariables'; + +export interface AutocompleteState { + isOpen: boolean; + position: { top: number; left: number }; + selectedIndex: number; + searchText: string; + filteredVariables: typeof TEMPLATE_VARIABLES; +} + +interface UseTemplateAutocompleteProps { + textareaRef: React.RefObject; + value: string; + onChange: (value: string) => void; +} + +interface UseTemplateAutocompleteReturn { + autocompleteState: AutocompleteState; + handleKeyDown: (e: React.KeyboardEvent) => boolean; + handleChange: (e: React.ChangeEvent) => void; + selectVariable: (variable: string) => void; + closeAutocomplete: () => void; + autocompleteRef: React.RefObject; +} + +const INITIAL_STATE: AutocompleteState = { + isOpen: false, + position: { top: 0, left: 0 }, + selectedIndex: 0, + searchText: '', + filteredVariables: TEMPLATE_VARIABLES, +}; + +/** + * Hook for template variable autocomplete functionality. + * Shows a dropdown when user types "{{" and allows selection of template variables. + */ +export function useTemplateAutocomplete({ + textareaRef, + value, + onChange, +}: UseTemplateAutocompleteProps): UseTemplateAutocompleteReturn { + const [autocompleteState, setAutocompleteState] = useState(INITIAL_STATE); + const autocompleteRef = useRef(null); + const triggerPositionRef = useRef(null); + + // Filter variables based on search text + const filterVariables = useCallback((searchText: string) => { + if (!searchText) { + return TEMPLATE_VARIABLES; + } + const search = searchText.toLowerCase(); + return TEMPLATE_VARIABLES.filter( + (v) => + v.variable.toLowerCase().includes(search) || + v.description.toLowerCase().includes(search) + ); + }, []); + + // Calculate position for the dropdown + const calculatePosition = useCallback((textarea: HTMLTextAreaElement, cursorPos: number) => { + // Create a mirror div to measure text position + const mirror = document.createElement('div'); + const style = window.getComputedStyle(textarea); + + // Copy relevant styles + mirror.style.cssText = ` + position: absolute; + visibility: hidden; + white-space: pre-wrap; + word-wrap: break-word; + font-family: ${style.fontFamily}; + font-size: ${style.fontSize}; + line-height: ${style.lineHeight}; + padding: ${style.padding}; + border: ${style.border}; + width: ${textarea.clientWidth}px; + box-sizing: border-box; + `; + + // Get text up to cursor + const textBeforeCursor = textarea.value.substring(0, cursorPos); + mirror.textContent = textBeforeCursor; + + // Add a span at the cursor position + const span = document.createElement('span'); + span.textContent = '|'; + mirror.appendChild(span); + + document.body.appendChild(mirror); + + const textareaRect = textarea.getBoundingClientRect(); + const spanRect = span.getBoundingClientRect(); + const mirrorRect = mirror.getBoundingClientRect(); + + document.body.removeChild(mirror); + + // Calculate position relative to textarea + const relativeTop = spanRect.top - mirrorRect.top; + const relativeLeft = spanRect.left - mirrorRect.left; + + // Account for scroll + const scrollTop = textarea.scrollTop; + + return { + top: relativeTop - scrollTop + parseInt(style.lineHeight || '20', 10) + 4, + left: Math.min(relativeLeft, textarea.clientWidth - 250), // Prevent overflow + }; + }, []); + + // Open autocomplete dropdown + const openAutocomplete = useCallback((textarea: HTMLTextAreaElement, cursorPos: number) => { + triggerPositionRef.current = cursorPos - 2; // Position before "{{" + const position = calculatePosition(textarea, cursorPos); + setAutocompleteState({ + isOpen: true, + position, + selectedIndex: 0, + searchText: '', + filteredVariables: TEMPLATE_VARIABLES, + }); + }, [calculatePosition]); + + // Close autocomplete dropdown + const closeAutocomplete = useCallback(() => { + setAutocompleteState(INITIAL_STATE); + triggerPositionRef.current = null; + }, []); + + // Select a variable and insert it + const selectVariable = useCallback((variable: string) => { + if (!textareaRef.current || triggerPositionRef.current === null) return; + + const textarea = textareaRef.current; + const triggerPos = triggerPositionRef.current; + const cursorPos = textarea.selectionStart; + + // Replace from trigger position to current cursor with the variable + const before = value.substring(0, triggerPos); + const after = value.substring(cursorPos); + const newValue = before + variable + after; + + onChange(newValue); + + // Move cursor to after the inserted variable + const newCursorPos = triggerPos + variable.length; + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }); + + closeAutocomplete(); + }, [textareaRef, value, onChange, closeAutocomplete]); + + // Handle key down events + const handleKeyDown = useCallback((e: React.KeyboardEvent): boolean => { + if (!autocompleteState.isOpen) return false; + + const { filteredVariables, selectedIndex } = autocompleteState; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setAutocompleteState((prev) => ({ + ...prev, + selectedIndex: Math.min(prev.selectedIndex + 1, filteredVariables.length - 1), + })); + return true; + + case 'ArrowUp': + e.preventDefault(); + setAutocompleteState((prev) => ({ + ...prev, + selectedIndex: Math.max(prev.selectedIndex - 1, 0), + })); + return true; + + case 'Enter': + case 'Tab': + if (filteredVariables.length > 0) { + e.preventDefault(); + selectVariable(filteredVariables[selectedIndex].variable); + return true; + } + break; + + case 'Escape': + e.preventDefault(); + closeAutocomplete(); + return true; + } + + return false; + }, [autocompleteState, selectVariable, closeAutocomplete]); + + // Handle text change + const handleChange = useCallback((e: React.ChangeEvent) => { + const textarea = e.target; + const newValue = textarea.value; + const cursorPos = textarea.selectionStart; + + onChange(newValue); + + // Check if we should open/update autocomplete + if (autocompleteState.isOpen && triggerPositionRef.current !== null) { + // Update search text based on what's typed after "{{" + const textAfterTrigger = newValue.substring(triggerPositionRef.current + 2, cursorPos); + + // Close if user deleted back past the trigger or typed "}}" + if (cursorPos <= triggerPositionRef.current + 1 || textAfterTrigger.includes('}}')) { + closeAutocomplete(); + return; + } + + const filtered = filterVariables(textAfterTrigger); + setAutocompleteState((prev) => ({ + ...prev, + searchText: textAfterTrigger, + filteredVariables: filtered, + selectedIndex: Math.min(prev.selectedIndex, Math.max(0, filtered.length - 1)), + })); + } else { + // Check if user just typed "{{" + const textBeforeCursor = newValue.substring(0, cursorPos); + if (textBeforeCursor.endsWith('{{')) { + openAutocomplete(textarea, cursorPos); + } + } + }, [autocompleteState.isOpen, onChange, filterVariables, openAutocomplete, closeAutocomplete]); + + // Scroll selected item into view + useEffect(() => { + if (autocompleteState.isOpen && autocompleteRef.current) { + const selectedElement = autocompleteRef.current.querySelector( + `[data-index="${autocompleteState.selectedIndex}"]` + ); + if (selectedElement) { + selectedElement.scrollIntoView({ block: 'nearest' }); + } + } + }, [autocompleteState.selectedIndex, autocompleteState.isOpen]); + + // Close on click outside + useEffect(() => { + if (!autocompleteState.isOpen) return; + + const handleClickOutside = (e: MouseEvent) => { + if ( + autocompleteRef.current && + !autocompleteRef.current.contains(e.target as Node) && + textareaRef.current && + !textareaRef.current.contains(e.target as Node) + ) { + closeAutocomplete(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [autocompleteState.isOpen, closeAutocomplete, textareaRef]); + + return { + autocompleteState, + handleKeyDown, + handleChange, + selectVariable, + closeAutocomplete, + autocompleteRef, + }; +}