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:
2026-01-27 23:33:06 -05:00
commit 0062e5d9d9
521 changed files with 66418 additions and 0 deletions

13
src/utils/diff.ts Normal file
View 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
View 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");
};

View 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
View 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
View 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
View 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
View 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;
};

View 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
View 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;
};

View 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";

View 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);

View 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
View 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
View 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;
};

View 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";

View 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 };
};

View 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";
};

View 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,
});