// ==UserScript==
// @name Repo Gist
// @namespace https://github.com/prudentbird
// @version 0.0.3
// @description Provides GitHub repositories as additional context.
// @author Prudent Bird
// @match https://t3.chat/*
// @match https://beta.t3.chat/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=t3.chat
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @run-at document-idle
// @connect *
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// --- Configuration and State ---
let debugMode = false;
const DB_VERSION = 1;
const SCRIPT_VERSION = "0.1.0";
const SCRIPT_NAME = "Repo Gist";
const DB_NAME = "t3chat_repogist_db";
const STORE_NAME = "repogist_states";
const githubSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-github-icon lucide-github"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>`;
const GM_STORAGE_KEYS = {
DEBUG: "debug",
API_URL: "apiUrl",
GEMINI_API_KEY: "geminiApiKey",
};
// Utility function to handle GM_getValue safely
const safeGMGetValue = (key, defaultValue = null) => {
try {
const result = GM_getValue(key, defaultValue);
// Check if it's a promise
if (result && typeof result.then === 'function') {
return result;
} else {
// Return a resolved promise for consistency
return Promise.resolve(result);
}
} catch (error) {
Logger.error(`Error getting GM value for ${key}:`, error);
return Promise.resolve(defaultValue);
}
};
// Utility function to handle GM_setValue safely
const safeGMSetValue = (key, value) => {
try {
const result = GM_setValue(key, value);
// Check if it's a promise
if (result && typeof result.then === 'function') {
return result;
} else {
// Return a resolved promise for consistency
return Promise.resolve(result);
}
} catch (error) {
Logger.error(`Error setting GM value for ${key}:`, error);
return Promise.reject(error);
}
};
// --- Utility: Logger ---
const Logger = {
log: (...args) => {
if (debugMode) console.log(`[${SCRIPT_NAME}]`, ...args);
},
error: (...args) => console.error(`[${SCRIPT_NAME}]`, ...args),
};
const getChatId = () => {
const currentUrl = window.location.href;
const match = currentUrl.match(/\/chat\/([^/?#]+)/);
const chatId = match ? match[1] : null;
if (!chatId) {
Logger.log("getChatId: No chat ID found in URL", currentUrl);
}
return chatId;
};
let apiUrl = null;
let geminiApiKey = null;
const ApiKeyModal = {
_isShown: false,
_isValidURL: (url) => {
try {
new URL(url);
return true;
} catch (e) {
return false;
}
},
show: () => {
if (document.getElementById(UI_IDS.apiKeyModal) || ApiKeyModal._isShown)
return;
ApiKeyModal._isShown = true;
const wrapper = document.createElement("div");
wrapper.id = UI_IDS.apiKeyModal;
wrapper.innerHTML = `
<div id="${UI_IDS.apiKeyModalContent}">
<div id="${UI_IDS.apiKeyModalHeader}">
<div><!-- Icon container -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cog-icon lucide-cog"><path d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z"/><path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/><path d="M12 2v2"/><path d="M12 22v-2"/><path d="m17 20.66-1-1.73"/><path d="M11 10.27 7 3.34"/><path d="m20.66 17-1.73-1"/><path d="m3.34 7 1.73 1"/><path d="M14 12h8"/><path d="M2 12h2"/><path d="m20.66 7-1.73 1"/><path d="m3.34 17 1.73-1"/><path d="m17 3.34-1 1.73"/><path d="m11 13.73-4 6.93"/></svg>
</div>
<div>Enter API Configuration</div><!-- Title -->
<button id="${UI_IDS.apiKeyModalCloseButton}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
</div>
<div id="${UI_IDS.apiKeyModalInputContainer}">
<label for="${UI_IDS.apiKeyModalInput}">RepoGist API URL</label>
<input id="${UI_IDS.apiKeyModalInput}" type="text" placeholder="https://api.repogist.com/ingest" />
<button id="${UI_IDS.apiKeyModalClearButton}" aria-label="Clear input"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg></button>
</div>
<div id="${UI_IDS.apiKeyModalInputContainer}">
<label for="${UI_IDS.geminiApiKeyInput}">Gemini API Key</label>
<input id="${UI_IDS.geminiApiKeyInput}" type="text" placeholder="Enter your Gemini API key" />
<button id="${UI_IDS.geminiApiKeyClearButton}" aria-label="Clear input"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg></button>
</div>
<button id="${UI_IDS.apiKeyModalSaveButton}">Save</button>
</div>`;
document.body.appendChild(wrapper);
const input = wrapper.querySelector(`#${UI_IDS.apiKeyModalInput}`);
if (input) {
input.focus();
}
ApiKeyModal._attachEventListeners(wrapper);
},
_attachEventListeners: (modalElement) => {
const urlInput = modalElement.querySelector(
`#${UI_IDS.apiKeyModalInput}`
);
const geminiKeyInput = modalElement.querySelector(
`#${UI_IDS.geminiApiKeyInput}`
);
const saveButton = modalElement.querySelector(
`#${UI_IDS.apiKeyModalSaveButton}`
);
const closeButton = modalElement.querySelector(
`#${UI_IDS.apiKeyModalCloseButton}`
);
const clearButton = modalElement.querySelector(
`#${UI_IDS.apiKeyModalClearButton}`
);
const geminiClearButton = modalElement.querySelector(
`#${UI_IDS.geminiApiKeyClearButton}`
);
modalElement.addEventListener("click", (e) => {
if (e.target === modalElement) {
ApiKeyModal._isShown = false;
modalElement.remove();
}
});
const updateClearButtonVisibility = (input, button) => {
if (button) {
button.style.display = input.value ? "flex" : "none";
}
};
updateClearButtonVisibility(urlInput, clearButton);
updateClearButtonVisibility(geminiKeyInput, geminiClearButton);
if (clearButton) {
clearButton.addEventListener("click", () => {
urlInput.value = "";
urlInput.focus();
updateClearButtonVisibility(urlInput, clearButton);
});
}
if (geminiClearButton) {
geminiClearButton.addEventListener("click", () => {
geminiKeyInput.value = "";
geminiKeyInput.focus();
updateClearButtonVisibility(geminiKeyInput, geminiClearButton);
});
}
urlInput.addEventListener("input", () =>
updateClearButtonVisibility(urlInput, clearButton)
);
geminiKeyInput.addEventListener("input", () =>
updateClearButtonVisibility(geminiKeyInput, geminiClearButton)
);
const handleSave = () => {
const url = urlInput.value.trim();
const geminiKey = geminiKeyInput.value.trim();
if (url && !ApiKeyModal._isValidURL(url)) {
alert("Invalid RepoGist API URL");
return;
}
if (url) {
safeGMSetValue(GM_STORAGE_KEYS.API_URL, url)
.then(() => {
apiUrl = url;
if (geminiKey) {
return safeGMSetValue(GM_STORAGE_KEYS.GEMINI_API_KEY, geminiKey);
}
})
.then(() => {
if (geminiKey) {
geminiApiKey = geminiKey;
}
ApiKeyModal._isShown = false;
modalElement.remove();
})
.catch((err) => {
Logger.error("Failed to save API configuration:", err);
});
} else {
ApiKeyModal._isShown = false;
modalElement.remove();
}
};
saveButton.addEventListener("click", handleSave);
urlInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
handleSave();
}
});
geminiKeyInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
handleSave();
}
});
closeButton.addEventListener("click", () => {
ApiKeyModal._isShown = false;
modalElement.remove();
});
},
};
const getRepoNamefromURL = (url) => {
if (typeof url !== "string") {
Logger.log("getRepoNamefromURL: Invalid URL type", typeof url);
return null;
}
url = url.replace(/\/+$/, "");
let match = url.match(/[:\/]([^\/]+)\.git$/);
if (match) {
Logger.log("getRepoNamefromURL: Found .git URL match", match[1]);
return match[1];
}
match = url.match(/\/([^\/]+)(?:\/tree\/[^\/]+)?$/);
if (match) {
Logger.log("getRepoNamefromURL: Found standard URL match", match[1]);
return match[1];
}
Logger.log("getRepoNamefromURL: No match found for URL", url);
return null;
};
// Updated selector to match the exact HTML structure provided
const selectors = {
messageActions: "div.ml-\\[-7px\\].flex.items-center.gap-1",
searchButton: 'button#search-toggle[aria-label="Enable search"]',
};
const UI_IDS = {
apiKeyModal: "api-key-modal",
apiKeyModalContent: "api-key-modal-content",
apiKeyModalHeader: "api-key-modal-header",
apiKeyModalInput: "api-key-modal-input",
apiKeyModalInputContainer: "api-key-modal-input-container",
apiKeyModalSaveButton: "api-key-modal-save-button",
apiKeyModalCloseButton: "api-key-modal-close-button",
apiKeyModalClearButton: "api-key-modal-clear-button",
geminiApiKeyInput: "gemini-api-key-input",
geminiApiKeyClearButton: "gemini-api-key-clear-button",
importButton: "import-button",
searchToggle: "search-toggle",
repoUrlModal: "repo-url-modal",
repoUrlModalContent: "repo-url-modal-content",
repoUrlModalHeader: "repo-url-modal-header",
repoUrlModalDescription: "repo-url-modal-description",
repoUrlModalInput: "repo-url-modal-input",
repoUrlModalSaveButton: "repo-url-modal-save-button",
repoUrlModalCloseButton: "repo-url-modal-close-button",
repoUrlModalClearButton: "repo-url-modal-clear-button",
repoUrlModalInputContainer: "repo-url-modal-input-container",
styleElement: "repo-gist-style",
};
const CSS_CLASSES = {
// Updated button classes to match the attach button style exactly
button:
"inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 disabled:cursor-not-allowed hover:bg-muted/40 hover:text-foreground disabled:hover:bg-transparent disabled:hover:text-foreground/50 text-xs cursor-pointer -mb-1.5 h-auto gap-2 rounded-full border border-solid border-secondary-foreground/10 px-2 py-1.5 pr-2.5 text-muted-foreground max-sm:p-2",
importButtonLoading: "loading",
importButtonOn: "on",
};
const StyleManager = {
injectGlobalStyles: () => {
if (document.getElementById(UI_IDS.styleElement)) return;
const styleEl = document.createElement("style");
styleEl.id = UI_IDS.styleElement;
styleEl.textContent = `
/* Button toggle animation */
#${UI_IDS.importButton} { position: relative; overflow: hidden; transition: color 0.3s ease; }
#${UI_IDS.importButton}::before { content: ''; position: absolute; inset: 0; background-color: rgba(219,39,119,0.15); transform: scaleX(0); transform-origin: left; transition: transform 0.3s ease; z-index:-1; }
#${UI_IDS.importButton}.${CSS_CLASSES.importButtonOn}::before { transform: scaleX(1); }
#${UI_IDS.importButton} svg { transition: transform 0.3s ease; }
#${UI_IDS.importButton}.${CSS_CLASSES.importButtonOn} svg { transform: rotate(360deg); }
/* Loading state */
#${UI_IDS.importButton}.${CSS_CLASSES.importButtonLoading} { opacity: 0.6; position: relative; }
#${UI_IDS.importButton}.${CSS_CLASSES.importButtonLoading}::after { content: ''; position: absolute; top:50%; left:50%; width:12px; height:12px; margin:-6px 0 0 -6px; border:2px solid currentColor; border-radius:50%; border-top-color:transparent; animation:spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Repo URL Modal Styles */
#${UI_IDS.repoUrlModal} {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
#${UI_IDS.repoUrlModalContent} {
background: #1c1c1e;
padding: 24px;
border-radius: 12px;
width: 500px;
max-width: 95vw;
box-sizing: border-box;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
@media (max-width: 600px) {
#${UI_IDS.repoUrlModalContent} {
width: 95vw;
padding: 16px;
}
}
#${UI_IDS.repoUrlModalHeader} {
display: flex;
align-items: center;
margin-bottom: 20px;
position: relative;
}
#${UI_IDS.repoUrlModalHeader} > div:first-child { /* Icon container */
color: #c62a88;
margin-right: 12px;
}
#${UI_IDS.repoUrlModalHeader} > div:last-child { /* Title container */
font-size: 22px;
font-weight: 600;
color: #fff;
}
#${UI_IDS.repoUrlModalCloseButton} {
position: absolute;
top: 0;
right: 0;
background: none;
border: none;
cursor: pointer;
color: #fff;
font-size: 24px;
transition: color 0.3s ease;
}
#${UI_IDS.repoUrlModalDescription} {
color: #999;
font-size: 14px;
margin-bottom: 16px;
}
#${UI_IDS.repoUrlModalInputContainer} {
position: relative;
width: 100%;
margin-bottom: 16px;
}
#${UI_IDS.repoUrlModalInput} {
width: 100%;
padding: 12px 36px 12px 12px;
box-sizing: border-box;
background: #2a2a2c;
color: #fff;
border: 1px solid #333;
border-radius: 6px;
outline: none;
font-size: 14px;
}
#${UI_IDS.repoUrlModalClearButton} {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
font-weight: 500;
color: #aaa;
transition: color 0.2s ease;
padding: 0;
}
#${UI_IDS.repoUrlModalClearButton}:hover {
color: #c62a88;
}
#${UI_IDS.repoUrlModalSaveButton} {
width: 100%;
padding: 12px;
background: #a02553;
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-size: 15px;
font-weight: 500;
transition: all 0.2s ease;
}
#${UI_IDS.repoUrlModalSaveButton}:hover {
background: #c62a88;
}
/* API Key Modal Styles */
#${UI_IDS.apiKeyModal} {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
#${UI_IDS.apiKeyModalContent} {
background: #1c1c1e;
padding: 24px;
border-radius: 12px;
width: 500px;
max-width: 95vw;
box-sizing: border-box;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
@media (max-width: 600px) {
#${UI_IDS.apiKeyModalContent} {
width: 95vw;
padding: 16px;
}
}
#${UI_IDS.apiKeyModalHeader} {
display: flex;
align-items: center;
margin-bottom: 20px;
position: relative;
}
#${UI_IDS.apiKeyModalHeader} > div:first-child { /* Icon container */
color: #c62a88;
margin-right: 12px;
}
#${UI_IDS.apiKeyModalHeader} > div:last-child { /* Title container */
font-size: 22px;
font-weight: 600;
color: #fff;
}
#${UI_IDS.apiKeyModalCloseButton} {
position: absolute;
top: 0;
right: 0;
background: none;
border: none;
cursor: pointer;
color: #fff;
font-size: 24px;
transition: color 0.3s ease;
}
#${UI_IDS.apiKeyModalInputContainer} {
position: relative;
width: 100%;
margin-bottom: 16px;
}
#${UI_IDS.apiKeyModalInputContainer} label {
display: block;
color: #999;
font-size: 14px;
margin-bottom: 8px;
}
#${UI_IDS.apiKeyModalInput} {
width: 100%;
padding: 12px 36px 12px 12px;
box-sizing: border-box;
background: #2a2a2c;
color: #fff;
border: 1px solid #333;
border-radius: 6px;
outline: none;
font-size: 14px;
}
#${UI_IDS.apiKeyModalInput}:focus {
border-color: #c62a88;
}
#${UI_IDS.geminiApiKeyInput} {
width: 100%;
padding: 12px 36px 12px 12px;
box-sizing: border-box;
background: #2a2a2c;
color: #fff;
border: 1px solid #333;
border-radius: 6px;
outline: none;
font-size: 14px;
}
#${UI_IDS.geminiApiKeyInput}:focus {
border-color: #c62a88;
}
#${UI_IDS.apiKeyModalClearButton}, #${UI_IDS.geminiApiKeyClearButton} {
position: absolute;
top: 68%;
right: 10px;
transform: translateY(-50%);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
font-weight: 500;
color: #aaa;
transition: color 0.2s ease;
padding: 0;
}
#${UI_IDS.apiKeyModalClearButton}:hover, #${UI_IDS.geminiApiKeyClearButton}:hover {
color: #c62a88;
}
#${UI_IDS.apiKeyModalSaveButton} {
width: 100%;
padding: 12px;
background: #a02553;
border: none;
border-radius: 6px;
color: white;
cursor: pointer;
font-size: 15px;
font-weight: 500;
transition: all 0.2s ease;
}
#${UI_IDS.apiKeyModalSaveButton}:hover {
background: #c62a88;
}`;
document.head.appendChild(styleEl);
},
};
const UIManager = {
importButton: null,
_createImportButton: () => {
return new Promise((resolve) => {
const chatId = getChatId();
IngestDBManager.getState(chatId)
.then((state) => {
const button = document.createElement("button");
button.type = "button";
button.id = UI_IDS.importButton;
button.className = CSS_CLASSES.button;
button.setAttribute("data-state", "closed");
if (state && state.repoUrl) {
const repoName = getRepoNamefromURL(state.repoUrl)
?.split("/")
?.pop()
?.slice(0, 10)
?.replace(/^./, (c) => c.toUpperCase()) || "Repo";
button.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">${repoName}</span></div>`;
button.setAttribute("aria-label", "Repository imported");
button.dataset.mode = "on";
button.classList.add(CSS_CLASSES.importButtonOn);
} else {
button.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`;
button.setAttribute("aria-label", "Import repository");
button.dataset.mode = "off";
}
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (!apiUrl || !geminiApiKey) {
ApiKeyModal.show();
return;
}
const chatId = getChatId();
if (!chatId) {
alert("No chat ID found, can't import repo");
Logger.log("No chat ID found, skipping import button click");
return;
}
if (RepoUrlModal._isShown) {
RepoUrlModal._isShown = false;
return;
}
RepoUrlModal.show();
});
resolve(button);
})
.catch((err) => {
Logger.error("Failed to create import button:", err);
resolve(null);
});
});
},
injectImportButton: () => {
return new Promise((resolve) => {
// Find the exact container with model selector, thinking level, and attach buttons
const messageActionsContainer = document.querySelector(selectors.messageActions);
if (!messageActionsContainer) {
Logger.log("Message actions container not found with selector:", selectors.messageActions);
resolve(false);
return;
}
// Check if button already exists
if (messageActionsContainer.querySelector(`#${UI_IDS.importButton}`)) {
Logger.log("Import button already exists");
resolve(true);
return;
}
UIManager._createImportButton()
.then((button) => {
if (!button) {
Logger.error("Import button creation failed.");
resolve(false);
return;
}
UIManager.importButton = button;
// Insert the button as the last child (after attach button)
messageActionsContainer.appendChild(button);
Logger.log("Import button injected successfully in message actions container");
resolve(true);
})
.catch((err) => {
Logger.error("Failed to inject import button:", err);
resolve(false);
});
});
},
};
const RepoUrlModal = {
_isShown: false,
_isValidRepoUrl: (url) => {
const patterns = [
/^git@[^:]+:.+\.git$/,
/^https:\/\/[^/]+\/.+\.git$/,
/^https:\/\/github\.com\/[^/]+\/[^/]+(\/tree\/[^/]+)?$/,
/^https:\/\/gitlab\.com\/[^/]+\/[^/]+(\/-\/tree\/[^/]+)?$/,
];
return patterns.some((pattern) => pattern.test(url));
},
show: () => {
return new Promise((resolve) => {
const chatId = getChatId();
IngestDBManager.getState(chatId)
.then((state) => {
if (
document.getElementById(UI_IDS.repoUrlModal) ||
RepoUrlModal._isShown
) {
resolve();
return;
}
RepoUrlModal._isShown = true;
const wrapper = document.createElement("div");
wrapper.id = UI_IDS.repoUrlModal;
wrapper.innerHTML = `
<div id="${UI_IDS.repoUrlModalContent}">
<div id="${UI_IDS.repoUrlModalHeader}">
<div><!-- Icon container -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-folder-git2-icon lucide-folder-git-2"><path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5"/><circle cx="13" cy="12" r="2"/><path d="M18 19c-2.8 0-5-2.2-5-5v8"/><circle cx="20" cy="19" r="2"/></svg>
</div>
<div>Enter Repo URL</div><!-- Title -->
<button id="${UI_IDS.repoUrlModalCloseButton}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
</div>
<div id="${UI_IDS.repoUrlModalDescription}">Enter the URL of the GitHub repository you want to import.</div>
<div id="${UI_IDS.repoUrlModalInputContainer}">
<input id="${UI_IDS.repoUrlModalInput}" type="text" placeholder="https://github.com/username/repo" />
<button id="${UI_IDS.repoUrlModalClearButton}" aria-label="Clear input"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg></button>
</div>
<button id="${UI_IDS.repoUrlModalSaveButton}">Import</button>
</div>`;
document.body.appendChild(wrapper);
const input = wrapper.querySelector(`#${UI_IDS.repoUrlModalInput}`);
if (input) {
input.focus();
input.value = (state && state.repoUrl) || "";
}
RepoUrlModal._attachEventListeners(wrapper);
resolve();
})
.catch((err) => {
Logger.error("Failed to show repo URL modal:", err);
resolve();
});
});
},
_attachEventListeners: (modalElement) => {
const urlInput = modalElement.querySelector(
`#${UI_IDS.repoUrlModalInput}`
);
const saveButton = modalElement.querySelector(
`#${UI_IDS.repoUrlModalSaveButton}`
);
const closeButton = modalElement.querySelector(
`#${UI_IDS.repoUrlModalCloseButton}`
);
const clearButton = modalElement.querySelector(
`#${UI_IDS.repoUrlModalClearButton}`
);
modalElement.addEventListener("click", (e) => {
if (e.target === modalElement) {
const urlInput = modalElement.querySelector(
`#${UI_IDS.repoUrlModalInput}`
);
if (urlInput && !urlInput.value) {
const importButton = document.getElementById(UI_IDS.importButton);
if (importButton) {
importButton.classList.remove(CSS_CLASSES.importButtonOn);
importButton.setAttribute("aria-label", "Import repository");
importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`;
importButton.dataset.mode = "off";
}
IngestDBManager.deleteState(getChatId());
}
RepoUrlModal._isShown = false;
modalElement.remove();
}
});
const updateClearButtonVisibility = () => {
if (clearButton) {
clearButton.style.display = urlInput.value ? "flex" : "none";
}
};
updateClearButtonVisibility();
if (clearButton) {
clearButton.addEventListener("click", () => {
urlInput.value = "";
urlInput.focus();
updateClearButtonVisibility();
});
}
urlInput.addEventListener("input", updateClearButtonVisibility);
const handleSave = () => {
const url = urlInput.value.trim();
if (url) {
if (!RepoUrlModal._isValidRepoUrl(url)) {
alert("Please enter a valid git repository URL.");
return;
}
RepoUrlModal._isShown = false;
modalElement.remove();
const importButton = document.getElementById(UI_IDS.importButton);
if (importButton) {
importButton.classList.add(CSS_CLASSES.importButtonLoading);
importButton.setAttribute("aria-label", "Importing repository...");
importButton.disabled = true;
}
new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: apiUrl,
headers: {
"Content-Type": "application/json",
},
data: JSON.stringify({
url,
}),
onload: (res) => {
if (res.status < 200 || res.status >= 300) {
reject(
new Error(`API Error (${res.status}): ${res.responseText}`)
);
return;
}
try {
const data = JSON.parse(res.responseText);
if (
!data ||
typeof data !== "object" ||
!data.data ||
!data.data.content ||
!data.data.normalized ||
!data.data.tree ||
!data.data.index
) {
reject(new Error("Invalid response data from API"));
return;
}
IngestDBManager.saveState({
chatId: getChatId(),
repoUrl: url,
repoTree: data.data.tree,
repoIndex: data.data.index,
repoContent: data.data.content,
repoNormalizedContent: data.data.normalized,
})
.then(() => {
if (importButton) {
const repoName = getRepoNamefromURL(url)
?.split("/")
?.pop()
?.slice(0, 10)
?.replace(/^./, (c) => c.toUpperCase()) || "Repo";
importButton.classList.add(CSS_CLASSES.importButtonOn);
importButton.setAttribute("aria-label", "Repository imported");
importButton.dataset.mode = "on";
importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">${repoName}</span></div>`;
}
resolve();
})
.catch((err) => {
reject(err);
});
} catch (err) {
reject(err);
}
},
onerror: (err) => {
reject(err);
},
ontimeout: () => {
reject(new Error("Request timed out"));
},
timeout: 30000,
});
})
.catch((err) => {
if (importButton) {
importButton.classList.remove(CSS_CLASSES.importButtonLoading);
importButton.classList.remove(CSS_CLASSES.importButtonOn);
importButton.setAttribute("aria-label", "Import repository");
importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`;
importButton.dataset.mode = "off";
importButton.disabled = false;
}
Logger.error("Error during repo import:", err);
alert(`Failed to import repository: ${err.message}`);
})
.finally(() => {
if (importButton) {
importButton.classList.remove(CSS_CLASSES.importButtonLoading);
importButton.disabled = false;
}
});
} else {
alert("Repo URL cannot be empty");
}
};
saveButton.addEventListener("click", handleSave);
urlInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
handleSave();
}
});
closeButton.addEventListener("click", () => {
const urlInput = modalElement.querySelector(
`#${UI_IDS.repoUrlModalInput}`
);
if (urlInput && !urlInput.value) {
const importButton = document.getElementById(UI_IDS.importButton);
if (importButton) {
importButton.classList.remove(CSS_CLASSES.importButtonOn);
importButton.setAttribute("aria-label", "Import repository");
importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`;
importButton.dataset.mode = "off";
}
IngestDBManager.deleteState(getChatId());
}
RepoUrlModal._isShown = false;
modalElement.remove();
});
},
};
const getAllFileNames = (index) => {
Logger.log(
"getAllFileNames: Processing index with",
(index && index.length) || 0,
"files"
);
return index.map((file, idx) => ({
fileName: file.fileName,
index: idx,
}));
};
const getFileContents = (index, indices) => {
Logger.log("getFileContents: Requesting contents for indices", indices);
if (!index || !Array.isArray(indices)) {
Logger.log("getFileContents: Invalid input", {
index: !!index,
indices: !!indices,
});
return [];
}
const contents = indices
.map((idx) => {
const file = index[idx];
if (file) {
return {
fileName: file.fileName,
content: file.fileContent,
};
} else {
return null;
}
})
.filter(function (item) { return item !== null; });
Logger.log(
"getFileContents: Retrieved contents for",
contents.length,
"files"
);
return contents;
};
const getRepoTree = () => {
return new Promise((resolve) => {
const chatId = getChatId();
IngestDBManager.getState(chatId)
.then((state) => {
if (!state || !state.repoTree) {
Logger.log("getRepoTree: No repo tree found in state");
resolve(null);
return;
}
Logger.log("getRepoTree: Retrieved repo tree");
resolve(state.repoTree);
})
.catch((err) => {
Logger.error("Failed to get repo tree:", err);
resolve(null);
});
});
};
// Simplified generateRelevantContext function
const generateRelevantContext = (query) =>
new Promise((resolve) => {
const chatId = getChatId();
IngestDBManager.getState(chatId).then((state) => {
if (!state?.repoIndex) return resolve(null);
const idx = state.repoIndex;
const fileList = getAllFileNames(idx);
const prompt = `Given the following list of files in a repository and a user query, return the indices of the most relevant files that would help answer the query. Only return the indices of files that are directly relevant.
Files in repository:
${JSON.stringify(fileList, null, 2)}
User query: ${query}
Return the response in this exact JSON format:
{
"relevantIndices": [array of indices]
}`;
GM_xmlhttpRequest({
method: "POST",
url: `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiApiKey}`,
headers: { "Content-Type": "application/json" },
data: JSON.stringify({
contents: [
{
parts: [{ text: prompt }],
},
],
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: "OBJECT",
properties: {
relevantIndices: {
type: "ARRAY",
items: { type: "NUMBER" },
},
},
propertyOrdering: ["relevantIndices"],
},
},
}),
onload: (r) => {
if (r.status < 200 || r.status >= 300) {
Logger.error("Gemini error", r);
return resolve(null);
}
let relevantIndices;
try {
relevantIndices = JSON.parse(
JSON.parse(r.responseText).candidates[0].content.parts[0].text
).relevantIndices;
} catch (e) {
Logger.error("Parse error", e);
return resolve(null);
}
if (!Array.isArray(relevantIndices)) {
Logger.error("Invalid relevantIndices format", relevantIndices);
return resolve(null);
}
const files = getFileContents(idx, relevantIndices);
if (!files.length) return resolve(null);
const context = files
.map((f) => `File: ${f.fileName}\nContent:\n${f.content}\n`)
.join("\n");
resolve(context);
},
onerror: (e) => {
Logger.error("Gemini request failed", e);
resolve(null);
},
});
});
});
// Fixed FetchInterceptor to prevent interference with pasting
const FetchInterceptor = {
originalFetch: null,
isIntercepting: false,
init: () => {
try {
if (typeof unsafeWindow === "undefined") {
Logger.error("FetchInterceptor: unsafeWindow is not available");
return;
}
const w = unsafeWindow;
w.t3ChatIngest = w.t3ChatIngest || { needIngest: false };
const originalFetch = w.fetch;
FetchInterceptor.originalFetch = originalFetch;
w.fetch = (input, initOptions = {}) => {
// Early return for non-relevant requests
const url = typeof input === "string" ? input : input?.url;
if (!url ||
!url.includes("/api/chat") ||
url.includes("/api/chat/resume") ||
initOptions?.method !== "POST" ||
FetchInterceptor.isIntercepting) {
return originalFetch.call(w, input, initOptions);
}
const chatId = getChatId();
if (!chatId) {
return originalFetch.call(w, input, initOptions);
}
// Set intercepting flag immediately
FetchInterceptor.isIntercepting = true;
return IngestDBManager.getState(chatId)
.then((state) => {
if (!state || !state.repoUrl) {
Logger.log("FetchInterceptor: No repo URL in state, passing through");
return originalFetch.call(w, input, initOptions);
}
let data;
try {
data = JSON.parse(initOptions.body || "{}");
} catch (error) {
Logger.error("FetchInterceptor: Failed to parse request body", error);
return originalFetch.call(w, input, initOptions);
}
if (!Array.isArray(data.messages)) {
Logger.log("FetchInterceptor: No messages array in request");
return originalFetch.call(w, input, initOptions);
}
const messages = data.messages;
const lastIdx = messages.length - 1;
if (lastIdx < 0 || !messages[lastIdx] || messages[lastIdx].role !== "user") {
Logger.log("FetchInterceptor: No user message found");
return originalFetch.call(w, input, initOptions);
}
const originalPrompt = messages[lastIdx].content;
if (typeof originalPrompt !== "string") {
Logger.log("FetchInterceptor: Invalid prompt type", typeof originalPrompt);
return originalFetch.call(w, input, initOptions);
}
Logger.log("FetchInterceptor: Intercepting fetch for ingest enhancement");
return Promise.all([
getRepoTree(),
generateRelevantContext(originalPrompt),
])
.then(([tree, context]) => {
Logger.log("FetchInterceptor: Retrieved repo tree and context");
if (context) {
const importInstruction =
"The following information was retrieved from the repository. Please use these results to inform your response:\n";
messages[lastIdx].content = `${importInstruction}\n[Repository Tree]\n${tree}\n\n[Repository Context]\n${context}\n\n[Original Message]\n${originalPrompt}`;
initOptions.body = JSON.stringify(data);
Logger.log("FetchInterceptor: Enhanced prompt with repository context");
} else {
Logger.log("FetchInterceptor: No context to add to prompt");
}
return originalFetch.call(w, input, initOptions);
})
.catch((error) => {
Logger.error("FetchInterceptor: Error during interception", error);
return originalFetch.call(w, input, initOptions);
});
})
.catch((error) => {
Logger.error("FetchInterceptor: Error getting state", error);
return originalFetch.call(w, input, initOptions);
})
.finally(() => {
FetchInterceptor.isIntercepting = false;
});
};
Logger.log("FetchInterceptor: Initialized successfully");
} catch (error) {
Logger.error("FetchInterceptor: Failed to initialize", error);
}
},
};
const IngestDBManager = {
db: null,
init: () => {
return new Promise((resolve, reject) => {
try {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
Logger.error("Failed to open IndexedDB");
reject(request.error);
};
request.onsuccess = (event) => {
IngestDBManager.db = event.target.result;
Logger.log("IndexedDB opened successfully");
resolve();
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: "chatId",
});
store.createIndex("repoUrl", "repoUrl", { unique: false });
Logger.log("IndexedDB store created");
}
};
} catch (error) {
Logger.error("Error initializing IndexedDB:", error);
reject(error);
}
});
},
getAllStates: () => {
return new Promise((resolve, reject) => {
try {
const transaction = IngestDBManager.db.transaction(
[STORE_NAME],
"readonly"
);
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
} catch (error) {
reject(error);
}
});
},
getState: (chatId) => {
return new Promise((resolve, reject) => {
if (!chatId) {
resolve(null);
return;
}
try {
const transaction = IngestDBManager.db.transaction(
[STORE_NAME],
"readonly"
);
const store = transaction.objectStore(STORE_NAME);
const request = store.get(chatId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
} catch (error) {
reject(error);
}
});
},
saveState: (state) => {
return new Promise((resolve, reject) => {
try {
const transaction = IngestDBManager.db.transaction(
[STORE_NAME],
"readwrite"
);
const store = transaction.objectStore(STORE_NAME);
const request = store.put(state);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
} catch (error) {
reject(error);
}
});
},
deleteState: (chatId) => {
return new Promise((resolve, reject) => {
try {
const transaction = IngestDBManager.db.transaction(
[STORE_NAME],
"readwrite"
);
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(chatId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
} catch (error) {
reject(error);
}
});
},
clearAll: () => {
return new Promise((resolve, reject) => {
try {
const transaction = IngestDBManager.db.transaction(
[STORE_NAME],
"readwrite"
);
const store = transaction.objectStore(STORE_NAME);
const request = store.clear();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
} catch (error) {
reject(error);
}
});
},
};
const MenuCommands = {
init: () => {
return new Promise((resolve) => {
try {
GM_registerMenuCommand("Toggle debug logs", () => {
safeGMGetValue(GM_STORAGE_KEYS.DEBUG, false)
.then((currentDebug) => {
const newDebug = !currentDebug;
return safeGMSetValue(GM_STORAGE_KEYS.DEBUG, newDebug);
})
.then(() => {
debugMode = !debugMode;
Logger.log(
`Debug mode toggled to: ${debugMode} via menu. Reloading...`
);
location.reload();
})
.catch((err) => {
Logger.error("Failed to toggle debug mode:", err);
});
});
GM_registerMenuCommand("Reset Gemini API Key", () => {
safeGMSetValue(GM_STORAGE_KEYS.GEMINI_API_KEY, "")
.then(() => {
geminiApiKey = null;
Logger.log("Gemini API Key reset via menu.");
location.reload();
})
.catch((err) => {
Logger.error("Failed to reset Gemini API key:", err);
});
});
GM_registerMenuCommand("Reset RepoGist API URL", () => {
safeGMSetValue(GM_STORAGE_KEYS.API_URL, "")
.then(() => {
apiUrl = null;
Logger.log("RepoGist API URL reset via menu.");
location.reload();
})
.catch((err) => {
Logger.error("Failed to reset RepoGist API URL:", err);
});
});
GM_registerMenuCommand("Reset IndexedDB for all chats", () => {
IngestDBManager.clearAll()
.then(() => {
Logger.log("IndexedDB reset via menu.");
location.reload();
})
.catch((err) => {
Logger.error("Failed to reset IndexedDB:", err);
});
});
Logger.log("Menu commands registered.");
resolve();
} catch (error) {
Logger.error("Error registering menu commands:", error);
resolve();
}
});
},
};
function main() {
try {
// Initialize debug mode safely
safeGMGetValue(GM_STORAGE_KEYS.DEBUG, false)
.then((value) => {
debugMode = value;
Logger.log(
`${SCRIPT_NAME} v${SCRIPT_VERSION} starting. Debug mode: ${debugMode}`
);
// Initialize all components
FetchInterceptor.init();
return Promise.all([
MenuCommands.init(),
IngestDBManager.init()
]);
})
.then(() => {
StyleManager.injectGlobalStyles();
// Load API configuration safely
return Promise.all([
safeGMGetValue(GM_STORAGE_KEYS.API_URL),
safeGMGetValue(GM_STORAGE_KEYS.GEMINI_API_KEY)
]);
})
.then(([url, key]) => {
apiUrl = url;
geminiApiKey = key;
if (!apiUrl || !geminiApiKey) {
Logger.log(
"RepoGist API URL or Gemini API Key not found. It will be requested upon first import attempt."
);
} else {
Logger.log("RepoGist API URL and Gemini API Key loaded.");
}
// Set up URL observer and button injection
let lastChatId = getChatId();
const urlObserver = new MutationObserver(() => {
const currentChatId = getChatId();
if (currentChatId !== lastChatId) {
lastChatId = currentChatId;
setTimeout(() => {
injectButtonWithRetry().catch((err) => {
Logger.error("Failed to inject button:", err);
});
}, 500);
IngestDBManager.getState(currentChatId)
.then((state) => {
const importButton = document.getElementById(UI_IDS.importButton);
if (importButton) {
if (state && state.repoUrl) {
const repoName = getRepoNamefromURL(state.repoUrl)
?.split("/")
?.pop()
?.slice(0, 10)
?.replace(/^./, (c) => c.toUpperCase()) || "Repo";
importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">${repoName}</span></div>`;
importButton.classList.add(CSS_CLASSES.importButtonOn);
importButton.setAttribute("aria-label", "Repository imported");
importButton.dataset.mode = "on";
} else {
importButton.innerHTML = `<div class="flex gap-1">${githubSVG}<span class="max-sm:hidden sm:ml-0.5">Import</span></div>`;
importButton.classList.remove(CSS_CLASSES.importButtonOn);
importButton.setAttribute("aria-label", "Import repository");
importButton.dataset.mode = "off";
}
}
})
.catch((err) => {
Logger.error("Failed to update button state:", err);
});
}
});
urlObserver.observe(document.querySelector("title"), {
subtree: true,
characterData: true,
childList: true,
});
const injectButtonWithRetry = (maxRetries = 10, delay = 1000) => {
let retries = 0;
const tryInjection = () => {
return new Promise((resolve) => {
const injectionObserverTargetParent = document.querySelector(
selectors.messageActions
);
if (injectionObserverTargetParent) {
UIManager.injectImportButton()
.then((success) => {
if (success) {
Logger.log("Successfully injected import button");
resolve(true);
return;
}
throw new Error("Injection failed");
})
.catch(() => {
if (retries < maxRetries) {
retries++;
Logger.log(
`Retrying button injection (${retries}/${maxRetries})...`
);
setTimeout(() => {
tryInjection().then(resolve).catch(() => resolve(false));
}, delay);
} else {
resolve(false);
}
});
} else {
if (retries < maxRetries) {
retries++;
Logger.log(
`Container not found, retrying (${retries}/${maxRetries})...`
);
setTimeout(() => {
tryInjection().then(resolve).catch(() => resolve(false));
}, delay);
} else {
Logger.error("Container not found after max retries");
resolve(false);
}
}
});
};
return tryInjection();
};
// Initial injection with delay to ensure DOM is ready
setTimeout(() => {
injectButtonWithRetry()
.then((success) => {
if (!success) {
Logger.log("Initial button injection failed, setting up observers");
const documentObserver = new MutationObserver(() => {
const target = document.querySelector(selectors.messageActions);
if (target && !target.querySelector(`#${UI_IDS.importButton}`)) {
UIManager.injectImportButton()
.then((success) => {
if (success) {
Logger.log("Button injected via document observer");
}
})
.catch((err) => {
Logger.error("Failed to inject button:", err);
});
}
});
documentObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
})
.catch((err) => {
Logger.error("Failed to initialize button injection:", err);
});
}, 2000);
Logger.log(`${SCRIPT_NAME} v${SCRIPT_VERSION} initialized!`);
})
.catch((err) => {
Logger.error("Failed to initialize:", err);
});
} catch (error) {
Logger.error("Failed to initialize:", error);
}
}
main();
})();