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:
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