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:
9
src/ui/banner.ts
Normal file
9
src/ui/banner.ts
Normal 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
13
src/ui/banner/lines.ts
Normal 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
11
src/ui/banner/logo.ts
Normal 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
39
src/ui/banner/print.ts
Normal 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
35
src/ui/banner/render.ts
Normal 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
24
src/ui/components.ts
Normal 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
150
src/ui/components/box.ts
Normal 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,
|
||||
});
|
||||
44
src/ui/components/header.ts
Normal file
44
src/ui/components/header.ts
Normal 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
50
src/ui/components/list.ts
Normal 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");
|
||||
};
|
||||
64
src/ui/components/message.ts
Normal file
64
src/ui/components/message.ts
Normal 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");
|
||||
};
|
||||
54
src/ui/components/status.ts
Normal file
54
src/ui/components/status.ts
Normal 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
10
src/ui/index.ts
Normal 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
17
src/ui/input-editor.ts
Normal 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";
|
||||
160
src/ui/input-editor/cursor.ts
Normal file
160
src/ui/input-editor/cursor.ts
Normal 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);
|
||||
};
|
||||
121
src/ui/input-editor/display.ts
Normal file
121
src/ui/input-editor/display.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
};
|
||||
230
src/ui/input-editor/editor.ts
Normal file
230
src/ui/input-editor/editor.ts
Normal 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,
|
||||
};
|
||||
249
src/ui/input-editor/keypress.ts
Normal file
249
src/ui/input-editor/keypress.ts
Normal 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]();
|
||||
};
|
||||
252
src/ui/input-editor/paste.ts
Normal file
252
src/ui/input-editor/paste.ts
Normal 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;
|
||||
};
|
||||
57
src/ui/input-editor/state.ts
Normal file
57
src/ui/input-editor/state.ts
Normal 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
22
src/ui/spinner.ts
Normal 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";
|
||||
30
src/ui/spinner/progress.ts
Normal file
30
src/ui/spinner/progress.ts
Normal 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
96
src/ui/spinner/scanner.ts
Normal 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
138
src/ui/spinner/spinner.ts
Normal 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
16
src/ui/styles.ts
Normal 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
19
src/ui/styles/apply.ts
Normal 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
22
src/ui/styles/colors.ts
Normal 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
75
src/ui/styles/text.ts
Normal 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
16
src/ui/tips.ts
Normal 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
45
src/ui/tips/parse.ts
Normal 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
82
src/ui/tips/render.ts
Normal 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");
|
||||
};
|
||||
Reference in New Issue
Block a user