termcode
Version:
Superior terminal AI coding agent with enterprise-grade security, intelligent error recovery, performance monitoring, and plugin system - Advanced Claude Code alternative
221 lines (220 loc) • 8.21 kB
JavaScript
import { log } from "../util/logging.js";
import readline from "node:readline";
// Parse unified diff into individual hunks
export function parseUnifiedDiff(diffText) {
const files = [];
const lines = diffText.split('\n');
let currentFile = null;
let currentHunk = null;
let hunkCounter = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// File header: diff --git a/file b/file
if (line.startsWith('diff --git')) {
const match = line.match(/diff --git a\/(.+) b\/(.+)/);
if (match) {
currentFile = match[1]; // Use 'a' file path
files.push({ filePath: currentFile, hunks: [] });
currentHunk = null;
}
}
// Hunk header: @@ -oldStart,oldLines +newStart,newLines @@
else if (line.startsWith('@@') && currentFile) {
const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
if (match) {
// Save previous hunk
if (currentHunk) {
const currentFileObj = files[files.length - 1];
currentFileObj.hunks.push(currentHunk);
}
const oldStart = parseInt(match[1]);
const oldLines = match[2] ? parseInt(match[2]) : 1;
const newStart = parseInt(match[3]);
const newLines = match[4] ? parseInt(match[4]) : 1;
currentHunk = {
id: `hunk-${++hunkCounter}`,
filePath: currentFile,
oldStart,
oldLines,
newStart,
newLines,
header: line,
content: line + '\n',
selected: true // Default to selected
};
}
}
// Hunk content lines (context, additions, deletions)
else if (currentHunk && (line.startsWith(' ') || line.startsWith('+') || line.startsWith('-'))) {
currentHunk.content += line + '\n';
}
// File metadata lines - add to current hunk if exists
else if (currentHunk && (line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++'))) {
// Insert metadata before the hunk header
const headerIndex = currentHunk.content.indexOf(currentHunk.header);
if (headerIndex !== -1) {
currentHunk.content = currentHunk.content.slice(0, headerIndex) + line + '\n' + currentHunk.content.slice(headerIndex);
}
else {
currentHunk.content = line + '\n' + currentHunk.content;
}
}
}
// Save final hunk
if (currentHunk && currentFile) {
const currentFileObj = files[files.length - 1];
currentFileObj.hunks.push(currentHunk);
}
return { files };
}
// Generate diff text from selected hunks only
export function generateSelectedDiff(parsedDiff) {
let result = '';
for (const file of parsedDiff.files) {
const selectedHunks = file.hunks.filter(hunk => hunk.selected);
if (selectedHunks.length === 0)
continue;
// Add file header
result += `diff --git a/${file.filePath} b/${file.filePath}\n`;
// Add metadata (extract from first hunk)
const firstHunk = selectedHunks[0];
const lines = firstHunk.content.split('\n');
for (const line of lines) {
if (line.startsWith('index ') || line.startsWith('---') || line.startsWith('+++')) {
result += line + '\n';
}
else if (line.startsWith('@@')) {
break; // Stop at hunk header
}
}
// Add selected hunks
for (const hunk of selectedHunks) {
// Extract just the hunk content (without metadata)
const hunkLines = hunk.content.split('\n');
let inHunkContent = false;
for (const line of hunkLines) {
if (line.startsWith('@@')) {
inHunkContent = true;
}
if (inHunkContent && line.trim()) {
result += line + '\n';
}
}
}
}
return result;
}
// Interactive hunk selection in CLI
export async function selectHunksInteractively(parsedDiff) {
if (parsedDiff.files.length === 0) {
return parsedDiff;
}
log.step("Hunk Selection", "reviewing changes before applying...");
log.raw("");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
let currentHunkIndex = 0;
const allHunks = [];
// Flatten all hunks with file context
for (const file of parsedDiff.files) {
for (const hunk of file.hunks) {
allHunks.push(hunk);
}
}
if (allHunks.length === 0) {
rl.close();
return parsedDiff;
}
const showHunk = (index) => {
const hunk = allHunks[index];
const status = hunk.selected ? log.colors.green("✓ SELECTED") : log.colors.red("✗ SKIPPED");
console.clear();
log.raw(log.colors.bright(`🔍 Hunk ${index + 1} of ${allHunks.length}`));
log.raw(`File: ${log.colors.cyan(hunk.filePath)}`);
log.raw(`Status: ${status}`);
log.raw("");
// Show hunk content with syntax highlighting
const lines = hunk.content.split('\n');
for (const line of lines) {
if (line.startsWith('@@')) {
log.raw(log.colors.cyan(line));
}
else if (line.startsWith('+')) {
log.raw(log.colors.green(line));
}
else if (line.startsWith('-')) {
log.raw(log.colors.red(line));
}
else {
log.raw(log.colors.dim(line));
}
}
log.raw("");
log.raw(log.colors.dim("Commands: [s]elect/deselect • [n]ext • [p]rev • [a]ll • [q]uit"));
log.raw("");
};
const handleInput = (input) => {
const cmd = input.trim().toLowerCase();
switch (cmd) {
case 's':
case 'space':
allHunks[currentHunkIndex].selected = !allHunks[currentHunkIndex].selected;
showHunk(currentHunkIndex);
break;
case 'n':
if (currentHunkIndex < allHunks.length - 1) {
currentHunkIndex++;
showHunk(currentHunkIndex);
}
break;
case 'p':
if (currentHunkIndex > 0) {
currentHunkIndex--;
showHunk(currentHunkIndex);
}
break;
case 'a':
const allSelected = allHunks.every(h => h.selected);
allHunks.forEach(h => h.selected = !allSelected);
showHunk(currentHunkIndex);
break;
case 'q':
rl.close();
return;
default:
log.raw(log.colors.yellow("Unknown command. Use s, n, p, a, or q"));
break;
}
};
showHunk(0);
return new Promise((resolve) => {
rl.on('line', handleInput);
rl.on('close', () => {
const selectedCount = allHunks.filter(h => h.selected).length;
log.raw("");
log.success(`Selected ${selectedCount} of ${allHunks.length} hunks for application`);
resolve(parsedDiff);
});
});
}
// Get summary of selected hunks
export function getSelectionSummary(parsedDiff) {
let totalHunks = 0;
let selectedHunks = 0;
const affectedFiles = [];
for (const file of parsedDiff.files) {
const fileSelectedHunks = file.hunks.filter(h => h.selected);
totalHunks += file.hunks.length;
selectedHunks += fileSelectedHunks.length;
if (fileSelectedHunks.length > 0) {
affectedFiles.push(file.filePath);
}
}
return {
totalHunks,
selectedHunks,
affectedFiles
};
}