Update src/ai-repo-commander.user.js

This commit is contained in:
rob 2025-10-10 18:41:50 +00:00
parent 9ddf1f891f
commit dd0427b598
1 changed files with 233 additions and 54 deletions

View File

@ -83,6 +83,13 @@
QUEUE_MAX_PER_MINUTE: 15, QUEUE_MAX_PER_MINUTE: 15,
QUEUE_MAX_PER_MESSAGE: 5, QUEUE_MAX_PER_MESSAGE: 5,
QUEUE_WAIT_FOR_COMPOSER_MS: 6000, 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() { function loadSavedConfig() {
@ -1110,17 +1117,67 @@
// ---------------------- UI feedback ---------------------- // ---------------------- UI feedback ----------------------
class UIFeedback { class UIFeedback {
static appendStatus(sourceElement, templateType, data) { static ensureBoard(containerEl) {
const statusElement = this.createStatusElement(templateType, data); let board = containerEl.querySelector('.ai-rc-status-board');
const existing = sourceElement.querySelector('.ai-repo-commander-status'); if (!board) {
if (existing) existing.remove(); board = document.createElement('div');
sourceElement.appendChild(statusElement); 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) { static createStatusElement(templateType, data) {
const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR; const template = STATUS_TEMPLATES[templateType] || STATUS_TEMPLATES.ERROR;
const message = template.replace('{action}', data.action).replace('{details}', data.details); const message = template.replace('{action}', data.action).replace('{details}', data.details);
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'ai-repo-commander-status';
el.textContent = message; el.textContent = message;
el.style.cssText = ` el.style.cssText = `
padding: 8px 12px; margin: 10px 0; border-radius: 4px; padding: 8px 12px; margin: 10px 0; border-radius: 4px;
@ -1130,6 +1187,7 @@
`; `;
return el; return el;
} }
static color(t) { static color(t) {
const c = { SUCCESS:'#10B981', ERROR:'#EF4444', VALIDATION_ERROR:'#F59E0B', EXECUTING:'#3B82F6', MOCK:'#8B5CF6' }; const c = { SUCCESS:'#10B981', ERROR:'#EF4444', VALIDATION_ERROR:'#F59E0B', EXECUTING:'#3B82F6', MOCK:'#8B5CF6' };
return c[t] || '#6B7280'; return c[t] || '#6B7280';
@ -1303,7 +1361,14 @@
try { try {
if (typeof GM_setClipboard === 'function') { if (typeof GM_setClipboard === 'function') {
GM_setClipboard(payload, { type: 'text', mimetype: 'text/plain' }); 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)'); RC_DEBUG?.info('✅ Paste method succeeded: GM_setClipboard (manual paste required)');
} }
} catch (e) { } catch (e) {
@ -1457,24 +1522,35 @@
// ---------------------- Execution ---------------------- // ---------------------- Execution ----------------------
class ExecutionManager { class ExecutionManager {
static async executeCommand(command, sourceElement) { static async executeCommand(command, sourceElement, renderKey = '', label = '') {
try { try {
if ((command.action === 'update_file' || command.action === 'create_file') && !command.commit_message) { if ((command.action === 'update_file' || command.action === 'create_file') && !command.commit_message) {
command.commit_message = `AI Repo Commander: ${command.path} (${new Date().toISOString()})`; 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); const res = await this.makeAPICallWithRetry(command);
return this.handleSuccess(res, command, sourceElement); return this.handleSuccess(res, command, sourceElement, false, renderKey, label);
} catch (error) { } 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) { static async makeAPICallWithRetry(command, attempt = 0) {
try { try {
requireBridgeKeyIfNeeded(); 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) { static _extractGetFileBody(payload) {
const item = Array.isArray(payload) ? payload[0] : payload; const item = Array.isArray(payload) ? payload[0] : payload;
@ -1571,20 +1635,21 @@
return '```text\n' + lines.join('\n') + '\n```'; return '```text\n' + lines.join('\n') + '\n```';
} }
static async handleSuccess(response, command, sourceElement, isMock = false) { static async handleSuccess(response, command, sourceElement, isMock = false, renderKey = '', label = '') {
let data; let data; try { data = JSON.parse(response.responseText || '{}'); }
try { data = JSON.parse(response.responseText || '{}'); }
catch { data = { message: 'Operation completed (no JSON body)' }; } catch { data = { message: 'Operation completed (no JSON body)' }; }
UIFeedback.appendStatus(sourceElement, isMock ? 'MOCK' : 'SUCCESS', { UIFeedback.appendStatus(sourceElement, isMock ? 'MOCK' : 'SUCCESS', {
action: command.action, action: command.action,
details: data.message || 'Operation completed successfully' details: data.message || 'Operation completed successfully',
key: renderKey,
label
}); });
if (command.action === 'get_file') { if (command.action === 'get_file') {
const body = this._extractGetFileBody(data); const body = this._extractGetFileBody(data);
if (typeof body === 'string' && body.length) { if (typeof body === 'string' && body.length) {
await pasteAndMaybeSubmit(body); RESP_BUFFER.push({ label, content: body });
} else { } else {
GM_notification({ title: 'AI Repo Commander', text: 'get_file succeeded, but no content to paste.', timeout: 4000 }); 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); const files = this._extractFilesArray(data);
if (files && files.length) { if (files && files.length) {
const listing = this._formatFilesListing(files); const listing = this._formatFilesListing(files);
await pasteAndMaybeSubmit(listing); RESP_BUFFER.push({ label, content: listing });
} else { } else {
const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```'; const fallback = '```json\n' + JSON.stringify(data, null, 2) + '\n```';
await pasteAndMaybeSubmit(fallback); 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 }); 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 }; return { success: true, data, isMock };
} }
static handleError(error, command, sourceElement) { static handleError(error, command, sourceElement, renderKey = '', label = '') {
UIFeedback.appendStatus(sourceElement, 'ERROR', { UIFeedback.appendStatus(sourceElement, 'ERROR', {
action: command.action || 'Command', action: command.action || 'Command',
details: error.message details: error.message,
key: renderKey,
label
}); });
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
@ -1670,6 +1741,114 @@
cancelOne: (cb) => execQueue.cancelOne(cb), 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 ---------------------- // ---------------------- Bridge Key ----------------------
let BRIDGE_KEY = null; let BRIDGE_KEY = null;
@ -1979,37 +2158,32 @@
const messageId = this.getReadableMessageId(element); const messageId = this.getReadableMessageId(element);
const subId = `${messageId}#${idx + 1}`; 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 () => { execQueue.push(async () => {
// optional tiny settle for streaming const finalTxt = hit.text; // <<< ADD THIS
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;
let parsed; let parsed;
try { try {
parsed = CommandParser.parseYAMLCommand(finalTxt); parsed = CommandParser.parseYAMLCommand(finalTxt);
const val = CommandParser.validateStructure(parsed); const val = CommandParser.validateStructure(parsed);
if (!val.isValid) throw new Error(`Validation failed: ${val.errors.join(', ')}`); if (!val.isValid) throw new Error(`Validation failed: ${val.errors.join(', ')}`);
} catch (err) { } 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); this.attachRetryUI(element, subId);
return; return;
} }
this.updateState(subId, COMMAND_STATES.EXECUTING); 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) { if (!res || res.success === false) {
this.updateState(subId, COMMAND_STATES.ERROR); this.updateState(subId, COMMAND_STATES.ERROR);
this.attachRetryUI(element, subId); this.attachRetryUI(element, subId);
@ -2018,6 +2192,7 @@
this.updateState(subId, COMMAND_STATES.COMPLETE); this.updateState(subId, COMMAND_STATES.COMPLETE);
}); });
} }
isAssistantMessage(el) { isAssistantMessage(el) {
if (!CONFIG.ASSISTANT_ONLY) return true; if (!CONFIG.ASSISTANT_ONLY) return true;
const host = location.hostname; const host = location.hostname;
@ -2214,7 +2389,11 @@
// 4) Execute // 4) Execute
this.updateState(messageId, COMMAND_STATES.EXECUTING); 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) { if (!result || result.success === false) {
RC_DEBUG?.warn('Execution reported failure', { messageId }); RC_DEBUG?.warn('Execution reported failure', { messageId });