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

9
src/ui/banner.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* ASCII art banner for CodeTyper CLI
*/
export type { BannerStyle } from "@/types/banner";
export { getBannerLines } from "@ui/banner/lines";
export { renderBanner, renderBannerWithSubtitle } from "@ui/banner/render";
export { printBanner, printWelcome } from "@ui/banner/print";
export { getInlineLogo } from "@ui/banner/logo";

13
src/ui/banner/lines.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Banner lines utilities
*/
import { BANNER_STYLE_MAP, BANNER_LINES } from "@constants/banner";
import type { BannerStyle } from "@/types/banner";
/**
* Get the banner lines for a given style
*/
export const getBannerLines = (
style: BannerStyle = "default",
): readonly string[] => BANNER_STYLE_MAP[style] ?? BANNER_LINES;

11
src/ui/banner/logo.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* Banner logo utilities
*/
import { Style } from "@ui/styles";
/**
* Simple logo for inline display
*/
export const getInlineLogo = (): string =>
Style.CYAN + Style.BOLD + "codetyper" + Style.RESET;

39
src/ui/banner/print.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Banner printing utilities
*/
import { Style } from "@ui/styles";
import type { BannerStyle } from "@/types/banner";
import { renderBanner } from "@ui/banner/render";
/**
* Print the banner to console
*/
export const printBanner = (style: BannerStyle = "default"): void => {
console.log("\n" + renderBanner(style));
};
/**
* Print banner with version and info
*/
export const printWelcome = (
version: string,
provider?: string,
model?: string,
): void => {
console.log("\n" + renderBanner("blocks"));
console.log("");
console.log(Style.DIM + " AI Coding Assistant" + Style.RESET);
console.log("");
const info: string[] = [];
if (version) info.push(`v${version}`);
if (provider) info.push(provider);
if (model) info.push(model);
if (info.length > 0) {
console.log(Style.DIM + " " + info.join(" | ") + Style.RESET);
}
console.log("");
};

35
src/ui/banner/render.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Banner rendering utilities
*/
import { GRADIENT_COLORS } from "@constants/banner";
import { Style } from "@ui/styles";
import type { BannerStyle } from "@/types/banner";
import { getBannerLines } from "@ui/banner/lines";
/**
* Render the banner with gradient colors
*/
export const renderBanner = (style: BannerStyle = "default"): string => {
const lines = getBannerLines(style);
return lines
.map((line, index) => {
const colorIndex = Math.min(index, GRADIENT_COLORS.length - 1);
const color = GRADIENT_COLORS[colorIndex];
return color + line + Style.RESET;
})
.join("\n");
};
/**
* Render banner with subtitle
*/
export const renderBannerWithSubtitle = (
subtitle: string,
style: BannerStyle = "default",
): string => {
const banner = renderBanner(style);
const subtitleLine = Style.DIM + " " + subtitle + Style.RESET;
return banner + "\n" + subtitleLine;
};

24
src/ui/components.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Terminal UI components
*/
export type {
BoxOptions,
KeyValueOptions,
ListOptions,
MessageOptions,
} from "@interfaces/BoxOptions";
export type {
BoxStyle,
BoxAlign,
HeaderStyle,
StatusState,
ToolCallState,
MessageRole,
} from "@/types/components";
export { box, panel, errorBox, successBox } from "@ui/components/box";
export { header, divider } from "@ui/components/header";
export { keyValue, list } from "@ui/components/list";
export { status, toolCall } from "@ui/components/status";
export { message, codeBlock } from "@ui/components/message";

150
src/ui/components/box.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* Box component utilities
*/
import { BoxChars, BOX_DEFAULTS } from "@constants/components";
import { Style, Theme } from "@constants/styles";
import { colors } from "@ui/styles/colors";
import { stripAnsi, getTerminalWidth } from "@ui/styles/text";
import type { BoxOptions } from "@interfaces/BoxOptions";
const ALIGN_HANDLERS = {
center: (
line: string,
innerWidth: number,
strippedLength: number,
): string => {
const leftPad = Math.floor((innerWidth - strippedLength) / 2);
const rightPad = innerWidth - strippedLength - leftPad;
return " ".repeat(leftPad) + line + " ".repeat(rightPad);
},
right: (line: string, innerWidth: number, strippedLength: number): string =>
" ".repeat(innerWidth - strippedLength) + line,
left: (line: string, innerWidth: number, strippedLength: number): string =>
line + " ".repeat(innerWidth - strippedLength),
};
/**
* Create a box around content
*/
export const box = (
content: string | string[],
options: BoxOptions = {},
): string => {
const {
title,
style = BOX_DEFAULTS.style,
padding = BOX_DEFAULTS.padding,
color = Theme.textMuted,
width: targetWidth,
align = BOX_DEFAULTS.align,
} = options;
const chars = BoxChars[style];
const lines = Array.isArray(content) ? content : content.split("\n");
// Calculate width
const maxContentWidth = Math.max(...lines.map((l) => stripAnsi(l).length));
const width =
targetWidth ||
Math.min(maxContentWidth + padding * 2 + 2, getTerminalWidth() - 4);
const innerWidth = width - 2;
const output: string[] = [];
// Top border with optional title
let topBorder =
chars.topLeft + chars.horizontal.repeat(innerWidth) + chars.topRight;
if (title) {
const titleText = ` ${title} `;
const titleStart = Math.floor((innerWidth - titleText.length) / 2);
topBorder =
chars.topLeft +
chars.horizontal.repeat(titleStart) +
colors.primary(titleText) +
chars.horizontal.repeat(innerWidth - titleStart - titleText.length) +
chars.topRight;
}
output.push(color + topBorder + Style.RESET);
// Padding top
for (let i = 0; i < padding; i++) {
output.push(
color +
chars.vertical +
" ".repeat(innerWidth) +
chars.vertical +
Style.RESET,
);
}
// Content lines
for (const line of lines) {
const strippedLength = stripAnsi(line).length;
const alignHandler = ALIGN_HANDLERS[align];
const paddedLine = alignHandler(line, innerWidth, strippedLength);
output.push(
color +
chars.vertical +
Style.RESET +
paddedLine +
color +
chars.vertical +
Style.RESET,
);
}
// Padding bottom
for (let i = 0; i < padding; i++) {
output.push(
color +
chars.vertical +
" ".repeat(innerWidth) +
chars.vertical +
Style.RESET,
);
}
// Bottom border
output.push(
color +
chars.bottomLeft +
chars.horizontal.repeat(innerWidth) +
chars.bottomRight +
Style.RESET,
);
return output.join("\n");
};
/**
* Create a panel (simpler than box, just left border)
*/
export const panel = (
content: string | string[],
color = Theme.textMuted,
): string => {
const lines = Array.isArray(content) ? content : content.split("\n");
return lines.map((l) => color + "│ " + Style.RESET + l).join("\n");
};
/**
* Create an error display
*/
export const errorBox = (title: string, message: string): string =>
box([colors.bold(colors.error(title)), "", message], {
style: "rounded",
color: Theme.error,
padding: 1,
});
/**
* Create a success display
*/
export const successBox = (title: string, message: string): string =>
box([colors.bold(colors.success(title)), "", message], {
style: "rounded",
color: Theme.success,
padding: 1,
});

View File

@@ -0,0 +1,44 @@
/**
* Header and divider components
*/
import { Style, Theme } from "@constants/styles";
import { colors } from "@ui/styles/colors";
import { stripAnsi, getTerminalWidth, line } from "@ui/styles/text";
import { box } from "@ui/components/box";
import type { HeaderStyle } from "@/types/components";
const HEADER_STYLE_HANDLERS: Record<HeaderStyle, (text: string) => string> = {
box: (text: string) => box(text, { title: "", align: "center", padding: 0 }),
simple: (text: string) => colors.bold(colors.primary(text)),
line: (text: string) => {
const width = getTerminalWidth();
const textLength = stripAnsi(text).length;
const leftWidth = 2;
const rightWidth = Math.max(0, width - textLength - leftWidth - 4);
return (
Theme.textMuted +
"─".repeat(leftWidth) +
Style.RESET +
" " +
colors.bold(colors.primary(text)) +
" " +
Theme.textMuted +
"─".repeat(rightWidth) +
Style.RESET
);
},
};
/**
* Create a section header
*/
export const header = (text: string, style: HeaderStyle = "line"): string =>
HEADER_STYLE_HANDLERS[style](text);
/**
* Create a divider line
*/
export const divider = (char = "─", color = Theme.textMuted): string =>
color + line(char) + Style.RESET;

50
src/ui/components/list.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* List and key-value display components
*/
import { Style, Theme, Icons } from "@constants/styles";
import type { KeyValueOptions, ListOptions } from "@interfaces/BoxOptions";
/**
* Create a key-value display
*/
export const keyValue = (
items: Record<string, string | number | boolean | undefined>,
options: KeyValueOptions = {},
): string => {
const {
separator = ": ",
labelColor = Theme.textMuted,
valueColor = "",
} = options;
const output: string[] = [];
for (const [key, value] of Object.entries(items)) {
if (value === undefined) continue;
const displayValue =
typeof value === "boolean" ? (value ? "Yes" : "No") : String(value);
output.push(
labelColor +
key +
Style.RESET +
separator +
valueColor +
displayValue +
Style.RESET,
);
}
return output.join("\n");
};
/**
* Create a list
*/
export const list = (items: string[], options: ListOptions = {}): string => {
const { bullet = Icons.bullet, indent = 2, color = Theme.primary } = options;
const padding = " ".repeat(indent);
return items
.map((item) => padding + color + bullet + Style.RESET + " " + item)
.join("\n");
};

View File

@@ -0,0 +1,64 @@
/**
* Message and code block display components
*/
import { Style, Theme } from "@constants/styles";
import { ROLE_CONFIG } from "@constants/components";
import { colors } from "@ui/styles/colors";
import { wrap, getTerminalWidth } from "@ui/styles/text";
import type { MessageRole } from "@/types/components";
import type { MessageOptions } from "@interfaces/BoxOptions";
type ThemeKey = keyof typeof Theme;
/**
* Create a message bubble
*/
export const message = (
role: MessageRole,
content: string,
options: MessageOptions = {},
): string => {
const config = ROLE_CONFIG[role];
const color = Theme[config.colorKey as ThemeKey];
const output: string[] = [];
if (options.showRole !== false) {
output.push(colors.bold(color + config.label + Style.RESET));
}
// Wrap content to terminal width
const wrapped = wrap(content, getTerminalWidth() - 4);
for (const line of wrapped) {
output.push(" " + line);
}
return output.join("\n");
};
/**
* Create a code block
*/
export const codeBlock = (code: string, language?: string): string => {
const lines = code.split("\n");
const output: string[] = [];
// Header
if (language) {
output.push(Theme.textMuted + "```" + language + Style.RESET);
} else {
output.push(Theme.textMuted + "```" + Style.RESET);
}
// Code with line numbers
const maxLineNum = String(lines.length).length;
for (let i = 0; i < lines.length; i++) {
const lineNum = String(i + 1).padStart(maxLineNum, " ");
output.push(Theme.textMuted + lineNum + " │ " + Style.RESET + lines[i]);
}
// Footer
output.push(Theme.textMuted + "```" + Style.RESET);
return output.join("\n");
};

View File

@@ -0,0 +1,54 @@
/**
* Status and tool call display components
*/
import { Style, Theme, Icons } from "@constants/styles";
import { STATUS_INDICATORS, TOOL_CALL_ICONS } from "@constants/components";
import type { StatusState, ToolCallState } from "@/types/components";
type ThemeKey = keyof typeof Theme;
type IconKey = keyof typeof Icons;
/**
* Create a status indicator
*/
export const status = (state: StatusState, text: string): string => {
const config = STATUS_INDICATORS[state];
const icon = Icons[config.iconKey as IconKey];
const color = Theme[config.colorKey as ThemeKey];
return color + icon + Style.RESET + " " + text;
};
/**
* Create a tool call display
*/
export const toolCall = (
tool: string,
description: string,
state: ToolCallState = "pending",
): string => {
const toolLower = tool.toLowerCase() as keyof typeof TOOL_CALL_ICONS;
const iconConfig = TOOL_CALL_ICONS[toolLower] || TOOL_CALL_ICONS.default;
const stateColorMap: Record<ToolCallState, string> = {
pending: Style.DIM,
running: Theme.primary,
success: Theme.success,
error: Theme.error,
};
const icon = Icons[iconConfig.iconKey as IconKey];
const iconColor = Theme[iconConfig.colorKey as ThemeKey];
const stateColor = stateColorMap[state];
return (
stateColor +
iconColor +
icon +
Style.RESET +
stateColor +
" " +
description +
Style.RESET
);
};

10
src/ui/index.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Terminal UI components for CodeTyper CLI
*/
export * from "@ui/banner";
export * from "@ui/styles";
export * from "@ui/spinner";
export * from "@ui/components";
export * from "@ui/tips";
export * from "@ui/input-editor";

17
src/ui/input-editor.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Single-line-by-default input editor with Alt+Enter for newlines
*
* - Enter: Submit the message immediately
* - Alt+Enter (Option+Enter on macOS): Insert newline without sending
* - Paste: Preserves newlines in buffer, does not auto-submit
*/
export type { InputEditorOptions } from "@interfaces/InputEditorOptions";
export type { InputEditorState, InputEditorEvents } from "@/types/input-editor";
export {
createInputEditorInstance,
createInputEditor,
InputEditor,
type InputEditorInstance,
} from "@ui/input-editor/editor";

View File

@@ -0,0 +1,160 @@
/**
* Cursor manipulation utilities
*/
import type { InputEditorState } from "@/types/input-editor";
import {
deletePastedBlockAtCursor,
isInsidePastedBlock,
getPastedBlockAtPosition,
} from "@ui/input-editor/paste";
/**
* Get start of current line
*/
export const getLineStart = (state: InputEditorState): number => {
const beforeCursor = state.buffer.slice(0, state.cursorPos);
const lastNewline = beforeCursor.lastIndexOf("\n");
return lastNewline === -1 ? 0 : lastNewline + 1;
};
/**
* Get end of current line
*/
export const getLineEnd = (state: InputEditorState): number => {
const afterCursor = state.buffer.slice(state.cursorPos);
const nextNewline = afterCursor.indexOf("\n");
return nextNewline === -1
? state.buffer.length
: state.cursorPos + nextNewline;
};
/**
* Insert text at cursor position
*/
export const insertText = (state: InputEditorState, text: string): void => {
const before = state.buffer.slice(0, state.cursorPos);
const after = state.buffer.slice(state.cursorPos);
state.buffer = before + text + after;
state.cursorPos += text.length;
};
/**
* Delete character before cursor (backspace)
* Deletes pasted blocks as a single unit
*/
export const backspace = (state: InputEditorState): boolean => {
if (state.cursorPos > 0) {
// Check if we're at the end of a pasted block - delete whole block
if (deletePastedBlockAtCursor(state)) {
return true;
}
// Check if inside a pasted block placeholder - skip to start
const block = getPastedBlockAtPosition(state, state.cursorPos - 1);
if (block) {
const placeholderStart = state.buffer.indexOf(block.placeholder);
if (placeholderStart !== -1) {
const before = state.buffer.slice(0, placeholderStart);
const after = state.buffer.slice(
placeholderStart + block.placeholder.length,
);
state.buffer = before + after;
state.cursorPos = placeholderStart;
state.pastedBlocks.delete(block.placeholder);
return true;
}
}
const before = state.buffer.slice(0, state.cursorPos - 1);
const after = state.buffer.slice(state.cursorPos);
state.buffer = before + after;
state.cursorPos--;
return true;
}
return false;
};
/**
* Delete character at cursor
* Deletes pasted blocks as a single unit
*/
export const deleteChar = (state: InputEditorState): boolean => {
if (state.cursorPos < state.buffer.length) {
// Check if we're at the start of a pasted block - delete whole block
if (deletePastedBlockAtCursor(state)) {
return true;
}
// Check if cursor is at or inside a pasted block placeholder
const block = getPastedBlockAtPosition(state, state.cursorPos);
if (block) {
const placeholderStart = state.buffer.indexOf(block.placeholder);
if (placeholderStart !== -1) {
const before = state.buffer.slice(0, placeholderStart);
const after = state.buffer.slice(
placeholderStart + block.placeholder.length,
);
state.buffer = before + after;
state.cursorPos = placeholderStart;
state.pastedBlocks.delete(block.placeholder);
return true;
}
}
const before = state.buffer.slice(0, state.cursorPos);
const after = state.buffer.slice(state.cursorPos + 1);
state.buffer = before + after;
return true;
}
return false;
};
/**
* Move cursor by delta
* Skips over pasted block placeholders as a single unit
*/
export const moveCursor = (state: InputEditorState, delta: number): boolean => {
let newPos = state.cursorPos + delta;
if (newPos < 0 || newPos > state.buffer.length) {
return false;
}
// Check if we're entering a pasted block placeholder - skip over it
if (isInsidePastedBlock(state, newPos)) {
const block = getPastedBlockAtPosition(state, newPos);
if (block) {
const placeholderStart = state.buffer.indexOf(block.placeholder);
if (placeholderStart !== -1) {
// If moving right, go to end of placeholder
// If moving left, go to start of placeholder
newPos =
delta > 0
? placeholderStart + block.placeholder.length
: placeholderStart;
}
}
}
if (newPos >= 0 && newPos <= state.buffer.length) {
state.cursorPos = newPos;
return true;
}
return false;
};
/**
* Clear line (Ctrl+U)
*/
export const clearLine = (state: InputEditorState): void => {
state.buffer = "";
state.cursorPos = 0;
};
/**
* Kill to end of line (Ctrl+K)
*/
export const killToEnd = (state: InputEditorState): void => {
state.buffer = state.buffer.slice(0, state.cursorPos);
};

View File

@@ -0,0 +1,121 @@
/**
* Display rendering utilities for input editor
*/
import { ANSI, PASTE_STYLE } from "@constants/input-editor";
import type { InputEditorState } from "@/types/input-editor";
/**
* Style pasted block placeholders in text
*/
const stylePastedBlocks = (text: string, state: InputEditorState): string => {
let result = text;
for (const [placeholder] of state.pastedBlocks) {
result = result.replace(
placeholder,
`${PASTE_STYLE.start}${placeholder}${PASTE_STYLE.end}`,
);
}
return result;
};
/**
* Clear the current display
*/
export const clearDisplay = (state: InputEditorState): void => {
const lines = state.buffer.split("\n");
const lineCount = lines.length;
// Move cursor to start of first line
if (lineCount > 1) {
process.stdout.write(ANSI.moveUp(lineCount - 1));
}
process.stdout.write(ANSI.carriageReturn);
// Clear all lines
for (let i = 0; i < lineCount; i++) {
process.stdout.write(ANSI.clearLine);
if (i < lineCount - 1) {
process.stdout.write(ANSI.moveDown(1));
}
}
// Move back to first line
if (lineCount > 1) {
process.stdout.write(ANSI.moveUp(lineCount - 1));
}
process.stdout.write(ANSI.carriageReturn);
};
/**
* Calculate cursor position in terms of line and column
*/
const calculateCursorPosition = (
state: InputEditorState,
lines: string[],
): { cursorLine: number; cursorCol: number } => {
let charCount = 0;
let cursorLine = 0;
let cursorCol = 0;
for (let i = 0; i < lines.length; i++) {
if (
charCount + lines[i].length >= state.cursorPos ||
i === lines.length - 1
) {
cursorLine = i;
cursorCol = state.cursorPos - charCount;
break;
}
charCount += lines[i].length + 1; // +1 for newline
}
return { cursorLine, cursorCol };
};
/**
* Render the input buffer with visual feedback
*/
export const render = (state: InputEditorState): void => {
clearDisplay(state);
const lines = state.buffer.split("\n");
const { cursorLine, cursorCol } = calculateCursorPosition(state, lines);
// Render each line with styled pasted blocks
for (let i = 0; i < lines.length; i++) {
const prefix = i === 0 ? state.prompt : state.continuationPrompt;
const styledLine = stylePastedBlocks(lines[i], state);
process.stdout.write(prefix + styledLine);
if (i < lines.length - 1) {
process.stdout.write("\n");
}
}
// Position cursor correctly
const linesToMoveUp = lines.length - 1 - cursorLine;
if (linesToMoveUp > 0) {
process.stdout.write(ANSI.moveUp(linesToMoveUp));
}
// Move to start of line and then to cursor column
process.stdout.write(ANSI.carriageReturn);
const prefixLength = 2; // Both prompts are 2 visible chars
process.stdout.write(ANSI.moveRight(prefixLength + cursorCol));
};
/**
* Show submitted input
*/
export const showSubmitted = (state: InputEditorState): void => {
clearDisplay(state);
const lines = state.buffer.split("\n");
for (let i = 0; i < lines.length; i++) {
if (i === 0) {
process.stdout.write(state.prompt + lines[i] + "\n");
} else {
process.stdout.write(state.continuationPrompt + lines[i] + "\n");
}
}
};

View File

@@ -0,0 +1,230 @@
/**
* Input editor factory - functional implementation
*/
import { EventEmitter } from "events";
import readline from "readline";
import type { InputEditorState } from "@/types/input-editor";
import type { InputEditorOptions } from "@interfaces/InputEditorOptions";
import {
createInitialState,
resetBuffer,
setActive,
setLocked,
} from "@ui/input-editor/state";
import { render, showSubmitted, clearDisplay } from "@ui/input-editor/display";
import {
handleKeypress,
processKeypressResult,
handleBracketedPaste,
} from "@ui/input-editor/keypress";
export type InputEditorInstance = EventEmitter & {
start: () => void;
stop: () => void;
lock: () => void;
unlock: () => void;
showPrompt: () => void;
};
/** Bracketed paste mode escape sequences */
const BRACKETED_PASTE_ENABLE = "\x1b[?2004h";
const BRACKETED_PASTE_DISABLE = "\x1b[?2004l";
const PASTE_START = "\x1b[200~";
const PASTE_END = "\x1b[201~";
const enableRawMode = (): void => {
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
// Enable bracketed paste mode
process.stdout.write(BRACKETED_PASTE_ENABLE);
}
process.stdin.resume();
};
const disableRawMode = (): void => {
if (process.stdin.isTTY) {
// Disable bracketed paste mode
process.stdout.write(BRACKETED_PASTE_DISABLE);
process.stdin.setRawMode(false);
}
};
/**
* Create an input editor instance
*/
export const createInputEditorInstance = (
options: InputEditorOptions = {},
): InputEditorInstance => {
const emitter = new EventEmitter() as InputEditorInstance;
const state: InputEditorState = createInitialState(options);
let dataHandler: ((data: Buffer) => void) | null = null;
const processResult = (result: ReturnType<typeof handleKeypress>): void => {
processKeypressResult(state, result, {
onSubmit: (content: string) => {
clearDisplay(state);
showSubmitted(state);
resetBuffer(state);
if (content) {
emitter.emit("submit", content);
} else {
render(state);
}
},
onInterrupt: () => emitter.emit("interrupt"),
onClose: () => emitter.emit("close"),
});
};
const keypressHandler = (
chunk: string | undefined,
key: readline.Key,
): void => {
if (!state.isActive || state.isLocked) return;
// Skip if we're in bracketed paste mode - raw handler takes over
if (state.isBracketedPaste) return;
const result = handleKeypress(state, chunk, key);
processResult(result);
};
/**
* Raw data handler to intercept bracketed paste sequences
* before readline processes them
*/
const rawDataHandler = (data: Buffer): void => {
if (!state.isActive || state.isLocked) return;
const str = data.toString("utf-8");
// Check for paste start
if (str.includes(PASTE_START)) {
state.isBracketedPaste = true;
state.bracketedPasteBuffer = "";
// Extract content after paste start
const startIdx = str.indexOf(PASTE_START);
let content = str.slice(startIdx + PASTE_START.length);
// Check if paste end is in the same chunk
const endIdx = content.indexOf(PASTE_END);
if (endIdx >= 0) {
// Complete paste in one chunk
state.bracketedPasteBuffer = content.slice(0, endIdx);
state.isBracketedPaste = false;
const result = handleBracketedPaste(state);
processResult(result);
} else {
// Paste continues
state.bracketedPasteBuffer = content;
}
return;
}
// Check for paste end
if (state.isBracketedPaste && str.includes(PASTE_END)) {
const endIdx = str.indexOf(PASTE_END);
state.bracketedPasteBuffer += str.slice(0, endIdx);
state.isBracketedPaste = false;
const result = handleBracketedPaste(state);
processResult(result);
return;
}
// If in bracketed paste, accumulate content
if (state.isBracketedPaste) {
state.bracketedPasteBuffer += str;
return;
}
// Not paste-related - let keypress handler deal with it
};
const start = (): void => {
if (state.isActive) return;
setActive(state, true);
resetBuffer(state);
enableRawMode();
// Add raw data handler BEFORE readline to catch paste sequences
dataHandler = rawDataHandler;
process.stdin.on("data", dataHandler);
readline.emitKeypressEvents(process.stdin);
render(state);
state.keypressHandler = keypressHandler;
process.stdin.on("keypress", keypressHandler);
};
const stop = (): void => {
if (!state.isActive) return;
setActive(state, false);
if (state.keypressHandler) {
process.stdin.removeListener("keypress", state.keypressHandler);
state.keypressHandler = null;
}
if (dataHandler) {
process.stdin.removeListener("data", dataHandler);
dataHandler = null;
}
disableRawMode();
};
const lock = (): void => {
setLocked(state, true);
disableRawMode();
if (state.keypressHandler) {
process.stdin.removeListener("keypress", state.keypressHandler);
}
if (dataHandler) {
process.stdin.removeListener("data", dataHandler);
}
};
const unlock = (): void => {
setLocked(state, false);
resetBuffer(state);
enableRawMode();
if (dataHandler) {
process.stdin.on("data", dataHandler);
}
if (state.keypressHandler) {
process.stdin.on("keypress", state.keypressHandler);
}
render(state);
};
const showPrompt = (): void => {
resetBuffer(state);
render(state);
};
emitter.start = start;
emitter.stop = stop;
emitter.lock = lock;
emitter.unlock = unlock;
emitter.showPrompt = showPrompt;
return emitter;
};
/**
* Create and manage input editor instance
*/
export const createInputEditor = (
options?: InputEditorOptions,
): InputEditorInstance => createInputEditorInstance(options);
// Backward compatibility - class-like interface
export const InputEditor = {
create: createInputEditorInstance,
};

View File

@@ -0,0 +1,249 @@
/**
* Keypress handling utilities
*/
import type readline from "readline";
import { ALT_ENTER_SEQUENCES } from "@constants/input-editor";
import type { InputEditorState } from "@/types/input-editor";
import {
insertText,
backspace,
deleteChar,
moveCursor,
clearLine,
killToEnd,
getLineStart,
getLineEnd,
} from "@ui/input-editor/cursor";
import { render } from "@ui/input-editor/display";
import {
handlePasteInput,
expandPastedBlocks,
isPasteInput,
insertPastedContent,
} from "@ui/input-editor/paste";
export type KeypressResult =
| { type: "submit"; content: string }
| { type: "interrupt" }
| { type: "close" }
| { type: "render" }
| { type: "none" };
const isAltEnterSequence = (key: readline.Key): boolean =>
ALT_ENTER_SEQUENCES.includes(
key.sequence as (typeof ALT_ENTER_SEQUENCES)[number],
);
const isRegularCharacter = (key: readline.Key): boolean =>
Boolean(
key.sequence &&
key.sequence.length === 1 &&
key.sequence.charCodeAt(0) >= 32,
);
type KeyHandler = (
state: InputEditorState,
key: readline.Key,
char?: string,
) => KeypressResult;
const KEY_HANDLERS: Record<string, KeyHandler> = {
// Ctrl+C - interrupt
"ctrl+c": () => ({ type: "interrupt" }),
// Ctrl+D - close if buffer empty
"ctrl+d": (state) =>
state.buffer.length === 0 ? { type: "close" } : { type: "none" },
// Enter with meta (Alt+Enter) - insert newline
"meta+return": (state) => {
insertText(state, "\n");
return { type: "render" };
},
"meta+enter": (state) => {
insertText(state, "\n");
return { type: "render" };
},
// Plain Enter - submit (expand pasted blocks)
return: (state) => ({
type: "submit",
content: expandPastedBlocks(state).trim(),
}),
enter: (state) => ({
type: "submit",
content: expandPastedBlocks(state).trim(),
}),
// Backspace
backspace: (state) => {
backspace(state);
return { type: "render" };
},
// Delete
delete: (state) => {
deleteChar(state);
return { type: "render" };
},
// Arrow keys
left: (state) => {
moveCursor(state, -1);
return { type: "render" };
},
right: (state) => {
moveCursor(state, 1);
return { type: "render" };
},
// Home / Ctrl+A
home: (state) => {
state.cursorPos = getLineStart(state);
return { type: "render" };
},
"ctrl+a": (state) => {
state.cursorPos = getLineStart(state);
return { type: "render" };
},
// End / Ctrl+E
end: (state) => {
state.cursorPos = getLineEnd(state);
return { type: "render" };
},
"ctrl+e": (state) => {
state.cursorPos = getLineEnd(state);
return { type: "render" };
},
// Ctrl+U - clear line
"ctrl+u": (state) => {
clearLine(state);
return { type: "render" };
},
// Ctrl+K - kill to end
"ctrl+k": (state) => {
killToEnd(state);
return { type: "render" };
},
};
const getKeyName = (key: readline.Key): string => {
const parts: string[] = [];
if (key.ctrl) parts.push("ctrl");
if (key.meta) parts.push("meta");
if (key.name) parts.push(key.name);
return parts.join("+");
};
/**
* Handle bracketed paste content - called from editor when paste ends
*/
export const handleBracketedPaste = (state: InputEditorState): KeypressResult => {
if (state.bracketedPasteBuffer.length === 0) {
return { type: "none" };
}
const content = state.bracketedPasteBuffer;
state.bracketedPasteBuffer = "";
// Insert the pasted content (will be collapsed if large)
insertPastedContent(state, content);
return { type: "render" };
};
/**
* Handle a keypress event
* Note: Bracketed paste is handled by raw data handler in editor.ts
*/
export const handleKeypress = (
state: InputEditorState,
chunk: string | undefined,
key: readline.Key,
): KeypressResult => {
const char = chunk || key?.sequence;
// If in bracketed paste mode, skip - raw handler is collecting paste
if (state.isBracketedPaste) {
return { type: "none" };
}
// No key info - raw input (paste without brackets)
// OR multi-character input (definitely paste)
if (!key || (char && char.length > 1)) {
if (char) {
// Buffer paste input and flush after timeout
handlePasteInput(state, char);
// Don't render immediately - paste handler will render after flush
return { type: "none" };
}
return { type: "none" };
}
// Check for Alt+Enter escape sequences
if (isAltEnterSequence(key)) {
insertText(state, "\n");
return { type: "render" };
}
// Check handlers
const keyName = getKeyName(key);
const handler = KEY_HANDLERS[keyName];
if (handler) {
return handler(state, key, char);
}
// Skip control sequences and special keys
if (key.ctrl || key.meta) {
return { type: "none" };
}
// Skip special keys that don't produce characters
const skipKeys = ["escape", "tab", "up", "down"];
if (key.name && skipKeys.includes(key.name)) {
return { type: "none" };
}
// Handle regular character input
if (isRegularCharacter(key) && key.sequence) {
// If we're in paste mode (buffering or rapid input), treat as paste
if (isPasteInput(state)) {
handlePasteInput(state, key.sequence);
return { type: "none" };
}
insertText(state, key.sequence);
return { type: "render" };
}
return { type: "none" };
};
/**
* Process keypress result
*/
export const processKeypressResult = (
state: InputEditorState,
result: KeypressResult,
callbacks: {
onSubmit: (content: string) => void;
onInterrupt: () => void;
onClose: () => void;
},
): void => {
const handlers: Record<KeypressResult["type"], () => void> = {
submit: () => {
const content = (result as { type: "submit"; content: string }).content;
callbacks.onSubmit(content);
},
interrupt: callbacks.onInterrupt,
close: callbacks.onClose,
render: () => render(state),
none: () => {},
};
handlers[result.type]();
};

View File

@@ -0,0 +1,252 @@
/**
* Paste handling utilities for input editor
*
* Handles collapsing large pasted content into placeholders
* and expanding them when submitting.
*/
import type { InputEditorState, PastedBlock } from "@/types/input-editor";
import { render } from "@ui/input-editor/display";
/** Minimum lines to trigger paste collapsing */
const MIN_LINES_FOR_COLLAPSE = 2;
/** Minimum characters to trigger paste collapsing */
const MIN_CHARS_FOR_COLLAPSE = 80;
/** Time window to consider input as part of same paste (ms) */
const PASTE_TIMEOUT_MS = 30;
/** Time between chars to consider as rapid input (paste) */
const RAPID_INPUT_THRESHOLD_MS = 15;
/**
* Check if content should be collapsed as a pasted block
*/
export const shouldCollapsePaste = (content: string): boolean => {
const lineCount = content.split("\n").length;
return (
lineCount >= MIN_LINES_FOR_COLLAPSE ||
content.length >= MIN_CHARS_FOR_COLLAPSE
);
};
/**
* Create a placeholder for a pasted block
*/
export const createPastePlaceholder = (
id: number,
lineCount: number,
charCount: number,
): string => {
if (lineCount > 1) {
return `[Pasted text #${id} +${lineCount} lines]`;
}
return `[Pasted text #${id} ${charCount} chars]`;
};
/**
* Create and store a pasted block
*/
export const createPastedBlock = (
state: InputEditorState,
content: string,
): PastedBlock => {
state.pasteCounter++;
const id = state.pasteCounter;
const lineCount = content.split("\n").length;
const placeholder = createPastePlaceholder(id, lineCount, content.length);
const block: PastedBlock = {
id,
content,
lineCount,
placeholder,
};
state.pastedBlocks.set(placeholder, block);
return block;
};
/**
* Insert pasted content - collapses if multiline/large
*/
export const insertPastedContent = (
state: InputEditorState,
content: string,
): void => {
const before = state.buffer.slice(0, state.cursorPos);
const after = state.buffer.slice(state.cursorPos);
if (shouldCollapsePaste(content)) {
const block = createPastedBlock(state, content);
state.buffer = before + block.placeholder + after;
state.cursorPos += block.placeholder.length;
} else {
state.buffer = before + content + after;
state.cursorPos += content.length;
}
};
/**
* Expand all pasted blocks in the buffer
* Returns the full content with all placeholders replaced
* Also flushes any pending paste buffer
*/
export const expandPastedBlocks = (state: InputEditorState): string => {
// First, flush any pending paste buffer synchronously
if (state.pasteBuffer.length > 0) {
if (state.pasteFlushTimer) {
clearTimeout(state.pasteFlushTimer);
state.pasteFlushTimer = null;
}
// Insert paste buffer content directly (already in buffer position)
const content = state.pasteBuffer;
state.pasteBuffer = "";
const before = state.buffer.slice(0, state.cursorPos);
const after = state.buffer.slice(state.cursorPos);
state.buffer = before + content + after;
state.cursorPos += content.length;
}
let expanded = state.buffer;
for (const [placeholder, block] of state.pastedBlocks) {
expanded = expanded.replace(placeholder, block.content);
}
return expanded;
};
/**
* Delete a pasted block if cursor is at it
* Returns true if a block was deleted
*/
export const deletePastedBlockAtCursor = (state: InputEditorState): boolean => {
// Check if cursor is at the end of a pasted block placeholder
for (const [placeholder, _block] of state.pastedBlocks) {
const placeholderStart = state.buffer.indexOf(placeholder);
if (placeholderStart === -1) continue;
const placeholderEnd = placeholderStart + placeholder.length;
// Cursor is right after the placeholder (backspace case)
if (state.cursorPos === placeholderEnd) {
const before = state.buffer.slice(0, placeholderStart);
const after = state.buffer.slice(placeholderEnd);
state.buffer = before + after;
state.cursorPos = placeholderStart;
state.pastedBlocks.delete(placeholder);
return true;
}
// Cursor is right at the start of placeholder (delete case)
if (state.cursorPos === placeholderStart) {
const before = state.buffer.slice(0, placeholderStart);
const after = state.buffer.slice(placeholderEnd);
state.buffer = before + after;
state.pastedBlocks.delete(placeholder);
return true;
}
}
return false;
};
/**
* Check if position is inside a pasted block placeholder
*/
export const isInsidePastedBlock = (
state: InputEditorState,
position: number,
): boolean => {
for (const [placeholder, _block] of state.pastedBlocks) {
const start = state.buffer.indexOf(placeholder);
if (start === -1) continue;
const end = start + placeholder.length;
if (position > start && position < end) {
return true;
}
}
return false;
};
/**
* Get pasted block at position if cursor is at its boundary
*/
export const getPastedBlockAtPosition = (
state: InputEditorState,
position: number,
): PastedBlock | null => {
for (const [placeholder, block] of state.pastedBlocks) {
const start = state.buffer.indexOf(placeholder);
if (start === -1) continue;
const end = start + placeholder.length;
if (position >= start && position <= end) {
return block;
}
}
return null;
};
/**
* Flush the paste buffer - called after paste timeout
*/
const flushPasteBuffer = (state: InputEditorState): void => {
if (state.pasteBuffer.length === 0) return;
const content = state.pasteBuffer;
state.pasteBuffer = "";
state.pasteFlushTimer = null;
const before = state.buffer.slice(0, state.cursorPos);
const after = state.buffer.slice(state.cursorPos);
if (shouldCollapsePaste(content)) {
const block = createPastedBlock(state, content);
state.buffer = before + block.placeholder + after;
state.cursorPos += block.placeholder.length;
} else {
state.buffer = before + content + after;
state.cursorPos += content.length;
}
render(state);
};
/**
* Check if input timing suggests this is part of a paste
*/
export const isPasteInput = (state: InputEditorState): boolean => {
if (state.pasteBuffer.length > 0) return true;
const timeSinceLastPaste = Date.now() - state.lastPasteTime;
return timeSinceLastPaste < RAPID_INPUT_THRESHOLD_MS;
};
/**
* Handle raw paste input - buffers characters and flushes after timeout
* Returns true if the input was handled as paste
*/
export const handlePasteInput = (
state: InputEditorState,
char: string,
): boolean => {
const now = Date.now();
// Clear existing timer
if (state.pasteFlushTimer) {
clearTimeout(state.pasteFlushTimer);
state.pasteFlushTimer = null;
}
// Add to paste buffer
state.pasteBuffer += char;
state.lastPasteTime = now;
// Set timer to flush paste buffer
state.pasteFlushTimer = setTimeout(() => {
flushPasteBuffer(state);
}, PASTE_TIMEOUT_MS);
return true;
};

View File

@@ -0,0 +1,57 @@
/**
* Input editor state management
*/
import { INPUT_EDITOR_DEFAULTS } from "@constants/input-editor";
import type { InputEditorState, KeypressHandler } from "@/types/input-editor";
import type { InputEditorOptions } from "@interfaces/InputEditorOptions";
export const createInitialState = (
options: InputEditorOptions = {},
): InputEditorState => ({
buffer: "",
cursorPos: 0,
isActive: false,
isLocked: false,
prompt: options.prompt || INPUT_EDITOR_DEFAULTS.prompt,
continuationPrompt:
options.continuationPrompt || INPUT_EDITOR_DEFAULTS.continuationPrompt,
keypressHandler: null,
pastedBlocks: new Map(),
pasteCounter: 0,
pasteBuffer: "",
lastPasteTime: 0,
pasteFlushTimer: null,
isBracketedPaste: false,
bracketedPasteBuffer: "",
});
export const resetBuffer = (state: InputEditorState): void => {
state.buffer = "";
state.cursorPos = 0;
state.pastedBlocks.clear();
state.pasteCounter = 0;
state.pasteBuffer = "";
state.lastPasteTime = 0;
if (state.pasteFlushTimer) {
clearTimeout(state.pasteFlushTimer);
state.pasteFlushTimer = null;
}
state.isBracketedPaste = false;
state.bracketedPasteBuffer = "";
};
export const setKeypressHandler = (
state: InputEditorState,
handler: KeypressHandler | null,
): void => {
state.keypressHandler = handler;
};
export const setActive = (state: InputEditorState, active: boolean): void => {
state.isActive = active;
};
export const setLocked = (state: InputEditorState, locked: boolean): void => {
state.isLocked = locked;
};

22
src/ui/spinner.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Terminal spinner animations
*/
export { Spinners } from "@constants/spinner";
export type { SpinnerType } from "@/types/spinner";
export type { SpinnerOptions } from "@interfaces/SpinnerOptions";
export {
createSpinnerInstance,
createSpinner,
Spinner,
type SpinnerInstance,
} from "@ui/spinner/spinner";
export {
createScannerInstance,
ScannerSpinner,
type ScannerInstance,
} from "@ui/spinner/scanner";
export { progressBar } from "@ui/spinner/progress";

View File

@@ -0,0 +1,30 @@
/**
* Progress bar utilities
*/
import { PROGRESS_BAR_DEFAULTS } from "@constants/spinner";
import { Style, Theme } from "@constants/styles";
import type { ProgressBarOptions } from "@interfaces/SpinnerOptions";
/**
* Progress bar
*/
export const progressBar = (
current: number,
total: number,
options: ProgressBarOptions = {},
): string => {
const width = options.width || PROGRESS_BAR_DEFAULTS.width;
const filledChar = options.chars?.filled || PROGRESS_BAR_DEFAULTS.filledChar;
const emptyChar = options.chars?.empty || PROGRESS_BAR_DEFAULTS.emptyChar;
const progress = Math.min(1, Math.max(0, current / total));
const filledWidth = Math.round(progress * width);
const emptyWidth = width - filledWidth;
const filled = filledChar.repeat(filledWidth);
const empty = emptyChar.repeat(emptyWidth);
const percent = Math.round(progress * 100);
return `${Theme.primary}${filled}${Style.RESET}${Style.DIM}${empty}${Style.RESET} ${percent}%`;
};

96
src/ui/spinner/scanner.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Scanner spinner - Knight Rider style animation
*/
import { SCANNER_DEFAULTS } from "@constants/spinner";
import { Style, Theme } from "@constants/styles";
import type { ScannerState } from "@/types/spinner";
import type { ScannerOptions } from "@interfaces/SpinnerOptions";
export type ScannerInstance = {
start: (text?: string) => void;
stop: () => void;
};
const createState = (options: ScannerOptions = {}): ScannerState => ({
width: options.width || SCANNER_DEFAULTS.width,
position: 0,
direction: 1,
interval: null,
text: options.text || "",
char: options.char || SCANNER_DEFAULTS.char,
});
const render = (state: ScannerState): void => {
const bar = Array(state.width).fill("░");
// Create trail effect
for (let i = -2; i <= 2; i++) {
const pos = state.position + i;
if (pos >= 0 && pos < state.width) {
const charMap: Record<number, string> = {
0: state.char,
1: "▓",
"-1": "▓",
2: "▒",
"-2": "▒",
};
bar[pos] = charMap[i] || bar[pos];
}
}
const output = `\r${Theme.primary}[${bar.join("")}]${Style.RESET} ${state.text}`;
process.stdout.write(output);
};
const hideCursor = (): void => {
process.stdout.write("\x1b[?25l");
};
const showCursor = (): void => {
process.stdout.write("\x1b[?25h");
};
const clearLine = (): void => {
process.stdout.write("\r\x1b[K");
};
/**
* Create a scanner spinner instance
*/
export const createScannerInstance = (
options: ScannerOptions = {},
): ScannerInstance => {
const state = createState(options);
const start = (text?: string): void => {
if (text) state.text = text;
hideCursor();
state.interval = setInterval(() => {
render(state);
state.position += state.direction;
if (state.position >= state.width - 1 || state.position <= 0) {
state.direction *= -1;
}
}, SCANNER_DEFAULTS.interval);
render(state);
};
const stop = (): void => {
if (state.interval) {
clearInterval(state.interval);
state.interval = null;
}
clearLine();
showCursor();
};
return { start, stop };
};
// Backward compatibility
export const ScannerSpinner = {
create: createScannerInstance,
};

138
src/ui/spinner/spinner.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Spinner factory - functional implementation
*/
import { Spinners, SPINNER_DEFAULTS } from "@constants/spinner";
import { Style, Theme } from "@constants/styles";
import type { SpinnerState } from "@/types/spinner";
import type { SpinnerOptions } from "@interfaces/SpinnerOptions";
export type SpinnerInstance = {
start: (text?: string) => void;
stop: () => void;
update: (text: string) => void;
succeed: (text?: string) => void;
fail: (text?: string) => void;
warn: (text?: string) => void;
info: (text?: string) => void;
isSpinning: () => boolean;
};
const createState = (options: SpinnerOptions): SpinnerState => ({
frames: Spinners[options.type || SPINNER_DEFAULTS.type],
frameIndex: 0,
interval: null,
text: options.text || SPINNER_DEFAULTS.text,
color: options.color || Theme.primary,
intervalMs: options.interval || SPINNER_DEFAULTS.interval,
});
const render = (state: SpinnerState): void => {
const frame = state.frames[state.frameIndex];
const output = `\r${state.color}${frame}${Style.RESET} ${state.text}`;
process.stdout.write(output);
};
const hideCursor = (): void => {
process.stdout.write("\x1b[?25l");
};
const showCursor = (): void => {
process.stdout.write("\x1b[?25h");
};
const clearLine = (): void => {
process.stdout.write("\r\x1b[K");
};
const stopInterval = (state: SpinnerState): void => {
if (state.interval) {
clearInterval(state.interval);
state.interval = null;
}
clearLine();
showCursor();
};
const printResult = (icon: string, color: string, text: string): void => {
console.log(`\r${color}${icon}${Style.RESET} ${text}`);
};
/**
* Create a spinner instance
*/
export const createSpinnerInstance = (
options: SpinnerOptions = {},
): SpinnerInstance => {
const state = createState(options);
const start = (text?: string): void => {
if (text) state.text = text;
hideCursor();
state.interval = setInterval(() => {
render(state);
state.frameIndex = (state.frameIndex + 1) % state.frames.length;
}, state.intervalMs);
render(state);
};
const stop = (): void => {
stopInterval(state);
};
const update = (text: string): void => {
state.text = text;
};
const succeed = (text?: string): void => {
stop();
printResult("✓", Theme.success, text || state.text);
};
const fail = (text?: string): void => {
stop();
printResult("✗", Theme.error, text || state.text);
};
const warn = (text?: string): void => {
stop();
printResult("!", Theme.warning, text || state.text);
};
const info = (text?: string): void => {
stop();
printResult("", Theme.info, text || state.text);
};
const isSpinning = (): boolean => state.interval !== null;
return {
start,
stop,
update,
succeed,
fail,
warn,
info,
isSpinning,
};
};
/**
* Create and start a spinner
*/
export const createSpinner = (
text: string,
options?: Omit<SpinnerOptions, "text">,
): SpinnerInstance => {
const spinner = createSpinnerInstance({ ...options, text });
spinner.start();
return spinner;
};
// Backward compatibility - class-like object
export const Spinner = {
create: createSpinnerInstance,
};

16
src/ui/styles.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* ANSI color styles for terminal output
*/
export { Style, Theme, Icons } from "@constants/styles";
export { style, colored } from "@ui/styles/apply";
export { colors } from "@ui/styles/colors";
export {
getTerminalWidth,
repeat,
line,
stripAnsi,
center,
truncate,
wrap,
} from "@ui/styles/text";

19
src/ui/styles/apply.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Style application utilities
*/
import { Style } from "@constants/styles";
/**
* Apply style to text
*/
export const style = (text: string, ...styles: string[]): string =>
styles.join("") + text + Style.RESET;
/**
* Create a colored text helper
*/
export const colored =
(color: string): ((text: string) => string) =>
(text: string) =>
color + text + Style.RESET;

22
src/ui/styles/colors.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Pre-built color functions
*/
import { Style, Theme } from "@constants/styles";
import { colored } from "@ui/styles/apply";
// Pre-built color functions
export const colors = {
primary: colored(Theme.primary),
secondary: colored(Theme.secondary),
accent: colored(Theme.accent),
success: colored(Theme.success),
warning: colored(Theme.warning),
error: colored(Theme.error),
info: colored(Theme.info),
muted: colored(Theme.textMuted),
dim: colored(Style.DIM),
bold: (text: string) => Style.BOLD + text + Style.RESET,
italic: (text: string) => Style.ITALIC + text + Style.RESET,
underline: (text: string) => Style.UNDERLINE + text + Style.RESET,
} as const;

75
src/ui/styles/text.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* Text manipulation utilities
*/
/**
* Get terminal width
*/
export const getTerminalWidth = (): number => process.stdout.columns || 80;
/**
* Repeat a character to fill width
*/
export const repeat = (char: string, count: number): string =>
char.repeat(Math.max(0, count));
/**
* Create a horizontal line
*/
export const line = (char = "─", width?: number): string =>
repeat(char, width || getTerminalWidth());
/**
* Strip ANSI codes from string (for measuring length)
*/
export const stripAnsi = (text: string): string =>
// eslint-disable-next-line no-control-regex
text.replace(/\x1b\[[0-9;]*m/g, "");
/**
* Center text in terminal
*/
export const center = (text: string, width?: number): string => {
const w = width || getTerminalWidth();
const textLength = stripAnsi(text).length;
const padding = Math.max(0, Math.floor((w - textLength) / 2));
return repeat(" ", padding) + text;
};
/**
* Truncate text to max length with ellipsis
*/
export const truncate = (
text: string,
maxLength: number,
suffix = "...",
): string => {
const stripped = stripAnsi(text);
if (stripped.length <= maxLength) return text;
return text.slice(0, maxLength - suffix.length) + suffix;
};
/**
* Wrap text to specified width
*/
export const wrap = (
text: string,
width: number = getTerminalWidth(),
): string[] => {
const words = text.split(" ");
const lines: string[] = [];
let currentLine = "";
for (const word of words) {
const testLine = currentLine ? currentLine + " " + word : word;
if (stripAnsi(testLine).length > width) {
if (currentLine) lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) lines.push(currentLine);
return lines;
};

16
src/ui/tips.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Tips and hints for CodeTyper CLI
*/
export { SHORTCUTS } from "@constants/tips";
export { parseTip } from "@ui/tips/parse";
export {
renderTip,
getRandomTip,
getRandomTipRendered,
formatTipLine,
getAllTips,
getAllTipsRendered,
searchTips,
getShortcutsFormatted,
} from "@ui/tips/render";

45
src/ui/tips/parse.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Tip parsing utilities
*/
import { TIP_HIGHLIGHT_REGEX } from "@constants/tips";
import type { TipPart } from "@/types/components";
/**
* Parse tip with highlights
*/
export const parseTip = (tip: string): TipPart[] => {
const parts: TipPart[] = [];
const regex = new RegExp(TIP_HIGHLIGHT_REGEX.source, "g");
let lastIndex = 0;
let match;
while ((match = regex.exec(tip)) !== null) {
// Add text before the highlight
if (match.index > lastIndex) {
parts.push({
text: tip.slice(lastIndex, match.index),
highlight: false,
});
}
// Add highlighted text
parts.push({
text: match[1],
highlight: true,
});
lastIndex = regex.lastIndex;
}
// Add remaining text
if (lastIndex < tip.length) {
parts.push({
text: tip.slice(lastIndex),
highlight: false,
});
}
return parts;
};

82
src/ui/tips/render.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* Tip rendering utilities
*/
import { TIPS, SHORTCUTS } from "@constants/tips";
import { Style, Theme, Icons } from "@constants/styles";
import { parseTip } from "@ui/tips/parse";
/**
* Render a tip with colored highlights
*/
export const renderTip = (tip: string): string => {
const parts = parseTip(tip);
let output = "";
for (const part of parts) {
if (part.highlight) {
output += Theme.primary + part.text + Style.RESET;
} else {
output += Theme.textMuted + part.text + Style.RESET;
}
}
return output;
};
/**
* Get a random tip
*/
export const getRandomTip = (): string => {
const index = Math.floor(Math.random() * TIPS.length);
return TIPS[index];
};
/**
* Get a random tip, rendered with colors
*/
export const getRandomTipRendered = (): string => renderTip(getRandomTip());
/**
* Format a tip line with bullet
*/
export const formatTipLine = (): string =>
Theme.warning +
Icons.bullet +
" Tip: " +
Style.RESET +
getRandomTipRendered();
/**
* Get all tips
*/
export const getAllTips = (): string[] => [...TIPS];
/**
* Get all tips rendered
*/
export const getAllTipsRendered = (): string[] => TIPS.map(renderTip);
/**
* Get tips matching a keyword
*/
export const searchTips = (keyword: string): string[] => {
const lower = keyword.toLowerCase();
return TIPS.filter((tip) => tip.toLowerCase().includes(lower));
};
/**
* Get shortcuts formatted
*/
export const getShortcutsFormatted = (): string => {
const maxKeyLen = Math.max(...SHORTCUTS.map((s) => s.key.length));
return SHORTCUTS.map(
(s) =>
Theme.primary +
s.key.padEnd(maxKeyLen + 2) +
Style.RESET +
Theme.textMuted +
s.description +
Style.RESET,
).join("\n");
};