diff --git a/src/ai-repo-commander.user.js b/src/ai-repo-commander.user.js index 9b65a98..83d87b5 100644 --- a/src/ai-repo-commander.user.js +++ b/src/ai-repo-commander.user.js @@ -83,6 +83,13 @@ QUEUE_MAX_PER_MINUTE: 15, QUEUE_MAX_PER_MESSAGE: 5, QUEUE_WAIT_FOR_COMPOSER_MS: 6000, + + RESPONSE_BUFFER_FLUSH_DELAY_MS: 500, // wait for siblings to finish + RESPONSE_BUFFER_SECTION_HEADINGS: true, + + MAX_PASTE_CHARS: 250_000, // hard cap per message + SPLIT_LONG_RESPONSES: true, // enable multi-message split + }; function loadSavedConfig() { @@ -1110,17 +1117,67 @@ // ---------------------- UI feedback ---------------------- class UIFeedback { - static appendStatus(sourceElement, templateType, data) { - const statusElement = this.createStatusElement(templateType, data); - const existing = sourceElement.querySelector('.ai-repo-commander-status'); - if (existing) existing.remove(); - sourceElement.appendChild(statusElement); + static ensureBoard(containerEl) { + let board = containerEl.querySelector('.ai-rc-status-board'); + if (!board) { + board = document.createElement('div'); + board.className = 'ai-rc-status-board'; + board.style.cssText = ` + margin:10px 0;padding:8px;border:1px solid rgba(255,255,255,0.15); + border-radius:6px;background:rgba(255,255,255,0.06);font-family:monospace; + `; + containerEl.appendChild(board); + } + return board; } + + static appendStatus(containerEl, templateType, data) { + // Back-compat: when no key provided, fall through to single-line behavior + if (!data || !data.key) { + const statusElement = this.createStatusElement(templateType, data); + const existing = containerEl.querySelector('.ai-repo-commander-status'); + if (existing) existing.remove(); + statusElement.classList.add('ai-repo-commander-status'); + containerEl.appendChild(statusElement); + return; + } + // Multi-line board (preferred) + const board = this.ensureBoard(containerEl); + const entry = this.upsertEntry(board, data.key); + entry.textContent = this.renderLine(templateType, data); + entry.dataset.state = templateType; + entry.style.borderLeft = `4px solid ${this.color(templateType)}`; + } + + static upsertEntry(board, key) { + let el = board.querySelector(`[data-entry-key="${key}"]`); + if (!el) { + el = document.createElement('div'); + el.dataset.entryKey = key; + el.style.cssText = ` + padding:6px 8px;margin:4px 0;border-left:4px solid transparent; + background:rgba(0,0,0,0.15);border-radius:4px; + white-space:pre-wrap;word-break:break-word; + `; + board.appendChild(el); + } + return el; + } + + static renderLine(templateType, data) { + const { action, details, label } = data || {}; + const state = ({ + SUCCESS:'Success', ERROR:'Error', VALIDATION_ERROR:'Invalid', + EXECUTING:'Processing...', MOCK:'Mock' + })[templateType] || templateType; + const left = label || action || 'Command'; + return `${left} — ${state}${details ? `: ${details}` : ''}`; + } + static createStatusElement(templateType, data) { const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR; const message = template.replace('{action}', data.action).replace('{details}', data.details); const el = document.createElement('div'); - el.className = 'ai-repo-commander-status'; el.textContent = message; el.style.cssText = ` padding: 8px 12px; margin: 10px 0; border-radius: 4px; @@ -1130,6 +1187,7 @@ `; return el; } + static color(t) { const c = { SUCCESS:'#10B981', ERROR:'#EF4444', VALIDATION_ERROR:'#F59E0B', EXECUTING:'#3B82F6', MOCK:'#8B5CF6' }; return c[t] || '#6B7280'; @@ -1303,7 +1361,14 @@ try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(payload, { type: 'text', mimetype: 'text/plain' }); - GM_notification({ title: 'AI Repo Commander', text: 'Content copied to clipboard — press Ctrl/Cmd+V to paste.', timeout: 5000 }); + RC_DEBUG?.warn('📋 Clipboard fallback used — manual paste may be required', { + length: payload.length + }); + GM_notification({ + title: 'AI Repo Commander', + text: 'Content copied to clipboard — press Ctrl/Cmd+V to paste.', + timeout: 5000 + }); RC_DEBUG?.info('✅ Paste method succeeded: GM_setClipboard (manual paste required)'); } } catch (e) { @@ -1457,24 +1522,35 @@ // ---------------------- Execution ---------------------- class ExecutionManager { - static async executeCommand(command, sourceElement) { + static async executeCommand(command, sourceElement, renderKey = '', label = '') { try { if ((command.action === 'update_file' || command.action === 'create_file') && !command.commit_message) { command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`; } - if (!CONFIG.ENABLE_API) return this.mockExecution(command, sourceElement); + if (!CONFIG.ENABLE_API) { + UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Mocking...', key: renderKey, label }); + const res = await this.mockExecution(command, sourceElement, renderKey, label); + return res; + } - UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...' }); + UIFeedback.appendStatus(sourceElement, 'EXECUTING', { action: command.action, details: 'Making API request...', key: renderKey, label }); const res = await this.makeAPICallWithRetry(command); - return this.handleSuccess(res, command, sourceElement); + return this.handleSuccess(res, command, sourceElement, false, renderKey, label); } catch (error) { - return this.handleError(error, command, sourceElement); + return this.handleError(error, command, sourceElement, renderKey, label); } } + static async mockExecution(command, sourceElement, renderKey = '', label = '') { + await this.delay(500); + const mock = { status: 200, responseText: JSON.stringify({ success: true, message: `Mock execution completed for ${command.action}` }) }; + return this.handleSuccess(mock, command, sourceElement, true, renderKey, label); + } + + static async makeAPICallWithRetry(command, attempt = 0) { try { requireBridgeKeyIfNeeded(); @@ -1519,18 +1595,6 @@ }); } - static async mockExecution(command, sourceElement) { - await this.delay(500); - const mock = { - status: 200, - responseText: JSON.stringify({ - success: true, - message: `Mock execution completed for ${command.action}`, - data: { command: command.action, repo: command.repo, path: command.path, commit_message: command.commit_message } - }) - }; - return this.handleSuccess(mock, command, sourceElement, true); - } static _extractGetFileBody(payload) { const item = Array.isArray(payload) ? payload[0] : payload; @@ -1571,20 +1635,21 @@ return '```text\n' + lines.join('\n') + '\n```'; } - static async handleSuccess(response, command, sourceElement, isMock = false) { - let data; - try { data = JSON.parse(response.responseText || '{}'); } + static async handleSuccess(response, command, sourceElement, isMock = false, renderKey = '', label = '') { + let data; try { data = JSON.parse(response.responseText || '{}'); } catch { data = { message: 'Operation completed (no JSON body)' }; } UIFeedback.appendStatus(sourceElement, isMock ? 'MOCK' : 'SUCCESS', { action: command.action, - details: data.message || 'Operation completed successfully' + details: data.message || 'Operation completed successfully', + key: renderKey, + label }); if (command.action === 'get_file') { const body = this._extractGetFileBody(data); if (typeof body === 'string' && body.length) { - await pasteAndMaybeSubmit(body); + RESP_BUFFER.push({ label, content: body }); } else { GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 }); } @@ -1594,21 +1659,27 @@ const files = this._extractFilesArray(data); if (files && files.length) { const listing = this._formatFilesListing(files); - await pasteAndMaybeSubmit(listing); + RESP_BUFFER.push({ label, content: listing }); } else { const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```'; - await pasteAndMaybeSubmit(fallback); - GM_notification({ title: 'AI Repo Commander', text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', timeout: 5000 }); + RESP_BUFFER.push({ label, content: fallback }); + GM_notification({ + title: 'AI Repo Commander', + text: 'list_files succeeded, but response had no obvious files array. Pasted raw JSON.', + timeout: 5000 + }); } } return { success: true, data, isMock }; } - static handleError(error, command, sourceElement) { + static handleError(error, command, sourceElement, renderKey = '', label = '') { UIFeedback.appendStatus(sourceElement, 'ERROR', { action: command.action || 'Command', - details: error.message + details: error.message, + key: renderKey, + label }); return { success: false, error: error.message }; } @@ -1670,6 +1741,114 @@ cancelOne: (cb) => execQueue.cancelOne(cb), }; + function chunkByLines(s, limit) { + const out = []; + let start = 0; + while (start < s.length) { + const endSoft = s.lastIndexOf('\n', Math.min(start + limit, s.length)); + const end = endSoft > start ? endSoft + 1 : Math.min(start + limit, s.length); + out.push(s.slice(start, end)); + start = end; + } + return out; + } + + function isSingleFencedBlock(s) { + return /^```[^\n]*\n[\s\S]*\n```$/.test(s.trim()); + } + + function splitRespectingCodeFence(text, limit) { + const trimmed = text.trim(); + if (!isSingleFencedBlock(trimmed)) { + // Not a single fence → just line-friendly chunking + return chunkByLines(text, limit); + } + // Extract inner payload & language hint + const m = /^```([^\n]*)\n([\s\S]*)\n```$/.exec(trimmed); + const lang = (m?.[1] || 'text').trim(); + const inner = m?.[2] ?? ''; + const chunks = chunkByLines(inner, limit - 16 - lang.length); // budget for fences + return chunks.map(c => '```' + lang + '\n' + c.replace(/\n?$/, '\n') + '```'); + } + + // ---------------------- ResponseBuffer ---------------------- + class ResponseBuffer { + constructor() { + this.pending = []; // { label, content } + this.timer = null; + this.flushing = false; + } + + push(item) { + if (!item || !item.content) return; + this.pending.push(item); + this.scheduleFlush(); + } + + scheduleFlush() { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => this.flush(), CONFIG.RESPONSE_BUFFER_FLUSH_DELAY_MS || 500); + } + + buildCombined() { + const parts = []; + for (const { label, content } of this.pending) { + if (CONFIG.RESPONSE_BUFFER_SECTION_HEADINGS && label) { + parts.push(`### ${label}\n`); + } + parts.push(String(content).trimEnd()); + parts.push(''); // blank line between sections + } + return parts.join('\n'); + } + + async flush() { + if (this.flushing) return; + if (!this.pending.length) return; + this.flushing = true; + + const toPaste = this.buildCombined(); + this.pending.length = 0; // clear + + try { + const limit = CONFIG.MAX_PASTE_CHARS || 250_000; + + if (CONFIG.SPLIT_LONG_RESPONSES && toPaste.length > limit) { + const chunks = splitRespectingCodeFence(toPaste, limit); + + RC_DEBUG?.warn(`Splitting long response into ${chunks.length} message(s)`, { + totalChars: toPaste.length, perChunkLimit: limit + }); + + chunks.forEach((chunk, i) => { + const header = CONFIG.RESPONSE_BUFFER_SECTION_HEADINGS + ? `### Part ${i+1}/${chunks.length}\n` + : ''; + const payload = header + chunk; + + execQueue.push(async () => { + await pasteAndMaybeSubmit(payload); + }); + }); + + return; // done: queued as multiple messages + } + + // Normal single-message path + execQueue.push(async () => { + await pasteAndMaybeSubmit(toPaste); + }); + + } finally { + this.flushing = false; + } + } + + } + + const RESP_BUFFER = new ResponseBuffer(); + window.AI_REPO_RESPONSES = RESP_BUFFER; // optional debug handle + // ---------------------- Bridge Key ---------------------- let BRIDGE_KEY = null; @@ -1979,37 +2158,32 @@ const messageId = this.getReadableMessageId(element); const subId = `${messageId}#${idx + 1}`; - // track this sub-command so updateState/attachRetryUI can work - this.trackedMessages.set(subId, { - element, - originalText: hit.text, - state: COMMAND_STATES.DETECTED, - startTime: Date.now(), - lastUpdate: Date.now(), - cancelToken: { cancelled: false }, - }); - execQueue.push(async () => { - // optional tiny settle for streaming - await ExecutionManager.delay(CONFIG.SETTLE_POLL_MS); - - const allNow = findAllCommandsInMessage(element); - const liveForIdx = allNow[idx]?.text; - const finalTxt = (liveForIdx && this.isCompleteCommandText(liveForIdx)) ? liveForIdx : hit.text; - + const finalTxt = hit.text; // <<< ADD THIS let parsed; try { parsed = CommandParser.parseYAMLCommand(finalTxt); const val = CommandParser.validateStructure(parsed); if (!val.isValid) throw new Error(`Validation failed: ${val.errors.join(', ')}`); } catch (err) { - UIFeedback.appendStatus(element, 'ERROR', { action: 'Command', details: err.message }); + UIFeedback.appendStatus(element, 'ERROR', { + action: 'Command', + details: err.message, + key: subId, // <<< key per sub-command + label: `[${idx+1}] parse` + }); this.attachRetryUI(element, subId); return; } this.updateState(subId, COMMAND_STATES.EXECUTING); - const res = await ExecutionManager.executeCommand(parsed, element); + const res = await ExecutionManager.executeCommand( + parsed, + element, + /* renderKey: */ subId, // <<< pass key down + /* label: */ `[${idx+1}] ${this.extractAction(finalTxt)}` + ); + if (!res || res.success === false) { this.updateState(subId, COMMAND_STATES.ERROR); this.attachRetryUI(element, subId); @@ -2018,6 +2192,7 @@ this.updateState(subId, COMMAND_STATES.COMPLETE); }); } + isAssistantMessage(el) { if (!CONFIG.ASSISTANT_ONLY) return true; const host = location.hostname; @@ -2214,7 +2389,11 @@ // 4) Execute this.updateState(messageId, COMMAND_STATES.EXECUTING); - const result = await ExecutionManager.executeCommand(parsed, message.element); + + const action = parsed?.action || 'unknown'; + const renderKey = `${messageId}#1`; + const label = `[1] ${action}`; + const result = await ExecutionManager.executeCommand(parsed, message.element, renderKey, label); if (!result || result.success === false) { RC_DEBUG?.warn('Execution reported failure', { messageId });