Skip to content

Instantly share code, notes, and snippets.

@replete
Last active April 13, 2026 10:34
Show Gist options
  • Select an option

  • Save replete/a0da6720189cbd1dfc063e0f67b28168 to your computer and use it in GitHub Desktop.

Select an option

Save replete/a0da6720189cbd1dfc063e0f67b28168 to your computer and use it in GitHub Desktop.
repomix-style HTML GUI for generating prompts for LLM web UI sessions
#!/usr/bin/env node
/*
repogui v0.2
https://gist.github.com/replete/a0da6720189cbd1dfc063e0f67b28168
1. save into a $PATH location (e.g. add ~/bin) for global terminal activation
2. chmod +x ~/bin/repogui
3. execute 'repogui' in a terminal
4. open http://127.0.0.1:6969 in browser
5. drag files from vscode (even most remote ssh/devcontainers) into the window to easily build a repomix-style code aggregate xml for use in an LLM web UI
*/
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { execFileSync } = require('node:child_process');
const PORT = 6969;
const BINARY_EXT = new Set([
'.png','.jpg','.jpeg','.gif','.bmp','.ico','.webp','.avif',
'.woff','.woff2','.ttf','.otf','.eot',
'.zip','.tar','.gz','.bz2','.7z','.rar',
'.pdf','.doc','.docx','.xls','.xlsx',
'.mp3','.mp4','.avi','.mov','.wav',
'.exe','.dll','.so','.dylib','.o','.a',
'.sqlite','.db','.DS_Store',
]);
const SKIP_DIRS = new Set(['node_modules', 'vendor', '__pycache__', '.git', 'dist', 'build']);
const SETS_FILE = path.join(os.homedir(), '.repogui.json');
function readSets() {
try { return JSON.parse(fs.readFileSync(SETS_FILE, 'utf-8')); } catch { return {}; }
}
function writeSets(sets) {
fs.writeFileSync(SETS_FILE, JSON.stringify(sets, null, 2));
}
function readFileSSH(host, filePath) {
try {
if (BINARY_EXT.has(path.extname(filePath).toLowerCase())) return [];
const content = execFileSync('ssh', [host, 'cat', filePath], { encoding: 'utf-8', timeout: 10000 });
if (content.slice(0, 1000).includes('\0')) return [];
return [{ path: filePath, content, size: Buffer.byteLength(content) }];
} catch { return []; }
}
function readFile(filePath) {
try {
if (BINARY_EXT.has(path.extname(filePath).toLowerCase())) return [];
const stat = fs.statSync(filePath);
if (stat.isDirectory()) return readDir(filePath);
if (stat.size > 5 * 1024 * 1024) return [];
const content = fs.readFileSync(filePath, 'utf-8');
if (content.slice(0, 1000).includes('\0')) return [];
return [{ path: filePath, content, size: stat.size }];
} catch { return []; }
}
function readDir(dirPath) {
const results = [];
try {
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
const full = path.join(dirPath, entry.name);
if (entry.isDirectory()) results.push(...readDir(full));
else results.push(...readFile(full));
}
} catch {}
return results;
}
const HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>repogui</title>
<script src="https://cdn.tailwindcss.com"><\/script>
</head>
<body class="bg-gray-950 text-gray-200 min-h-screen p-6">
<div class="max-w-3xl mx-auto">
<h1 class="text-lg font-semibold text-white mb-4">repogui</h1>
<div id="mainCard" class="border border-gray-800 rounded-lg overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-gray-900 border-b border-gray-800">
<label class="text-gray-400 shrink-0 text-xs">strip prefix</label>
<input id="pathPrefix" class="flex-1 bg-gray-800 text-gray-300 font-mono text-xs px-2 py-0.5 rounded focus:outline-none focus:ring-1 focus:ring-indigo-500 placeholder-gray-600" placeholder="${os.homedir()}/project/">
</div>
<ul id="fileList" class="divide-y divide-gray-900"></ul>
<div id="emptyHint" class="flex items-center justify-center h-32 text-gray-600 text-sm select-none">
drop files or folders from VS Code / Finder
</div>
<div class="flex flex-wrap items-center gap-2 px-3 py-2 bg-gray-900 border-t border-gray-800">
<span class="text-gray-400 text-xs shrink-0">sets</span>
<input id="setName" class="bg-gray-800 border border-gray-600 text-gray-200 px-2 py-0.5 rounded text-xs focus:outline-none focus:border-indigo-500 w-32 placeholder-gray-500" placeholder="name…">
<button id="saveSetBtn" class="px-2 py-0.5 bg-gray-700 border border-gray-600 text-gray-200 rounded text-xs hover:bg-gray-600">save</button>
<div class="w-px h-3 bg-gray-600 shrink-0"></div>
<select id="setsSelect" class="bg-gray-800 border border-gray-600 text-gray-200 px-2 py-0.5 rounded text-xs focus:outline-none focus:border-indigo-500"></select>
<button id="loadSetBtn" class="px-2 py-0.5 bg-gray-700 border border-gray-600 text-gray-200 rounded text-xs hover:bg-gray-600">load</button>
<button id="deleteSetBtn" class="px-2 py-0.5 bg-gray-700 border border-gray-600 text-red-400 rounded text-xs hover:bg-gray-600 hover:text-red-300">×</button>
</div>
<div class="flex items-center gap-3 px-3 py-2 bg-gray-900 border-t border-gray-800">
<span id="fileCount" class="text-xs text-gray-400"></span>
<span id="totalSize" class="text-xs text-gray-400"></span>
<span id="tokenCount" class="text-sm font-semibold text-indigo-400 mr-auto"></span>
<button id="updateBtn" disabled class="px-3 py-1 bg-gray-700 border border-gray-600 text-gray-200 rounded text-xs font-medium disabled:opacity-30 disabled:cursor-not-allowed hover:bg-gray-600">update</button>
<button id="clearBtn" class="hidden px-3 py-1 bg-gray-700 border border-gray-600 text-red-400 rounded text-xs font-medium hover:bg-gray-600 hover:text-red-300">clear</button>
<button id="copyBtn" disabled class="px-3 py-1 bg-indigo-600 text-white rounded text-xs font-medium disabled:opacity-30 disabled:cursor-not-allowed hover:bg-indigo-500">copy XML</button>
</div>
</div>
<textarea id="output" class="hidden mt-4 w-full bg-gray-900 border border-gray-800 p-4 rounded text-xs text-gray-400 font-mono resize-y focus:outline-none focus:border-indigo-500" rows="16" readonly></textarea>
<pre id="dropLog" class="hidden mt-4 bg-yellow-950 border border-yellow-800 p-4 rounded text-xs text-yellow-300 overflow-auto max-h-64 whitespace-pre-wrap break-all"></pre>
</div>
<div id="dragOverlay" class="fixed inset-0 pointer-events-none opacity-0 transition-opacity duration-150 flex items-center justify-center z-50">
<div class="absolute inset-2 border-2 border-dashed border-indigo-500 rounded-xl"></div>
<div class="absolute inset-0 bg-gray-950 opacity-60"></div>
<span class="relative text-indigo-400 text-base font-medium tracking-wide">drop files or folders</span>
</div>
<div id="toast" class="fixed bottom-6 left-1/2 -translate-x-1/2 bg-emerald-600 text-white px-6 py-2 rounded-lg text-sm opacity-0 transition-opacity pointer-events-none">Copied!</div>
<script>
const files = new Map();
const $ = id => document.getElementById(id);
const DEBUG = location.search.includes('debug') || location.hash.includes('debug');
function esc(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmtSize(b) { return b < 1024 ? b+' B' : b < 1048576 ? (b/1024).toFixed(1)+' KB' : (b/1048576).toFixed(1)+' MB'; }
function fmtTok(n) { return n < 1000 ? n+'' : (n/1000).toFixed(1)+'k'; }
function clean(p) { const px = $('pathPrefix').value.trim(); return px && p.startsWith(px) ? p.slice(px.length) : p; }
function updateUI() {
const list = $('fileList');
list.innerHTML = '';
const sorted = [...files.keys()].sort();
sorted.forEach(p => {
const f = files.get(p);
const li = document.createElement('li');
li.className = 'flex items-center gap-2 px-3 py-2 text-xs font-mono hover:bg-gray-900 transition-colors';
li.innerHTML = '<span class="truncate flex-1 text-gray-300" title="'+esc(p)+'">'+esc(clean(p))+'</span>'
+ '<span class="text-gray-500 shrink-0">'+fmtSize(f.size)+'</span>'
+ '<button class="text-gray-500 hover:text-red-400 pl-2 leading-none text-sm">&times;</button>';
li.querySelector('button').onclick = () => { files.delete(p); updateUI(); };
list.appendChild(li);
});
const has = files.size > 0;
$('emptyHint').className = has ? 'hidden' : 'flex items-center justify-center h-32 text-gray-700 text-sm select-none';
const xml = genXml();
$('fileCount').textContent = has ? files.size + ' file' + (files.size === 1 ? '' : 's') : '';
$('totalSize').textContent = has ? fmtSize([...files.values()].reduce((s,f) => s+f.size, 0)) : '';
$('tokenCount').textContent = has ? '~' + fmtTok(Math.ceil(xml.length / 3.5)) + ' tokens' : '';
$('copyBtn').disabled = !has;
$('updateBtn').disabled = ![...files.values()].some(f => f.reloadable);
$('clearBtn').className = has ? 'px-3 py-1 bg-gray-700 border border-gray-600 text-red-400 rounded text-xs font-medium hover:bg-gray-600 hover:text-red-300' : 'hidden';
const out = $('output');
if (has) { out.classList.remove('hidden'); out.value = xml; } else { out.classList.add('hidden'); out.value = ''; }
}
function genXml() {
const sorted = [...files.keys()].sort();
const tree = sorted.map(p => clean(p)).join('\\n');
let x = \`This file is a merged representation of a subset of the codebase, containing specifically included files, combined into a single document.
<file_summary>
<purpose>
This file contains a packed representation of the repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
</purpose>
<file_format>
The content is organized as follows:
1. This summary section
2. Directory structure
3. Multiple file entries, each consisting of:
- File path as an attribute
- Full contents of the file
</file_format>
<usage_guidelines>
- This file should be treated as read-only. Any changes should be made to the original repository files, not this packed version.
- When processing this file, use the file path to distinguish between different files in the repository.
</usage_guidelines>
</file_summary>
<directory_structure>
\${tree}
</directory_structure>
<files>
\`;
sorted.forEach(p => { x += '\\n<file path="'+esc(clean(p))+'">\\n'+files.get(p).content+'\\n</file>\\n'; });
return x + '\\n</files>\\n';
}
function autoPrefix() {
if ($('pathPrefix').value || files.size < 2) return;
const paths = [...files.keys()];
if (!paths[0].includes('/')) return;
const parts = paths[0].split('/');
let px = '';
for (let i = 0; i < parts.length - 1; i++) {
const c = px + parts[i] + '/';
if (paths.every(p => p.startsWith(c))) px = c; else break;
}
if (px) $('pathPrefix').value = px;
}
async function addPaths(paths, host) {
try {
const payload = { paths }; if (host) payload.host = host;
const r = await fetch('/read', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) });
const results = await r.json();
for (const f of results) files.set(f.path, { content: f.content, size: f.size, host: host || null, reloadable: true });
autoPrefix();
updateUI();
} catch (e) { console.error('read failed', e); }
}
function extractPaths(dt) {
// Check for vscode-remote:// URIs first (devcontainers, SSH remote)
const uriList = dt.getData('application/vnd.code.uri-list') || dt.getData('text/uri-list') || '';
const remoteUris = uriList.split(/[\\r\\n]+/).map(s => s.trim()).filter(s => s.startsWith('vscode-remote://'));
if (remoteUris.length) {
// Parse: vscode-remote://ssh-remote%2Bhostname/path
const parsed = remoteUris.map(u => { try { return new URL(u); } catch { return null; } }).filter(Boolean);
if (parsed.length) {
// hostname is like "ssh-remote%2Bhostname"
const host = decodeURIComponent(parsed[0].hostname).replace(/^ssh-remote\\+/, '');
const paths = parsed.map(u => decodeURIComponent(u.pathname));
return { paths, host };
}
}
// VS Code local: 'codefiles' has JSON array of absolute paths
const codefiles = dt.getData('codefiles');
if (codefiles) {
try { const arr = JSON.parse(codefiles); if (arr.length) return { paths: arr }; } catch {}
}
// text/plain: newline-separated absolute paths
const plain = dt.getData('text/plain');
if (plain) {
const paths = plain.split(/[\\r\\n]+/).map(s => s.trim()).filter(s => s.startsWith('/'));
if (paths.length) return { paths };
}
// Fallback: file:// URIs
const fileUris = uriList.split(/[\\r\\n]+/).map(s => s.trim()).filter(s => s.startsWith('file://')).map(s => decodeURIComponent(new URL(s).pathname));
if (fileUris.length) return { paths: fileUris };
return { paths: [] };
}
function readEntry(entry, base) {
return new Promise(resolve => {
if (entry.isFile) {
entry.file(f => {
const p = base ? base+'/'+f.name : f.name;
resolve([new File([f], p, {type: f.type})]);
}, () => resolve([]));
} else if (entry.isDirectory) {
const reader = entry.createReader(), entries = [];
const batch = () => reader.readEntries(async b => {
if (!b.length) { const r = await Promise.all(entries.map(e => readEntry(e, base ? base+'/'+entry.name : entry.name))); resolve(r.flat()); }
else { entries.push(...b); batch(); }
}, () => resolve([]));
batch();
} else resolve([]);
});
}
async function addFileObjects(list) {
for (const f of list) {
if (!f.size) continue;
try {
const t = await f.text();
if (t.slice(0,1000).includes('\\0')) continue;
files.set(f.name, { content: t, size: f.size, host: null, reloadable: false });
} catch {}
}
autoPrefix();
updateUI();
}
async function updateFiles() {
const byHost = new Map();
for (const [p, f] of files) {
if (!f.reloadable) continue;
const k = f.host || '';
if (!byHost.has(k)) byHost.set(k, { host: f.host, paths: [] });
byHost.get(k).paths.push(p);
}
for (const { host, paths } of byHost.values()) {
await addPaths(paths, host || undefined);
}
}
let sets = {};
async function fetchSets() {
try { const r = await fetch('/sets'); sets = await r.json(); renderSets(); } catch {}
}
function renderSets() {
const sel = $('setsSelect');
const cur = sel.value;
sel.innerHTML = '<option value="">— saved sets —</option>';
for (const name of Object.keys(sets).sort()) {
const o = document.createElement('option');
o.value = name; o.textContent = name;
sel.appendChild(o);
}
if (cur) sel.value = cur;
}
async function saveCurrentSet() {
const name = $('setName').value.trim();
if (!name || !files.size) return;
const entries = [...files.entries()].map(([p, f]) => ({ path: p, host: f.host || null }));
await fetch('/sets', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name, entries, prefix: $('pathPrefix').value }) });
await fetchSets();
$('setsSelect').value = name;
$('setName').value = '';
}
async function loadSelectedSet() {
const name = $('setsSelect').value;
if (!name || !sets[name]) return;
const set = sets[name];
files.clear();
if (set.prefix) $('pathPrefix').value = set.prefix;
const byHost = new Map();
for (const e of set.entries) {
const k = e.host || '';
if (!byHost.has(k)) byHost.set(k, { host: e.host, paths: [] });
byHost.get(k).paths.push(e.path);
}
for (const { host, paths } of byHost.values()) {
await addPaths(paths, host || undefined);
}
}
async function deleteSelectedSet() {
const name = $('setsSelect').value;
if (!name) return;
if (!confirm('Delete set "' + name + '"?')) return;
await fetch('/sets/' + encodeURIComponent(name), { method: 'DELETE' });
await fetchSets();
}
function logDrop(label, data) {
if (!DEBUG) return;
const el = $('dropLog');
el.classList.remove('hidden');
el.textContent += '[' + label + '] ' + data + '\\n';
}
document.addEventListener('dragover', e => { e.preventDefault(); $('dragOverlay').classList.replace('opacity-0','opacity-100'); });
document.addEventListener('dragleave', e => { if (!e.relatedTarget) $('dragOverlay').classList.replace('opacity-100','opacity-0'); });
document.addEventListener('drop', async e => {
e.preventDefault();
$('dragOverlay').classList.replace('opacity-100','opacity-0');
const dt = e.dataTransfer;
logDrop('types', JSON.stringify(dt.types));
for (const type of dt.types) {
try { logDrop('getData('+type+')', dt.getData(type)); } catch(err) { logDrop('getData('+type+')', 'ERROR: '+err); }
}
if (dt.items) {
for (let i = 0; i < dt.items.length; i++) {
const item = dt.items[i];
logDrop('item['+i+']', 'kind='+item.kind+' type='+item.type);
if (item.kind === 'file') {
const f = item.getAsFile();
logDrop('item['+i+'].file', f ? 'name='+f.name+' size='+f.size+' type='+f.type : 'null');
const entry = item.webkitGetAsEntry?.();
logDrop('item['+i+'].entry', entry ? 'name='+entry.name+' isFile='+entry.isFile+' isDir='+entry.isDirectory : 'null');
}
}
}
if (dt.files?.length) {
for (let i = 0; i < dt.files.length; i++) {
logDrop('files['+i+']', 'name='+dt.files[i].name+' size='+dt.files[i].size);
}
}
logDrop('---', '');
const extracted = extractPaths(e.dataTransfer);
if (extracted.paths.length) { logDrop('action', (extracted.host ? 'SSH:'+extracted.host+' ' : '') + JSON.stringify(extracted.paths)); await addPaths(extracted.paths, extracted.host); return; }
const items = e.dataTransfer.items;
if (items?.length) {
const proms = [];
for (const item of items) {
if (item.kind !== 'file') continue;
const entry = item.webkitGetAsEntry?.();
if (entry) proms.push(readEntry(entry, ''));
else { const f = item.getAsFile(); if (f) proms.push(Promise.resolve([f])); }
}
if (proms.length) { await addFileObjects((await Promise.all(proms)).flat()); return; }
}
if (e.dataTransfer.files.length) await addFileObjects([...e.dataTransfer.files]);
});
$('pathPrefix').addEventListener('input', updateUI);
$('copyBtn').addEventListener('click', async () => {
try { await navigator.clipboard.writeText(genXml()); } catch {
const t = document.createElement('textarea'); t.value = genXml(); t.style.cssText='position:fixed;left:-9999px';
document.body.appendChild(t); t.select(); document.execCommand('copy'); document.body.removeChild(t);
}
$('toast').classList.add('opacity-100'); setTimeout(() => $('toast').classList.remove('opacity-100'), 1500);
});
$('clearBtn').addEventListener('click', () => { files.clear(); $('pathPrefix').value=''; updateUI(); });
$('updateBtn').addEventListener('click', updateFiles);
$('saveSetBtn').addEventListener('click', saveCurrentSet);
$('loadSetBtn').addEventListener('click', loadSelectedSet);
$('deleteSetBtn').addEventListener('click', deleteSelectedSet);
fetchSets();
<\/script>
</body>
</html>`;
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(HTML);
return;
}
if (req.method === 'POST' && req.url === '/read') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const { paths, host } = JSON.parse(body);
const results = host
? paths.flatMap(p => readFileSSH(host, p))
: paths.flatMap(p => readFile(p));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(results));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message }));
}
});
return;
}
if (req.method === 'GET' && req.url === '/sets') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(readSets()));
return;
}
if (req.method === 'POST' && req.url === '/sets') {
let body = '';
req.on('data', c => body += c);
req.on('end', () => {
try {
const { name, entries, prefix } = JSON.parse(body);
const s = readSets();
s[name] = { entries, prefix: prefix || '' };
writeSets(s);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message }));
}
});
return;
}
if (req.method === 'DELETE' && req.url.startsWith('/sets/')) {
const name = decodeURIComponent(req.url.slice(6));
const s = readSets();
delete s[name];
writeSets(s);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
return;
}
res.writeHead(404); res.end('Not found');
});
server.listen(PORT, '127.0.0.1', () => {
const url = `http://127.0.0.1:${PORT}`;
// OSC 8 hyperlink escape sequence — works in most modern terminals
const link = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
console.log(`repogui → ${link}`);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment