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

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