Terminal-based AI coding agent with interactive TUI for autonomous code generation.
Features: - Interactive TUI with React/Ink - Autonomous agent with tool calls (bash, read, write, edit, glob, grep) - Permission system with pattern-based rules - Session management with auto-compaction - Dual providers: GitHub Copilot and Ollama - MCP server integration - Todo panel and theme system - Streaming responses - GitHub-compatible project context
This commit is contained in:
13
src/utils/diff.ts
Normal file
13
src/utils/diff.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Diff utility for generating colored git-style diff output
|
||||
*/
|
||||
|
||||
export type {
|
||||
DiffLine,
|
||||
DiffHunk,
|
||||
DiffResult,
|
||||
DiffLineType,
|
||||
} from "@/types/diff";
|
||||
export { generateDiff } from "@utils/diff/generate";
|
||||
export { formatDiff, formatCompactDiff } from "@utils/diff/format";
|
||||
export { parseDiffOutput, isDiffContent, stripAnsi } from "@utils/diff/index";
|
||||
97
src/utils/diff/format.ts
Normal file
97
src/utils/diff/format.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Diff formatting utilities
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import { LINE_PREFIXES } from "@constants/diff";
|
||||
import type { DiffResult, DiffLine, DiffLineType } from "@/types/diff";
|
||||
import { computeLCS } from "@utils/diff/lcs";
|
||||
import { generateDiffLines } from "@utils/diff/lines";
|
||||
|
||||
// Line formatters by type
|
||||
const LINE_FORMATTERS: Record<DiffLineType, (content: string) => string> = {
|
||||
add: (content) => chalk.green(`${LINE_PREFIXES.add}${content}`),
|
||||
remove: (content) => chalk.red(`${LINE_PREFIXES.remove}${content}`),
|
||||
context: (content) => chalk.gray(`${LINE_PREFIXES.context}${content}`),
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a single diff line
|
||||
*/
|
||||
const formatLine = (line: DiffLine): string =>
|
||||
LINE_FORMATTERS[line.type](line.content);
|
||||
|
||||
/**
|
||||
* Format diff as colored string for terminal output
|
||||
*/
|
||||
export const formatDiff = (diff: DiffResult, filePath?: string): string => {
|
||||
if (diff.hunks.length === 0) {
|
||||
return chalk.gray("No changes");
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header
|
||||
if (filePath) {
|
||||
lines.push(chalk.bold(`--- a/${filePath}`));
|
||||
lines.push(chalk.bold(`+++ b/${filePath}`));
|
||||
}
|
||||
|
||||
// Hunks
|
||||
for (const hunk of diff.hunks) {
|
||||
lines.push(
|
||||
chalk.cyan(
|
||||
`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const line of hunk.lines) {
|
||||
lines.push(formatLine(line));
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
lines.push("");
|
||||
lines.push(
|
||||
chalk.green(`+${diff.additions}`) + " / " + chalk.red(`-${diff.deletions}`),
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a compact diff showing only changed lines (no context)
|
||||
*/
|
||||
export const formatCompactDiff = (
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
): string => {
|
||||
const oldLines = oldContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
|
||||
const dp = computeLCS(oldLines, newLines);
|
||||
const diffLines = generateDiffLines(oldLines, newLines, dp);
|
||||
|
||||
const lines: string[] = [];
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
for (const line of diffLines) {
|
||||
if (line.type === "add") {
|
||||
lines.push(chalk.green(`+ ${line.content}`));
|
||||
additions++;
|
||||
} else if (line.type === "remove") {
|
||||
lines.push(chalk.red(`- ${line.content}`));
|
||||
deletions++;
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return chalk.gray("No changes");
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(chalk.green(`+${additions}`) + " / " + chalk.red(`-${deletions}`));
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
33
src/utils/diff/generate.ts
Normal file
33
src/utils/diff/generate.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Diff generation utilities
|
||||
*/
|
||||
|
||||
import type { DiffResult } from "@/types/diff";
|
||||
import { computeLCS } from "@utils/diff/lcs";
|
||||
import { generateDiffLines } from "@utils/diff/lines";
|
||||
import { groupIntoHunks } from "@utils/diff/hunks";
|
||||
|
||||
/**
|
||||
* Generate a diff between old and new content
|
||||
*/
|
||||
export const generateDiff = (
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
): DiffResult => {
|
||||
const oldLines = oldContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
|
||||
const dp = computeLCS(oldLines, newLines);
|
||||
const diffLines = generateDiffLines(oldLines, newLines, dp);
|
||||
const hunks = groupIntoHunks(diffLines);
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
|
||||
for (const line of diffLines) {
|
||||
if (line.type === "add") additions++;
|
||||
if (line.type === "remove") deletions++;
|
||||
}
|
||||
|
||||
return { hunks, additions, deletions };
|
||||
};
|
||||
97
src/utils/diff/hunks.ts
Normal file
97
src/utils/diff/hunks.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Diff hunk grouping utilities
|
||||
*/
|
||||
|
||||
import { DIFF_CONTEXT_LINES } from "@constants/diff";
|
||||
import type { DiffLine, DiffHunk } from "@/types/diff";
|
||||
|
||||
/**
|
||||
* Group diff lines into hunks with context
|
||||
*/
|
||||
export const groupIntoHunks = (
|
||||
lines: DiffLine[],
|
||||
contextLines = DIFF_CONTEXT_LINES,
|
||||
): DiffHunk[] => {
|
||||
const hunks: DiffHunk[] = [];
|
||||
let currentHunk: DiffHunk | null = null;
|
||||
let oldLine = 1;
|
||||
let newLine = 1;
|
||||
let contextBuffer: DiffLine[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const isChange = line.type !== "context";
|
||||
|
||||
if (isChange) {
|
||||
if (!currentHunk) {
|
||||
// Start new hunk with context
|
||||
const contextStart = Math.max(0, contextBuffer.length - contextLines);
|
||||
currentHunk = {
|
||||
oldStart: oldLine - (contextBuffer.length - contextStart),
|
||||
oldCount: 0,
|
||||
newStart: newLine - (contextBuffer.length - contextStart),
|
||||
newCount: 0,
|
||||
lines: contextBuffer.slice(contextStart),
|
||||
};
|
||||
currentHunk.oldCount += currentHunk.lines.length;
|
||||
currentHunk.newCount += currentHunk.lines.length;
|
||||
}
|
||||
|
||||
currentHunk.lines.push(line);
|
||||
if (line.type === "remove") {
|
||||
currentHunk.oldCount++;
|
||||
oldLine++;
|
||||
} else {
|
||||
currentHunk.newCount++;
|
||||
newLine++;
|
||||
}
|
||||
contextBuffer = [];
|
||||
} else {
|
||||
if (currentHunk) {
|
||||
// Check if we should close the hunk
|
||||
let remainingChanges = false;
|
||||
for (
|
||||
let j = i + 1;
|
||||
j < lines.length && j <= i + contextLines * 2;
|
||||
j++
|
||||
) {
|
||||
if (lines[j].type !== "context") {
|
||||
remainingChanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingChanges) {
|
||||
currentHunk.lines.push(line);
|
||||
currentHunk.oldCount++;
|
||||
currentHunk.newCount++;
|
||||
} else {
|
||||
// Add trailing context and close hunk
|
||||
const trailingContext = [];
|
||||
for (let j = i; j < lines.length && j < i + contextLines; j++) {
|
||||
if (lines[j].type === "context") {
|
||||
trailingContext.push(lines[j]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
currentHunk.lines.push(...trailingContext);
|
||||
currentHunk.oldCount += trailingContext.length;
|
||||
currentHunk.newCount += trailingContext.length;
|
||||
hunks.push(currentHunk);
|
||||
currentHunk = null;
|
||||
}
|
||||
}
|
||||
|
||||
contextBuffer.push(line);
|
||||
oldLine++;
|
||||
newLine++;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHunk) {
|
||||
hunks.push(currentHunk);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
162
src/utils/diff/index.ts
Normal file
162
src/utils/diff/index.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Diff View Utility Functions
|
||||
*/
|
||||
|
||||
import type { DiffLineData } from "@/types/tui";
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from a string
|
||||
*/
|
||||
export const stripAnsi = (str: string): string => {
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if content looks like a diff output
|
||||
*/
|
||||
export const isDiffContent = (content: string): boolean => {
|
||||
// Strip ANSI codes for pattern matching
|
||||
const cleanContent = stripAnsi(content);
|
||||
|
||||
// Check for common diff markers (not anchored to line start due to title prefixes)
|
||||
const diffPatterns = [
|
||||
/@@\s*-\d+/m, // Hunk header
|
||||
/---\s+[ab]?\//m, // File header
|
||||
/\+\+\+\s+[ab]?\//m, // File header
|
||||
];
|
||||
|
||||
return diffPatterns.some((pattern) => pattern.test(cleanContent));
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse raw diff output into structured DiffLineData
|
||||
*/
|
||||
export const parseDiffOutput = (
|
||||
diffOutput: string,
|
||||
): {
|
||||
lines: DiffLineData[];
|
||||
filePath?: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
} => {
|
||||
const rawLines = diffOutput.split("\n");
|
||||
const lines: DiffLineData[] = [];
|
||||
let filePath: string | undefined;
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let currentOldLine = 0;
|
||||
let currentNewLine = 0;
|
||||
let inDiff = false; // Track if we're inside the diff content
|
||||
|
||||
for (const rawLine of rawLines) {
|
||||
// Strip ANSI codes for parsing
|
||||
const cleanLine = stripAnsi(rawLine);
|
||||
|
||||
// Skip title lines (e.g., "Edited: filename" before diff starts)
|
||||
if (
|
||||
!inDiff &&
|
||||
!cleanLine.startsWith("---") &&
|
||||
!cleanLine.startsWith("@@")
|
||||
) {
|
||||
// Check if this looks like a title line
|
||||
if (cleanLine.match(/^(Edited|Created|Wrote|Modified|Deleted):/i)) {
|
||||
continue;
|
||||
}
|
||||
// Skip empty lines before diff starts
|
||||
if (cleanLine.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// File header detection (marks start of diff)
|
||||
if (cleanLine.startsWith("--- a/") || cleanLine.startsWith("--- ")) {
|
||||
inDiff = true;
|
||||
continue; // Skip old file header
|
||||
}
|
||||
|
||||
if (cleanLine.startsWith("+++ b/") || cleanLine.startsWith("+++ ")) {
|
||||
inDiff = true;
|
||||
filePath = cleanLine.replace(/^\+\+\+ [ab]?\//, "").trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hunk header: @@ -oldStart,oldCount +newStart,newCount @@
|
||||
const hunkMatch = cleanLine.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
||||
if (hunkMatch) {
|
||||
inDiff = true;
|
||||
currentOldLine = parseInt(hunkMatch[1], 10);
|
||||
currentNewLine = parseInt(hunkMatch[2], 10);
|
||||
lines.push({ type: "hunk", content: cleanLine });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process diff content lines after we've entered the diff
|
||||
if (!inDiff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Summary line (e.g., "+5 / -3") - marks end of diff
|
||||
if (cleanLine.match(/^\+\d+\s*\/\s*-\d+$/)) {
|
||||
const summaryMatch = cleanLine.match(/^\+(\d+)\s*\/\s*-(\d+)$/);
|
||||
if (summaryMatch) {
|
||||
// Use parsed values if we haven't counted any yet
|
||||
if (additions === 0 && deletions === 0) {
|
||||
additions = parseInt(summaryMatch[1], 10);
|
||||
deletions = parseInt(summaryMatch[2], 10);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Addition line
|
||||
if (cleanLine.startsWith("+")) {
|
||||
lines.push({
|
||||
type: "add",
|
||||
content: cleanLine.slice(1),
|
||||
newLineNum: currentNewLine,
|
||||
});
|
||||
currentNewLine++;
|
||||
additions++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deletion line
|
||||
if (cleanLine.startsWith("-")) {
|
||||
lines.push({
|
||||
type: "remove",
|
||||
content: cleanLine.slice(1),
|
||||
oldLineNum: currentOldLine,
|
||||
});
|
||||
currentOldLine++;
|
||||
deletions++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Context line (starts with space or is part of diff)
|
||||
if (cleanLine.startsWith(" ")) {
|
||||
lines.push({
|
||||
type: "context",
|
||||
content: cleanLine.slice(1),
|
||||
oldLineNum: currentOldLine,
|
||||
newLineNum: currentNewLine,
|
||||
});
|
||||
currentOldLine++;
|
||||
currentNewLine++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle empty lines in diff context
|
||||
if (cleanLine === "") {
|
||||
lines.push({
|
||||
type: "context",
|
||||
content: "",
|
||||
oldLineNum: currentOldLine,
|
||||
newLineNum: currentNewLine,
|
||||
});
|
||||
currentOldLine++;
|
||||
currentNewLine++;
|
||||
}
|
||||
}
|
||||
|
||||
return { lines, filePath, additions, deletions };
|
||||
};
|
||||
29
src/utils/diff/lcs.ts
Normal file
29
src/utils/diff/lcs.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Longest Common Subsequence computation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Simple line-based diff using Longest Common Subsequence
|
||||
*/
|
||||
export const computeLCS = (
|
||||
oldLines: string[],
|
||||
newLines: string[],
|
||||
): number[][] => {
|
||||
const m = oldLines.length;
|
||||
const n = newLines.length;
|
||||
const dp: number[][] = Array(m + 1)
|
||||
.fill(null)
|
||||
.map(() => Array(n + 1).fill(0));
|
||||
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (oldLines[i - 1] === newLines[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dp;
|
||||
};
|
||||
46
src/utils/diff/lines.ts
Normal file
46
src/utils/diff/lines.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Diff line generation utilities
|
||||
*/
|
||||
|
||||
import type { DiffLine, DiffOperation } from "@/types/diff";
|
||||
|
||||
/**
|
||||
* Generate diff lines from LCS matrix
|
||||
*/
|
||||
export const generateDiffLines = (
|
||||
oldLines: string[],
|
||||
newLines: string[],
|
||||
dp: number[][],
|
||||
): DiffLine[] => {
|
||||
const result: DiffLine[] = [];
|
||||
let i = oldLines.length;
|
||||
let j = newLines.length;
|
||||
|
||||
const ops: DiffOperation[] = [];
|
||||
|
||||
while (i > 0 || j > 0) {
|
||||
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
|
||||
ops.unshift({ type: "context", oldIdx: i - 1, newIdx: j - 1 });
|
||||
i--;
|
||||
j--;
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
ops.unshift({ type: "add", newIdx: j - 1 });
|
||||
j--;
|
||||
} else {
|
||||
ops.unshift({ type: "remove", oldIdx: i - 1 });
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.type === "context" && op.oldIdx !== undefined) {
|
||||
result.push({ type: "context", content: oldLines[op.oldIdx] });
|
||||
} else if (op.type === "remove" && op.oldIdx !== undefined) {
|
||||
result.push({ type: "remove", content: oldLines[op.oldIdx] });
|
||||
} else if (op.type === "add" && op.newIdx !== undefined) {
|
||||
result.push({ type: "add", content: newLines[op.newIdx] });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
14
src/utils/ensure-directories.ts
Normal file
14
src/utils/ensure-directories.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Ensures all XDG-compliant directories exist
|
||||
*/
|
||||
|
||||
import { mkdir } from "fs/promises";
|
||||
import { DIRS } from "@constants/paths";
|
||||
|
||||
export const ensureXdgDirectories = async (): Promise<void> => {
|
||||
await mkdir(DIRS.config, { recursive: true });
|
||||
await mkdir(DIRS.data, { recursive: true });
|
||||
await mkdir(DIRS.cache, { recursive: true });
|
||||
await mkdir(DIRS.state, { recursive: true });
|
||||
await mkdir(DIRS.sessions, { recursive: true });
|
||||
};
|
||||
77
src/utils/progress-bar.ts
Normal file
77
src/utils/progress-bar.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Progress bar rendering utilities
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
|
||||
const DEFAULT_BAR_WIDTH = 40;
|
||||
|
||||
interface ProgressBarOptions {
|
||||
width?: number;
|
||||
filledChar?: string;
|
||||
emptyChar?: string;
|
||||
showPercentage?: boolean;
|
||||
}
|
||||
|
||||
const defaultOptions: Required<ProgressBarOptions> = {
|
||||
width: DEFAULT_BAR_WIDTH,
|
||||
filledChar: "█",
|
||||
emptyChar: "░",
|
||||
showPercentage: true,
|
||||
};
|
||||
|
||||
export const renderProgressBar = (
|
||||
percent: number,
|
||||
options: ProgressBarOptions = {},
|
||||
): string => {
|
||||
const opts = { ...defaultOptions, ...options };
|
||||
const clampedPercent = Math.max(0, Math.min(100, percent));
|
||||
const filledWidth = Math.round((clampedPercent / 100) * opts.width);
|
||||
const emptyWidth = opts.width - filledWidth;
|
||||
|
||||
const filledPart = opts.filledChar.repeat(filledWidth);
|
||||
const emptyPart = opts.emptyChar.repeat(emptyWidth);
|
||||
|
||||
const color =
|
||||
clampedPercent > 80
|
||||
? chalk.red
|
||||
: clampedPercent > 50
|
||||
? chalk.yellow
|
||||
: chalk.blueBright;
|
||||
|
||||
const bar = color(filledPart) + chalk.gray(emptyPart);
|
||||
|
||||
if (opts.showPercentage) {
|
||||
return `${bar} ${clampedPercent.toFixed(0)}% used`;
|
||||
}
|
||||
|
||||
return bar;
|
||||
};
|
||||
|
||||
export const renderUsageBar = (
|
||||
title: string,
|
||||
used: number,
|
||||
total: number,
|
||||
resetInfo?: string,
|
||||
): string[] => {
|
||||
const lines: string[] = [];
|
||||
const percent = total > 0 ? (used / total) * 100 : 0;
|
||||
|
||||
lines.push(chalk.bold(title));
|
||||
lines.push(renderProgressBar(percent));
|
||||
|
||||
if (resetInfo) {
|
||||
lines.push(chalk.gray(resetInfo));
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
export const renderUnlimitedBar = (title: string): string[] => {
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.bold(title));
|
||||
lines.push(
|
||||
chalk.green("█".repeat(DEFAULT_BAR_WIDTH)) + " " + chalk.green("Unlimited"),
|
||||
);
|
||||
return lines;
|
||||
};
|
||||
18
src/utils/syntax-highlight.ts
Normal file
18
src/utils/syntax-highlight.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Syntax Highlighting Utility
|
||||
*
|
||||
* Provides terminal-friendly syntax highlighting for code
|
||||
* using cli-highlight with language auto-detection.
|
||||
*/
|
||||
|
||||
export {
|
||||
detectLanguage,
|
||||
isLanguageSupported,
|
||||
getLanguageDisplayName,
|
||||
} from "@utils/syntax-highlight/detect";
|
||||
|
||||
export {
|
||||
highlightLine,
|
||||
highlightCode,
|
||||
highlightLines,
|
||||
} from "@utils/syntax-highlight/highlight";
|
||||
45
src/utils/syntax-highlight/detect.ts
Normal file
45
src/utils/syntax-highlight/detect.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Language detection utilities
|
||||
*/
|
||||
|
||||
import { supportsLanguage } from "cli-highlight";
|
||||
import {
|
||||
EXTENSION_TO_LANGUAGE,
|
||||
FILENAME_TO_LANGUAGE,
|
||||
LANGUAGE_DISPLAY_NAMES,
|
||||
} from "@constants/syntax-highlight";
|
||||
|
||||
/**
|
||||
* Detect the programming language from a file path
|
||||
*/
|
||||
export const detectLanguage = (filePath: string): string | undefined => {
|
||||
if (!filePath) return undefined;
|
||||
|
||||
// Check full filename first
|
||||
const filename = filePath.split("/").pop() || "";
|
||||
if (FILENAME_TO_LANGUAGE[filename]) {
|
||||
return FILENAME_TO_LANGUAGE[filename];
|
||||
}
|
||||
|
||||
// Check extension
|
||||
const lastDot = filename.lastIndexOf(".");
|
||||
if (lastDot !== -1) {
|
||||
const ext = filename.slice(lastDot).toLowerCase();
|
||||
return EXTENSION_TO_LANGUAGE[ext];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a language is supported for highlighting
|
||||
*/
|
||||
export const isLanguageSupported = (language: string): boolean =>
|
||||
supportsLanguage(language);
|
||||
|
||||
/**
|
||||
* Get a human-readable language name
|
||||
*/
|
||||
export const getLanguageDisplayName = (language: string): string =>
|
||||
LANGUAGE_DISPLAY_NAMES[language] ||
|
||||
language.charAt(0).toUpperCase() + language.slice(1);
|
||||
91
src/utils/syntax-highlight/highlight.ts
Normal file
91
src/utils/syntax-highlight/highlight.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Code highlighting utilities
|
||||
*/
|
||||
|
||||
import { highlight } from "cli-highlight";
|
||||
import { detectLanguage } from "@utils/syntax-highlight/detect";
|
||||
|
||||
/**
|
||||
* Highlight a single line of code
|
||||
*
|
||||
* @param line - The code line to highlight
|
||||
* @param language - The programming language
|
||||
* @returns Highlighted line with ANSI codes
|
||||
*/
|
||||
export const highlightLine = (line: string, language?: string): string => {
|
||||
if (!language || !line.trim()) {
|
||||
return line;
|
||||
}
|
||||
|
||||
try {
|
||||
// cli-highlight expects full code blocks, so we highlight and extract
|
||||
const highlighted = highlight(line, {
|
||||
language,
|
||||
ignoreIllegals: true,
|
||||
});
|
||||
return highlighted;
|
||||
} catch {
|
||||
// Return original line if highlighting fails
|
||||
return line;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight a block of code
|
||||
*
|
||||
* @param code - The code to highlight
|
||||
* @param language - The programming language (auto-detected if not provided)
|
||||
* @param filePath - Optional file path for language detection
|
||||
* @returns Highlighted code with ANSI codes
|
||||
*/
|
||||
export const highlightCode = (
|
||||
code: string,
|
||||
language?: string,
|
||||
filePath?: string,
|
||||
): string => {
|
||||
const lang = language || (filePath ? detectLanguage(filePath) : undefined);
|
||||
|
||||
if (!lang) {
|
||||
return code;
|
||||
}
|
||||
|
||||
try {
|
||||
return highlight(code, {
|
||||
language: lang,
|
||||
ignoreIllegals: true,
|
||||
});
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlight multiple lines while preserving line structure
|
||||
* Useful for diff views where each line needs individual highlighting
|
||||
*
|
||||
* @param lines - Array of code lines
|
||||
* @param language - The programming language
|
||||
* @returns Array of highlighted lines
|
||||
*/
|
||||
export const highlightLines = (
|
||||
lines: string[],
|
||||
language?: string,
|
||||
): string[] => {
|
||||
if (!language || lines.length === 0) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
try {
|
||||
// Highlight the entire block to maintain context
|
||||
const fullCode = lines.join("\n");
|
||||
const highlighted = highlight(fullCode, {
|
||||
language,
|
||||
ignoreIllegals: true,
|
||||
});
|
||||
|
||||
// Split back into lines
|
||||
return highlighted.split("\n");
|
||||
} catch {
|
||||
return lines;
|
||||
}
|
||||
};
|
||||
326
src/utils/terminal.ts
Normal file
326
src/utils/terminal.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Terminal UI helpers for formatting and display
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import ora, { Ora } from "ora";
|
||||
import boxen from "boxen";
|
||||
import { TERMINAL_SEQUENCES } from "@constants/ui";
|
||||
import { DISABLE_MOUSE_TRACKING } from "@constants/terminal";
|
||||
|
||||
/**
|
||||
* Spinner state
|
||||
*/
|
||||
let spinner: Ora | null = null;
|
||||
|
||||
/**
|
||||
* Track if exit handlers have been registered
|
||||
*/
|
||||
let exitHandlersRegistered = false;
|
||||
|
||||
/**
|
||||
* Emergency cleanup for terminal state on process exit
|
||||
*/
|
||||
const emergencyTerminalCleanup = (): void => {
|
||||
try {
|
||||
process.stdout.write(
|
||||
DISABLE_MOUSE_TRACKING +
|
||||
TERMINAL_SEQUENCES.SHOW_CURSOR +
|
||||
TERMINAL_SEQUENCES.LEAVE_ALTERNATE_SCREEN,
|
||||
);
|
||||
} catch {
|
||||
// TODO: Create a catch with a logger to log errors
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register process exit handlers to ensure terminal cleanup
|
||||
*/
|
||||
export const registerExitHandlers = (): void => {
|
||||
if (exitHandlersRegistered) return;
|
||||
exitHandlersRegistered = true;
|
||||
|
||||
process.on("exit", emergencyTerminalCleanup);
|
||||
process.on("SIGINT", () => {
|
||||
emergencyTerminalCleanup();
|
||||
process.exit(130);
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
emergencyTerminalCleanup();
|
||||
process.exit(143);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Print success message
|
||||
*/
|
||||
export const successMessage = (message: string): void => {
|
||||
console.log(chalk.green("✓") + " " + message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Print error message
|
||||
*/
|
||||
export const errorMessage = (message: string): void => {
|
||||
console.error(chalk.red("✗") + " " + message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Print warning message
|
||||
*/
|
||||
export const warningMessage = (message: string): void => {
|
||||
console.log(chalk.yellow("⚠") + " " + message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Print info message
|
||||
*/
|
||||
export const infoMessage = (message: string): void => {
|
||||
console.log(chalk.blue("ℹ") + " " + message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Print header
|
||||
*/
|
||||
export const headerMessage = (text: string): void => {
|
||||
console.log("\n" + chalk.bold.cyan(text) + "\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* Print a boxed message
|
||||
*/
|
||||
export const boxMessage = (message: string, title?: string): void => {
|
||||
console.log(
|
||||
boxen(message, {
|
||||
padding: 1,
|
||||
margin: 1,
|
||||
borderStyle: "round",
|
||||
borderColor: "cyan",
|
||||
title: title,
|
||||
titleAlignment: "center",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Start spinner
|
||||
*/
|
||||
export const startSpinner = (text: string): void => {
|
||||
spinner = ora({
|
||||
text,
|
||||
color: "cyan",
|
||||
}).start();
|
||||
};
|
||||
|
||||
/**
|
||||
* Update spinner text
|
||||
*/
|
||||
export const updateSpinner = (text: string): void => {
|
||||
if (spinner) {
|
||||
spinner.text = text;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop spinner with success
|
||||
*/
|
||||
export const succeedSpinner = (text?: string): void => {
|
||||
if (spinner) {
|
||||
spinner.succeed(text);
|
||||
spinner = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop spinner with failure
|
||||
*/
|
||||
export const failSpinner = (text?: string): void => {
|
||||
if (spinner) {
|
||||
spinner.fail(text);
|
||||
spinner = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop spinner
|
||||
*/
|
||||
export const stopSpinner = (): void => {
|
||||
if (spinner) {
|
||||
spinner.stop();
|
||||
spinner = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Print divider
|
||||
*/
|
||||
export const dividerMessage = (): void => {
|
||||
console.log(chalk.gray("─".repeat(process.stdout.columns || 80)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Print JSON with syntax highlighting
|
||||
*/
|
||||
export const hightLigthedJson = (data: unknown): void => {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Format file path
|
||||
*/
|
||||
export const filePath = (path: string): string => chalk.cyan(path);
|
||||
|
||||
/**
|
||||
* Format command
|
||||
*/
|
||||
export const commandFormat = (cmd: string): string => chalk.yellow(cmd);
|
||||
|
||||
/**
|
||||
* Format key
|
||||
*/
|
||||
export const keyFormat = (text: string): string => chalk.bold(text);
|
||||
|
||||
/**
|
||||
* Format code
|
||||
*/
|
||||
export const codeFormat = (text: string): string => chalk.gray(text);
|
||||
|
||||
/**
|
||||
* Print a table
|
||||
*/
|
||||
export const tableMessage = (data: Array<Record<string, string>>): void => {
|
||||
if (data.length === 0) return;
|
||||
|
||||
const keys = Object.keys(data[0]);
|
||||
const widths = keys.map((key) =>
|
||||
Math.max(key.length, ...data.map((row) => String(row[key]).length)),
|
||||
);
|
||||
|
||||
// Header
|
||||
const headerLine = keys.map((key, i) => key.padEnd(widths[i])).join(" ");
|
||||
console.log(chalk.bold(headerLine));
|
||||
console.log(chalk.gray("─".repeat(headerLine.length)));
|
||||
|
||||
// Rows
|
||||
data.forEach((row) => {
|
||||
const line = keys
|
||||
.map((key, i) => String(row[key]).padEnd(widths[i]))
|
||||
.join(" ");
|
||||
console.log(line);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Print progress bar
|
||||
*/
|
||||
export const progressBar = (
|
||||
current: number,
|
||||
total: number,
|
||||
label?: string,
|
||||
): void => {
|
||||
const percent = Math.floor((current / total) * 100);
|
||||
const filled = Math.floor((current / total) * 40);
|
||||
const empty = 40 - filled;
|
||||
const bar = "█".repeat(filled) + "░".repeat(empty);
|
||||
const text = label ? `${label} ` : "";
|
||||
process.stdout.write(`\r${text}${bar} ${percent}%`);
|
||||
if (current === total) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear line
|
||||
*/
|
||||
export const clearLine = (): void => {
|
||||
process.stdout.write("\r\x1b[K");
|
||||
};
|
||||
|
||||
/**
|
||||
* Enter fullscreen mode (alternate screen buffer)
|
||||
*/
|
||||
export const enterFullscreen = (): void => {
|
||||
process.stdout.write(
|
||||
TERMINAL_SEQUENCES.ENTER_ALTERNATE_SCREEN +
|
||||
TERMINAL_SEQUENCES.CLEAR_SCREEN +
|
||||
TERMINAL_SEQUENCES.CURSOR_HOME,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Exit fullscreen mode (restore main screen buffer)
|
||||
* Disables all mouse tracking modes that might have been enabled
|
||||
*/
|
||||
export const exitFullscreen = (): void => {
|
||||
process.stdout.write(
|
||||
DISABLE_MOUSE_TRACKING +
|
||||
TERMINAL_SEQUENCES.SHOW_CURSOR +
|
||||
TERMINAL_SEQUENCES.LEAVE_ALTERNATE_SCREEN,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the entire screen and move cursor to home
|
||||
*/
|
||||
export const clearScreen = (): void => {
|
||||
process.stdout.write(
|
||||
TERMINAL_SEQUENCES.CLEAR_SCREEN +
|
||||
TERMINAL_SEQUENCES.CLEAR_SCROLLBACK +
|
||||
TERMINAL_SEQUENCES.CURSOR_HOME,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ask for confirmation
|
||||
*/
|
||||
export const askConfirm = async (message: string): Promise<boolean> => {
|
||||
const inquirer = (await import("inquirer")).default;
|
||||
const answer = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "confirmed",
|
||||
message,
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
return answer.confirmed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prompt for input
|
||||
*/
|
||||
export const inputPrompt = async (
|
||||
message: string,
|
||||
defaultValue?: string,
|
||||
): Promise<string> => {
|
||||
const inquirer = (await import("inquirer")).default;
|
||||
const answer = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "value",
|
||||
message,
|
||||
default: defaultValue,
|
||||
},
|
||||
]);
|
||||
return answer.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Select from list
|
||||
*/
|
||||
export const selectList = async (
|
||||
message: string,
|
||||
choices: string[],
|
||||
): Promise<string> => {
|
||||
const inquirer = (await import("inquirer")).default;
|
||||
const answer = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
name: "selected",
|
||||
message,
|
||||
choices,
|
||||
},
|
||||
]);
|
||||
return answer.selected;
|
||||
};
|
||||
28
src/utils/tools.ts
Normal file
28
src/utils/tools.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Determine if a tool operation should be quiet (not shown in logs)
|
||||
* Quiet operations are read-only exploration tasks
|
||||
*/
|
||||
|
||||
import { QUIET_BASH_PATTERNS } from "@constants/bashPatterns";
|
||||
import { TOOL_NAMES } from "@constants/tools";
|
||||
|
||||
export const isQuietTool = (
|
||||
toolName: string,
|
||||
args?: Record<string, unknown>,
|
||||
): boolean => {
|
||||
// Read operations are always quiet
|
||||
if (TOOL_NAMES.includes(toolName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bash commands - check if it's a read-only exploration command
|
||||
if (toolName === "bash" && args?.command) {
|
||||
const command = String(args.command).trim();
|
||||
const bashPatternFound = QUIET_BASH_PATTERNS.some((pattern) =>
|
||||
pattern.test(command),
|
||||
);
|
||||
return bashPatternFound;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
33
src/utils/tui-app/index.ts
Normal file
33
src/utils/tui-app/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* TUI App Utilities - Exports
|
||||
*/
|
||||
|
||||
export {
|
||||
isMouseEscapeSequence,
|
||||
cleanInput,
|
||||
insertAtCursor,
|
||||
deleteBeforeCursor,
|
||||
calculateCursorPosition,
|
||||
} from "@utils/tui-app/input-utils";
|
||||
|
||||
export {
|
||||
isInputLocked,
|
||||
isModalCommand,
|
||||
isMainInputActive,
|
||||
isProcessing,
|
||||
} from "@utils/tui-app/mode-utils";
|
||||
|
||||
export {
|
||||
countLines,
|
||||
shouldSummarizePaste,
|
||||
generatePlaceholder,
|
||||
createPastedContent,
|
||||
generatePasteId,
|
||||
addPastedBlock,
|
||||
updatePastedBlockPositions,
|
||||
updatePastedBlocksAfterDelete,
|
||||
expandPastedContent,
|
||||
getDisplayBuffer,
|
||||
normalizeLineEndings,
|
||||
clearPastedBlocks,
|
||||
} from "@utils/tui-app/paste-utils";
|
||||
142
src/utils/tui-app/input-utils.ts
Normal file
142
src/utils/tui-app/input-utils.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* TUI App Input Utilities
|
||||
*
|
||||
* Helper functions for input handling in the TUI App
|
||||
*/
|
||||
|
||||
// Mouse escape sequence patterns for filtering
|
||||
// Note: Patterns for replacement use 'g' flag, patterns for testing don't
|
||||
const MOUSE_PATTERNS = {
|
||||
// Full escape sequences (for replacement)
|
||||
SGR_FULL: /\x1b\[<\d+;\d+;\d+[Mm]/g,
|
||||
X10_FULL: /\x1b\[M[\x20-\xff]{3}/g,
|
||||
// Partial sequences (when ESC is stripped by Ink) - for replacement
|
||||
SGR_PARTIAL: /\[<\d+;\d+;\d+[Mm]/g,
|
||||
// Just the coordinates part - for replacement
|
||||
SGR_COORDS_ONLY: /<\d+;\d+;\d+[Mm]/g,
|
||||
// Check patterns (no 'g' flag to avoid stateful matching)
|
||||
SGR_PARTIAL_CHECK: /\[<\d+;\d+;\d+[Mm]/,
|
||||
SGR_COORDS_CHECK: /^\d+;\d+;\d+[Mm]/,
|
||||
// String prefixes
|
||||
SGR_PREFIX: "\x1b[<",
|
||||
X10_PREFIX: "\x1b[M",
|
||||
BRACKET_SGR_PREFIX: "[<",
|
||||
} as const;
|
||||
|
||||
// Control character patterns for cleaning input
|
||||
const CONTROL_PATTERNS = {
|
||||
CONTROL_CHARS: /[\x00-\x1f\x7f]/g,
|
||||
ESCAPE_SEQUENCES: /\x1b\[.*?[a-zA-Z]/g,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check if input is a mouse escape sequence
|
||||
* Handles both full sequences and partial sequences where ESC was stripped
|
||||
*/
|
||||
export const isMouseEscapeSequence = (input: string): boolean => {
|
||||
if (!input) return false;
|
||||
|
||||
// Check for full SGR or X10 prefixes
|
||||
if (
|
||||
input.includes(MOUSE_PATTERNS.SGR_PREFIX) ||
|
||||
input.includes(MOUSE_PATTERNS.X10_PREFIX)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for partial SGR sequence (when ESC is stripped): [<64;45;22M
|
||||
if (input.includes(MOUSE_PATTERNS.BRACKET_SGR_PREFIX)) {
|
||||
if (MOUSE_PATTERNS.SGR_PARTIAL_CHECK.test(input)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for SGR coordinate pattern without prefix: <64;45;22M
|
||||
if (
|
||||
input.startsWith("<") &&
|
||||
MOUSE_PATTERNS.SGR_COORDS_CHECK.test(input.slice(1))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for just coordinates: 64;45;22M (unlikely but possible)
|
||||
if (MOUSE_PATTERNS.SGR_COORDS_CHECK.test(input)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean input by removing control characters, escape sequences, and mouse sequences
|
||||
*/
|
||||
export const cleanInput = (input: string): string => {
|
||||
return (
|
||||
input
|
||||
// Remove full mouse escape sequences
|
||||
.replace(MOUSE_PATTERNS.SGR_FULL, "")
|
||||
.replace(MOUSE_PATTERNS.X10_FULL, "")
|
||||
// Remove partial mouse sequences (when ESC is stripped)
|
||||
.replace(MOUSE_PATTERNS.SGR_PARTIAL, "")
|
||||
.replace(MOUSE_PATTERNS.SGR_COORDS_ONLY, "")
|
||||
// Remove control characters and other escape sequences
|
||||
.replace(CONTROL_PATTERNS.CONTROL_CHARS, "")
|
||||
.replace(CONTROL_PATTERNS.ESCAPE_SEQUENCES, "")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert text at cursor position in buffer
|
||||
*/
|
||||
export const insertAtCursor = (
|
||||
buffer: string,
|
||||
cursorPos: number,
|
||||
text: string,
|
||||
): { newBuffer: string; newCursorPos: number } => {
|
||||
const before = buffer.slice(0, cursorPos);
|
||||
const after = buffer.slice(cursorPos);
|
||||
return {
|
||||
newBuffer: before + text + after,
|
||||
newCursorPos: cursorPos + text.length,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete character before cursor
|
||||
*/
|
||||
export const deleteBeforeCursor = (
|
||||
buffer: string,
|
||||
cursorPos: number,
|
||||
): { newBuffer: string; newCursorPos: number } => {
|
||||
if (cursorPos <= 0) {
|
||||
return { newBuffer: buffer, newCursorPos: cursorPos };
|
||||
}
|
||||
const before = buffer.slice(0, cursorPos - 1);
|
||||
const after = buffer.slice(cursorPos);
|
||||
return {
|
||||
newBuffer: before + after,
|
||||
newCursorPos: cursorPos - 1,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate cursor line and column from buffer position
|
||||
*/
|
||||
export const calculateCursorPosition = (
|
||||
buffer: string,
|
||||
cursorPos: number,
|
||||
): { line: number; col: number } => {
|
||||
const lines = buffer.split("\n");
|
||||
let charCount = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (charCount + lines[i].length >= cursorPos || i === lines.length - 1) {
|
||||
let col = cursorPos - charCount;
|
||||
if (col > lines[i].length) col = lines[i].length;
|
||||
return { line: i, col };
|
||||
}
|
||||
charCount += lines[i].length + 1;
|
||||
}
|
||||
|
||||
return { line: 0, col: 0 };
|
||||
};
|
||||
63
src/utils/tui-app/mode-utils.ts
Normal file
63
src/utils/tui-app/mode-utils.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* TUI App Mode Utilities
|
||||
*
|
||||
* Helper functions for mode checking in the TUI App
|
||||
*/
|
||||
|
||||
import type { AppMode } from "@/types/tui";
|
||||
|
||||
// Modes that lock the input
|
||||
const LOCKED_MODES: ReadonlySet<AppMode> = new Set([
|
||||
"thinking",
|
||||
"tool_execution",
|
||||
"permission_prompt",
|
||||
"learning_prompt",
|
||||
]);
|
||||
|
||||
// Commands that open their own modal
|
||||
const MODAL_COMMANDS: ReadonlySet<string> = new Set([
|
||||
"model",
|
||||
"models",
|
||||
"agent",
|
||||
"theme",
|
||||
"mcp",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if input is locked based on current mode
|
||||
*/
|
||||
export const isInputLocked = (mode: AppMode): boolean => {
|
||||
return LOCKED_MODES.has(mode);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a command opens a modal
|
||||
*/
|
||||
export const isModalCommand = (command: string): boolean => {
|
||||
return MODAL_COMMANDS.has(command);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if main input should be active
|
||||
*/
|
||||
export const isMainInputActive = (
|
||||
mode: AppMode,
|
||||
isLocked: boolean,
|
||||
): boolean => {
|
||||
return (
|
||||
!isLocked &&
|
||||
mode !== "command_menu" &&
|
||||
mode !== "model_select" &&
|
||||
mode !== "agent_select" &&
|
||||
mode !== "theme_select" &&
|
||||
mode !== "mcp_select" &&
|
||||
mode !== "learning_prompt"
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if currently processing (thinking or tool execution)
|
||||
*/
|
||||
export const isProcessing = (mode: AppMode): boolean => {
|
||||
return mode === "thinking" || mode === "tool_execution";
|
||||
};
|
||||
209
src/utils/tui-app/paste-utils.ts
Normal file
209
src/utils/tui-app/paste-utils.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Utility functions for paste virtual text handling
|
||||
*/
|
||||
|
||||
import type { PastedContent, PasteState } from "@interfaces/PastedContent";
|
||||
import {
|
||||
PASTE_LINE_THRESHOLD,
|
||||
PASTE_CHAR_THRESHOLD,
|
||||
PASTE_PLACEHOLDER_FORMAT,
|
||||
} from "@constants/paste";
|
||||
|
||||
/**
|
||||
* Counts the number of lines in a string
|
||||
*/
|
||||
export const countLines = (text: string): number => {
|
||||
return (text.match(/\n/g)?.length ?? 0) + 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if pasted content should be summarized as virtual text
|
||||
*/
|
||||
export const shouldSummarizePaste = (content: string): boolean => {
|
||||
const lineCount = countLines(content);
|
||||
return (
|
||||
lineCount >= PASTE_LINE_THRESHOLD || content.length > PASTE_CHAR_THRESHOLD
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a placeholder string for pasted content
|
||||
*/
|
||||
export const generatePlaceholder = (lineCount: number): string => {
|
||||
return PASTE_PLACEHOLDER_FORMAT.replace("{lineCount}", String(lineCount));
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new pasted content entry
|
||||
*/
|
||||
export const createPastedContent = (
|
||||
id: string,
|
||||
content: string,
|
||||
startPos: number,
|
||||
): PastedContent => {
|
||||
const lineCount = countLines(content);
|
||||
const placeholder = generatePlaceholder(lineCount);
|
||||
return {
|
||||
id,
|
||||
content,
|
||||
lineCount,
|
||||
placeholder,
|
||||
startPos,
|
||||
endPos: startPos + placeholder.length,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a unique ID for a pasted block
|
||||
*/
|
||||
export const generatePasteId = (counter: number): string => {
|
||||
return `paste-${counter}-${Date.now()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new pasted block to the state
|
||||
*/
|
||||
export const addPastedBlock = (
|
||||
state: PasteState,
|
||||
content: string,
|
||||
startPos: number,
|
||||
): { newState: PasteState; pastedContent: PastedContent } => {
|
||||
const newCounter = state.pasteCounter + 1;
|
||||
const id = generatePasteId(newCounter);
|
||||
const pastedContent = createPastedContent(id, content, startPos);
|
||||
|
||||
const newBlocks = new Map(state.pastedBlocks);
|
||||
newBlocks.set(id, pastedContent);
|
||||
|
||||
return {
|
||||
newState: {
|
||||
pastedBlocks: newBlocks,
|
||||
pasteCounter: newCounter,
|
||||
},
|
||||
pastedContent,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates positions of pasted blocks after text insertion
|
||||
*/
|
||||
export const updatePastedBlockPositions = (
|
||||
blocks: Map<string, PastedContent>,
|
||||
insertPos: number,
|
||||
insertLength: number,
|
||||
): Map<string, PastedContent> => {
|
||||
const updatedBlocks = new Map<string, PastedContent>();
|
||||
|
||||
for (const [id, block] of blocks) {
|
||||
if (block.startPos >= insertPos) {
|
||||
// Block is after insertion point - shift positions
|
||||
updatedBlocks.set(id, {
|
||||
...block,
|
||||
startPos: block.startPos + insertLength,
|
||||
endPos: block.endPos + insertLength,
|
||||
});
|
||||
} else if (block.endPos <= insertPos) {
|
||||
// Block is before insertion point - no change
|
||||
updatedBlocks.set(id, block);
|
||||
} else {
|
||||
// Insertion is within the block - this shouldn't happen with virtual text
|
||||
// but keep the block unchanged as a fallback
|
||||
updatedBlocks.set(id, block);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedBlocks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates positions of pasted blocks after text deletion
|
||||
*/
|
||||
export const updatePastedBlocksAfterDelete = (
|
||||
blocks: Map<string, PastedContent>,
|
||||
deletePos: number,
|
||||
deleteLength: number,
|
||||
): Map<string, PastedContent> => {
|
||||
const updatedBlocks = new Map<string, PastedContent>();
|
||||
|
||||
for (const [id, block] of blocks) {
|
||||
// Check if deletion affects this block
|
||||
const deleteEnd = deletePos + deleteLength;
|
||||
|
||||
if (deleteEnd <= block.startPos) {
|
||||
// Deletion is completely before this block - shift positions back
|
||||
updatedBlocks.set(id, {
|
||||
...block,
|
||||
startPos: block.startPos - deleteLength,
|
||||
endPos: block.endPos - deleteLength,
|
||||
});
|
||||
} else if (deletePos >= block.endPos) {
|
||||
// Deletion is completely after this block - no change
|
||||
updatedBlocks.set(id, block);
|
||||
} else if (deletePos <= block.startPos && deleteEnd >= block.endPos) {
|
||||
// Deletion completely contains this block - remove it
|
||||
// Don't add to updatedBlocks
|
||||
} else {
|
||||
// Partial overlap - this is complex, for now just remove the block
|
||||
// A more sophisticated implementation could adjust boundaries
|
||||
// Don't add to updatedBlocks
|
||||
}
|
||||
}
|
||||
|
||||
return updatedBlocks;
|
||||
};
|
||||
|
||||
/**
|
||||
* Expands all pasted blocks in the input buffer
|
||||
* Used before submitting the message
|
||||
*/
|
||||
export const expandPastedContent = (
|
||||
inputBuffer: string,
|
||||
pastedBlocks: Map<string, PastedContent>,
|
||||
): string => {
|
||||
if (pastedBlocks.size === 0) {
|
||||
return inputBuffer;
|
||||
}
|
||||
|
||||
// Sort blocks by position in reverse order to avoid position shifts
|
||||
const sortedBlocks = Array.from(pastedBlocks.values()).sort(
|
||||
(a, b) => b.startPos - a.startPos,
|
||||
);
|
||||
|
||||
let result = inputBuffer;
|
||||
|
||||
for (const block of sortedBlocks) {
|
||||
const before = result.slice(0, block.startPos);
|
||||
const after = result.slice(block.endPos);
|
||||
result = before + block.content + after;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the display text for the input buffer
|
||||
* This returns the buffer as-is since placeholders are already in the buffer
|
||||
*/
|
||||
export const getDisplayBuffer = (
|
||||
inputBuffer: string,
|
||||
_pastedBlocks: Map<string, PastedContent>,
|
||||
): string => {
|
||||
// The placeholders are already stored in the buffer
|
||||
// This function is here for future enhancements like styling
|
||||
return inputBuffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleans carriage returns and normalizes line endings
|
||||
*/
|
||||
export const normalizeLineEndings = (text: string): string => {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears all pasted blocks
|
||||
*/
|
||||
export const clearPastedBlocks = (): PasteState => ({
|
||||
pastedBlocks: new Map(),
|
||||
pasteCounter: 0,
|
||||
});
|
||||
Reference in New Issue
Block a user