mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat(ipuaro): add multiline input and syntax highlighting
- Multiline input support with Shift+Enter for new lines - Auto-height adjustment and line navigation - Syntax highlighting in DiffView for added lines - Language detection from file extensions - Config options for multiline and syntaxHighlight
This commit is contained in:
@@ -5,6 +5,132 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.21.4] - 2025-12-02 - Syntax Highlighting in DiffView
|
||||
|
||||
### Added
|
||||
|
||||
- **Syntax Highlighter Utility (0.21.4)**
|
||||
- New syntax-highlighter utility in `src/tui/utils/syntax-highlighter.ts`
|
||||
- Simple regex-based syntax highlighting for terminal UI
|
||||
- Language detection from file extension: `ts`, `tsx`, `js`, `jsx`, `json`, `yaml`, `yml`
|
||||
- Token types: keywords, strings, comments, numbers, operators, whitespace
|
||||
- Color mapping: keywords (magenta), strings (green), comments (gray), numbers (cyan), operators (yellow)
|
||||
- Support for single-line comments (`//`), multi-line comments (`/* */`)
|
||||
- String literals: double quotes, single quotes, template literals
|
||||
- Keywords: TypeScript/JavaScript keywords (const, let, function, async, etc.)
|
||||
- Exports: `detectLanguage()`, `highlightLine()`, `Language` type, `HighlightedToken` interface
|
||||
|
||||
- **EditConfigSchema Enhancement**
|
||||
- Added `syntaxHighlight` option to EditConfigSchema (default: `true`)
|
||||
- Enables/disables syntax highlighting in diff views globally
|
||||
|
||||
### Changed
|
||||
|
||||
- **DiffView Component Enhanced**
|
||||
- Added `language?: Language` prop for explicit language override
|
||||
- Added `syntaxHighlight?: boolean` prop (default: `false`)
|
||||
- Automatic language detection from `filePath` using `detectLanguage()`
|
||||
- Highlights only added lines (`type === "add"`) when syntax highlighting enabled
|
||||
- Renders tokens with individual colors when highlighting is active
|
||||
- Falls back to plain colored text when highlighting is disabled
|
||||
|
||||
- **ConfirmDialog Component**
|
||||
- Added `syntaxHighlight?: boolean` prop (default: `false`)
|
||||
- Passes `syntaxHighlight` to DiffView component
|
||||
- Enables syntax highlighting in confirmation dialogs when configured
|
||||
|
||||
- **App Component**
|
||||
- Added `syntaxHighlight?: boolean` prop to ExtendedAppProps (default: `true`)
|
||||
- Passes `syntaxHighlight` to ConfirmDialog
|
||||
- Integrates with global configuration for syntax highlighting
|
||||
|
||||
- **DiffLine Subcomponent**
|
||||
- Enhanced to support syntax highlighting mode
|
||||
- Conditional rendering: highlighted tokens vs plain colored text
|
||||
- Token-based rendering when syntax highlighting is active
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Total tests: 1525 passed (was 1501, +24 new tests)
|
||||
- New test file: `syntax-highlighter.test.ts` with 24 tests
|
||||
- Language detection (9 tests)
|
||||
- Token highlighting for keywords, strings, comments, numbers, operators (15 tests)
|
||||
- Coverage: 97.63% lines, 91.25% branches, 98.97% functions, 97.63% statements
|
||||
- 0 ESLint errors, 0 warnings
|
||||
- Build successful with no TypeScript errors
|
||||
- Regex-based approach using `RegExp#exec()` for performance
|
||||
- No external dependencies added (native JavaScript)
|
||||
|
||||
### Notes
|
||||
|
||||
This release completes the v0.21.0 TUI Enhancements milestone. All items for v0.21.0 are now complete:
|
||||
- ✅ 0.21.1 - useAutocomplete Hook
|
||||
- ✅ 0.21.2 - Edit Mode in ConfirmDialog
|
||||
- ✅ 0.21.3 - Multiline Input support
|
||||
- ✅ 0.21.4 - Syntax Highlighting in DiffView
|
||||
|
||||
---
|
||||
|
||||
## [0.21.3] - 2025-12-02 - Multiline Input Support
|
||||
|
||||
### Added
|
||||
|
||||
- **InputConfigSchema (0.21.3)**
|
||||
- New configuration schema for input settings
|
||||
- `multiline` option: boolean | "auto" (default: false)
|
||||
- Supports three modes: `false` (disabled), `true` (always on), `"auto"` (activates when multiple lines present)
|
||||
- Added `InputConfig` type export
|
||||
|
||||
- **Multiline Input Component (0.21.3)**
|
||||
- Multiline text input support in Input component
|
||||
- Shift+Enter: add new line in multiline mode
|
||||
- Enter: submit all lines (in multiline mode) or submit text (in single-line mode)
|
||||
- Auto-height adjustment: dynamically shows all input lines
|
||||
- Line-by-line editing with visual indicator (">") for current line
|
||||
- Arrow key navigation (↑/↓) between lines in multiline mode
|
||||
- Instructions displayed: "Shift+Enter: new line | Enter: submit"
|
||||
- Seamless switch between single-line and multiline modes based on configuration
|
||||
|
||||
### Changed
|
||||
|
||||
- **Input Component Enhanced**
|
||||
- Added `multiline?: boolean | "auto"` prop
|
||||
- State management for multiple lines (`lines`, `currentLineIndex`)
|
||||
- Conditional rendering: single-line TextInput vs multiline Box with multiple lines
|
||||
- Arrow key handlers now support both history navigation (single-line) and line navigation (multiline)
|
||||
- Submit handler resets lines state in addition to value
|
||||
- Line change handlers: `handleLineChange`, `handleAddLine`, `handleMultilineSubmit`
|
||||
|
||||
- **App Component**
|
||||
- Added `multiline?: boolean | "auto"` prop to ExtendedAppProps
|
||||
- Passes multiline config to Input component
|
||||
- Default value: false (single-line mode)
|
||||
|
||||
- **Config Schema**
|
||||
- Added `input` section to ConfigSchema
|
||||
- InputConfigSchema included in full configuration
|
||||
- Config type updated to include InputConfig
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Total tests: 1501 passed (was 1484, +17 new tests)
|
||||
- New test suite: "multiline support" with 21 tests
|
||||
- InputProps with multiline options
|
||||
- Multiline activation logic (true, false, "auto")
|
||||
- Line management (update, add, join)
|
||||
- Line navigation (up/down with boundaries)
|
||||
- Multiline submit (trim, empty check, reset)
|
||||
- Coverage: 97.67% lines, 91.37% branches, 98.97% functions, 97.67% statements
|
||||
- 0 ESLint errors, 0 warnings
|
||||
- Build successful with no type errors
|
||||
|
||||
### Notes
|
||||
|
||||
This release completes the third item of the v0.21.0 TUI Enhancements milestone. Remaining item for v0.21.0:
|
||||
- 0.21.4 - Syntax Highlighting in DiffView
|
||||
|
||||
---
|
||||
|
||||
## [0.21.1] - 2025-12-01 - TUI Enhancements (Part 2)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -76,6 +76,14 @@ export const UndoConfigSchema = z.object({
|
||||
*/
|
||||
export const EditConfigSchema = z.object({
|
||||
autoApply: z.boolean().default(false),
|
||||
syntaxHighlight: z.boolean().default(true),
|
||||
})
|
||||
|
||||
/**
|
||||
* Input configuration schema.
|
||||
*/
|
||||
export const InputConfigSchema = z.object({
|
||||
multiline: z.union([z.boolean(), z.literal("auto")]).default(false),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -88,6 +96,7 @@ export const ConfigSchema = z.object({
|
||||
watchdog: WatchdogConfigSchema.default({}),
|
||||
undo: UndoConfigSchema.default({}),
|
||||
edit: EditConfigSchema.default({}),
|
||||
input: InputConfigSchema.default({}),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -100,6 +109,7 @@ export type ProjectConfig = z.infer<typeof ProjectConfigSchema>
|
||||
export type WatchdogConfig = z.infer<typeof WatchdogConfigSchema>
|
||||
export type UndoConfig = z.infer<typeof UndoConfigSchema>
|
||||
export type EditConfig = z.infer<typeof EditConfigSchema>
|
||||
export type InputConfig = z.infer<typeof InputConfigSchema>
|
||||
|
||||
/**
|
||||
* Default configuration.
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface AppDependencies {
|
||||
export interface ExtendedAppProps extends AppProps {
|
||||
deps: AppDependencies
|
||||
onExit?: () => void
|
||||
multiline?: boolean | "auto"
|
||||
syntaxHighlight?: boolean
|
||||
}
|
||||
|
||||
function LoadingScreen(): React.JSX.Element {
|
||||
@@ -65,6 +67,8 @@ export function App({
|
||||
autoApply: initialAutoApply = false,
|
||||
deps,
|
||||
onExit,
|
||||
multiline = false,
|
||||
syntaxHighlight = true,
|
||||
}: ExtendedAppProps): React.JSX.Element {
|
||||
const { exit } = useApp()
|
||||
|
||||
@@ -253,6 +257,7 @@ export function App({
|
||||
}
|
||||
onSelect={handleConfirmSelect}
|
||||
editableContent={pendingConfirmation.diff?.newLines}
|
||||
syntaxHighlight={syntaxHighlight}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
@@ -263,6 +268,7 @@ export function App({
|
||||
storage={deps.storage}
|
||||
projectRoot={projectPath}
|
||||
autocompleteEnabled={true}
|
||||
multiline={multiline}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ConfirmDialogProps {
|
||||
diff?: DiffViewProps
|
||||
onSelect: (choice: ConfirmChoice, editedContent?: string[]) => void
|
||||
editableContent?: string[]
|
||||
syntaxHighlight?: boolean
|
||||
}
|
||||
|
||||
type DialogMode = "confirm" | "edit"
|
||||
@@ -42,6 +43,7 @@ export function ConfirmDialog({
|
||||
diff,
|
||||
onSelect,
|
||||
editableContent,
|
||||
syntaxHighlight = false,
|
||||
}: ConfirmDialogProps): React.JSX.Element {
|
||||
const [mode, setMode] = useState<DialogMode>("confirm")
|
||||
const [selected, setSelected] = useState<ConfirmChoice | null>(null)
|
||||
@@ -113,7 +115,7 @@ export function ConfirmDialog({
|
||||
|
||||
{diff && (
|
||||
<Box marginBottom={1}>
|
||||
<DiffView {...diff} />
|
||||
<DiffView {...diff} syntaxHighlight={syntaxHighlight} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
|
||||
import { Box, Text } from "ink"
|
||||
import type React from "react"
|
||||
import { detectLanguage, highlightLine, type Language } from "../utils/syntax-highlighter.js"
|
||||
|
||||
export interface DiffViewProps {
|
||||
filePath: string
|
||||
oldLines: string[]
|
||||
newLines: string[]
|
||||
startLine: number
|
||||
language?: Language
|
||||
syntaxHighlight?: boolean
|
||||
}
|
||||
|
||||
interface DiffLine {
|
||||
@@ -97,20 +100,37 @@ function formatLineNumber(num: number | undefined, width: number): string {
|
||||
function DiffLine({
|
||||
line,
|
||||
lineNumberWidth,
|
||||
language,
|
||||
syntaxHighlight,
|
||||
}: {
|
||||
line: DiffLine
|
||||
lineNumberWidth: number
|
||||
language?: Language
|
||||
syntaxHighlight?: boolean
|
||||
}): React.JSX.Element {
|
||||
const prefix = getLinePrefix(line)
|
||||
const color = getLineColor(line)
|
||||
const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth)
|
||||
|
||||
const shouldHighlight = syntaxHighlight && language && line.type === "add"
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color="gray">{lineNum} </Text>
|
||||
<Text color={color}>
|
||||
{prefix} {line.content}
|
||||
</Text>
|
||||
{shouldHighlight ? (
|
||||
<Box>
|
||||
<Text color={color}>{prefix} </Text>
|
||||
{highlightLine(line.content, language).map((token, idx) => (
|
||||
<Text key={idx} color={token.color}>
|
||||
{token.text}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Text color={color}>
|
||||
{prefix} {line.content}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -166,6 +186,8 @@ export function DiffView({
|
||||
oldLines,
|
||||
newLines,
|
||||
startLine,
|
||||
language,
|
||||
syntaxHighlight = false,
|
||||
}: DiffViewProps): React.JSX.Element {
|
||||
const diffLines = computeDiff(oldLines, newLines, startLine)
|
||||
const endLine = startLine + newLines.length - 1
|
||||
@@ -174,6 +196,8 @@ export function DiffView({
|
||||
const additions = diffLines.filter((l) => l.type === "add").length
|
||||
const deletions = diffLines.filter((l) => l.type === "remove").length
|
||||
|
||||
const detectedLanguage = language ?? detectLanguage(filePath)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<DiffHeader filePath={filePath} startLine={startLine} endLine={endLine} />
|
||||
@@ -183,6 +207,8 @@ export function DiffView({
|
||||
key={`${line.type}-${String(index)}`}
|
||||
line={line}
|
||||
lineNumberWidth={lineNumberWidth}
|
||||
language={detectedLanguage}
|
||||
syntaxHighlight={syntaxHighlight}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface InputProps {
|
||||
storage?: IStorage
|
||||
projectRoot?: string
|
||||
autocompleteEnabled?: boolean
|
||||
multiline?: boolean | "auto"
|
||||
}
|
||||
|
||||
export function Input({
|
||||
@@ -27,10 +28,15 @@ export function Input({
|
||||
storage,
|
||||
projectRoot = "",
|
||||
autocompleteEnabled = true,
|
||||
multiline = false,
|
||||
}: InputProps): React.JSX.Element {
|
||||
const [value, setValue] = useState("")
|
||||
const [historyIndex, setHistoryIndex] = useState(-1)
|
||||
const [savedInput, setSavedInput] = useState("")
|
||||
const [lines, setLines] = useState<string[]>([""])
|
||||
const [currentLineIndex, setCurrentLineIndex] = useState(0)
|
||||
|
||||
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1)
|
||||
|
||||
/*
|
||||
* Initialize autocomplete hook if storage is provided
|
||||
@@ -62,6 +68,8 @@ export function Input({
|
||||
}
|
||||
onSubmit(text)
|
||||
setValue("")
|
||||
setLines([""])
|
||||
setCurrentLineIndex(0)
|
||||
setHistoryIndex(-1)
|
||||
setSavedInput("")
|
||||
autocomplete.reset()
|
||||
@@ -69,6 +77,31 @@ export function Input({
|
||||
[disabled, onSubmit, autocomplete],
|
||||
)
|
||||
|
||||
const handleLineChange = useCallback(
|
||||
(newValue: string) => {
|
||||
const newLines = [...lines]
|
||||
newLines[currentLineIndex] = newValue
|
||||
setLines(newLines)
|
||||
setValue(newLines.join("\n"))
|
||||
},
|
||||
[lines, currentLineIndex],
|
||||
)
|
||||
|
||||
const handleAddLine = useCallback(() => {
|
||||
const newLines = [...lines]
|
||||
newLines.splice(currentLineIndex + 1, 0, "")
|
||||
setLines(newLines)
|
||||
setCurrentLineIndex(currentLineIndex + 1)
|
||||
setValue(newLines.join("\n"))
|
||||
}, [lines, currentLineIndex])
|
||||
|
||||
const handleMultilineSubmit = useCallback(() => {
|
||||
const fullText = lines.join("\n").trim()
|
||||
if (fullText) {
|
||||
handleSubmit(fullText)
|
||||
}
|
||||
}, [lines, handleSubmit])
|
||||
|
||||
const handleTabKey = useCallback(() => {
|
||||
if (storage && autocompleteEnabled && value.trim()) {
|
||||
const suggestions = autocomplete.suggestions
|
||||
@@ -116,11 +149,22 @@ export function Input({
|
||||
if (key.tab) {
|
||||
handleTabKey()
|
||||
}
|
||||
if (key.return && key.shift && isMultilineActive) {
|
||||
handleAddLine()
|
||||
}
|
||||
if (key.upArrow) {
|
||||
handleUpArrow()
|
||||
if (isMultilineActive && currentLineIndex > 0) {
|
||||
setCurrentLineIndex(currentLineIndex - 1)
|
||||
} else if (!isMultilineActive) {
|
||||
handleUpArrow()
|
||||
}
|
||||
}
|
||||
if (key.downArrow) {
|
||||
handleDownArrow()
|
||||
if (isMultilineActive && currentLineIndex < lines.length - 1) {
|
||||
setCurrentLineIndex(currentLineIndex + 1)
|
||||
} else if (!isMultilineActive) {
|
||||
handleDownArrow()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: !disabled },
|
||||
@@ -130,21 +174,56 @@ export function Input({
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box borderStyle="single" borderColor={disabled ? "gray" : "cyan"} paddingX={1}>
|
||||
<Text color={disabled ? "gray" : "green"} bold>
|
||||
{">"}{" "}
|
||||
</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={disabled ? "gray" : "cyan"}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{disabled ? (
|
||||
<Text color="gray" dimColor>
|
||||
{placeholder}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text color="gray" bold>
|
||||
{">"}{" "}
|
||||
</Text>
|
||||
<Text color="gray" dimColor>
|
||||
{placeholder}
|
||||
</Text>
|
||||
</Box>
|
||||
) : isMultilineActive ? (
|
||||
<Box flexDirection="column">
|
||||
{lines.map((line, index) => (
|
||||
<Box key={index}>
|
||||
<Text color="green" bold>
|
||||
{index === currentLineIndex ? ">" : " "}{" "}
|
||||
</Text>
|
||||
{index === currentLineIndex ? (
|
||||
<TextInput
|
||||
value={line}
|
||||
onChange={handleLineChange}
|
||||
onSubmit={handleMultilineSubmit}
|
||||
placeholder={index === 0 ? placeholder : ""}
|
||||
/>
|
||||
) : (
|
||||
<Text>{line}</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Shift+Enter: new line | Enter: submit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<Box>
|
||||
<Text color="green" bold>
|
||||
{">"}{" "}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{hasSuggestions && !disabled && (
|
||||
|
||||
167
packages/ipuaro/src/tui/utils/syntax-highlighter.ts
Normal file
167
packages/ipuaro/src/tui/utils/syntax-highlighter.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Simple syntax highlighter for terminal UI.
|
||||
* Highlights keywords, strings, comments, numbers, and operators.
|
||||
*/
|
||||
|
||||
export type Language = "typescript" | "javascript" | "tsx" | "jsx" | "json" | "yaml" | "unknown"
|
||||
|
||||
export interface HighlightedToken {
|
||||
text: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const KEYWORDS = new Set([
|
||||
"abstract",
|
||||
"any",
|
||||
"as",
|
||||
"async",
|
||||
"await",
|
||||
"boolean",
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"constructor",
|
||||
"continue",
|
||||
"debugger",
|
||||
"declare",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"enum",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"finally",
|
||||
"for",
|
||||
"from",
|
||||
"function",
|
||||
"get",
|
||||
"if",
|
||||
"implements",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"interface",
|
||||
"let",
|
||||
"module",
|
||||
"namespace",
|
||||
"new",
|
||||
"null",
|
||||
"number",
|
||||
"of",
|
||||
"package",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"readonly",
|
||||
"require",
|
||||
"return",
|
||||
"set",
|
||||
"static",
|
||||
"string",
|
||||
"super",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"true",
|
||||
"try",
|
||||
"type",
|
||||
"typeof",
|
||||
"undefined",
|
||||
"var",
|
||||
"void",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
])
|
||||
|
||||
export function detectLanguage(filePath: string): Language {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase()
|
||||
switch (ext) {
|
||||
case "ts":
|
||||
return "typescript"
|
||||
case "tsx":
|
||||
return "tsx"
|
||||
case "js":
|
||||
return "javascript"
|
||||
case "jsx":
|
||||
return "jsx"
|
||||
case "json":
|
||||
return "json"
|
||||
case "yaml":
|
||||
case "yml":
|
||||
return "yaml"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
const COMMENT_REGEX = /^(\/\/.*|\/\*[\s\S]*?\*\/)/
|
||||
const STRING_REGEX = /^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/
|
||||
const NUMBER_REGEX = /^(\b\d+\.?\d*\b)/
|
||||
const WORD_REGEX = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/
|
||||
const OPERATOR_REGEX = /^([+\-*/%=<>!&|^~?:;,.()[\]{}])/
|
||||
const WHITESPACE_REGEX = /^(\s+)/
|
||||
|
||||
export function highlightLine(line: string, language: Language): HighlightedToken[] {
|
||||
if (language === "unknown" || language === "json" || language === "yaml") {
|
||||
return [{ text: line, color: "white" }]
|
||||
}
|
||||
|
||||
const tokens: HighlightedToken[] = []
|
||||
let remaining = line
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const commentMatch = COMMENT_REGEX.exec(remaining)
|
||||
if (commentMatch) {
|
||||
tokens.push({ text: commentMatch[0], color: "gray" })
|
||||
remaining = remaining.slice(commentMatch[0].length)
|
||||
continue
|
||||
}
|
||||
|
||||
const stringMatch = STRING_REGEX.exec(remaining)
|
||||
if (stringMatch) {
|
||||
tokens.push({ text: stringMatch[0], color: "green" })
|
||||
remaining = remaining.slice(stringMatch[0].length)
|
||||
continue
|
||||
}
|
||||
|
||||
const numberMatch = NUMBER_REGEX.exec(remaining)
|
||||
if (numberMatch) {
|
||||
tokens.push({ text: numberMatch[0], color: "cyan" })
|
||||
remaining = remaining.slice(numberMatch[0].length)
|
||||
continue
|
||||
}
|
||||
|
||||
const wordMatch = WORD_REGEX.exec(remaining)
|
||||
if (wordMatch) {
|
||||
const word = wordMatch[0]
|
||||
const color = KEYWORDS.has(word) ? "magenta" : "white"
|
||||
tokens.push({ text: word, color })
|
||||
remaining = remaining.slice(word.length)
|
||||
continue
|
||||
}
|
||||
|
||||
const operatorMatch = OPERATOR_REGEX.exec(remaining)
|
||||
if (operatorMatch) {
|
||||
tokens.push({ text: operatorMatch[0], color: "yellow" })
|
||||
remaining = remaining.slice(operatorMatch[0].length)
|
||||
continue
|
||||
}
|
||||
|
||||
const whitespaceMatch = WHITESPACE_REGEX.exec(remaining)
|
||||
if (whitespaceMatch) {
|
||||
tokens.push({ text: whitespaceMatch[0], color: "white" })
|
||||
remaining = remaining.slice(whitespaceMatch[0].length)
|
||||
continue
|
||||
}
|
||||
|
||||
tokens.push({ text: remaining[0] ?? "", color: "white" })
|
||||
remaining = remaining.slice(1)
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
@@ -181,4 +181,170 @@ describe("Input", () => {
|
||||
expect(savedInput).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiline support", () => {
|
||||
describe("InputProps with multiline", () => {
|
||||
it("should accept multiline as boolean", () => {
|
||||
const props: InputProps = {
|
||||
onSubmit: vi.fn(),
|
||||
history: [],
|
||||
disabled: false,
|
||||
multiline: true,
|
||||
}
|
||||
expect(props.multiline).toBe(true)
|
||||
})
|
||||
|
||||
it("should accept multiline as 'auto'", () => {
|
||||
const props: InputProps = {
|
||||
onSubmit: vi.fn(),
|
||||
history: [],
|
||||
disabled: false,
|
||||
multiline: "auto",
|
||||
}
|
||||
expect(props.multiline).toBe("auto")
|
||||
})
|
||||
|
||||
it("should have multiline false by default", () => {
|
||||
const props: InputProps = {
|
||||
onSubmit: vi.fn(),
|
||||
history: [],
|
||||
disabled: false,
|
||||
}
|
||||
expect(props.multiline).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiline activation logic", () => {
|
||||
it("should be active when multiline is true", () => {
|
||||
const multiline = true
|
||||
const lines = ["single line"]
|
||||
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1)
|
||||
expect(isMultilineActive).toBe(true)
|
||||
})
|
||||
|
||||
it("should not be active when multiline is false", () => {
|
||||
const multiline = false
|
||||
const lines = ["line1", "line2"]
|
||||
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1)
|
||||
expect(isMultilineActive).toBe(false)
|
||||
})
|
||||
|
||||
it("should be active in auto mode with multiple lines", () => {
|
||||
const multiline = "auto"
|
||||
const lines = ["line1", "line2"]
|
||||
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1)
|
||||
expect(isMultilineActive).toBe(true)
|
||||
})
|
||||
|
||||
it("should not be active in auto mode with single line", () => {
|
||||
const multiline = "auto"
|
||||
const lines = ["single line"]
|
||||
const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1)
|
||||
expect(isMultilineActive).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("line management", () => {
|
||||
it("should update current line on change", () => {
|
||||
const lines = ["first", "second", "third"]
|
||||
const currentLineIndex = 1
|
||||
const newValue = "updated second"
|
||||
|
||||
const newLines = [...lines]
|
||||
newLines[currentLineIndex] = newValue
|
||||
|
||||
expect(newLines).toEqual(["first", "updated second", "third"])
|
||||
expect(newLines.join("\n")).toBe("first\nupdated second\nthird")
|
||||
})
|
||||
|
||||
it("should add new line at current position", () => {
|
||||
const lines = ["first", "second"]
|
||||
const currentLineIndex = 0
|
||||
|
||||
const newLines = [...lines]
|
||||
newLines.splice(currentLineIndex + 1, 0, "")
|
||||
|
||||
expect(newLines).toEqual(["first", "", "second"])
|
||||
})
|
||||
|
||||
it("should join lines with newline for submit", () => {
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const fullText = lines.join("\n")
|
||||
expect(fullText).toBe("line 1\nline 2\nline 3")
|
||||
})
|
||||
})
|
||||
|
||||
describe("line navigation", () => {
|
||||
it("should navigate up in multiline mode", () => {
|
||||
const lines = ["line1", "line2", "line3"]
|
||||
let currentLineIndex = 2
|
||||
|
||||
currentLineIndex = currentLineIndex - 1
|
||||
expect(currentLineIndex).toBe(1)
|
||||
|
||||
currentLineIndex = currentLineIndex - 1
|
||||
expect(currentLineIndex).toBe(0)
|
||||
})
|
||||
|
||||
it("should not navigate up past first line", () => {
|
||||
const lines = ["line1", "line2"]
|
||||
const currentLineIndex = 0
|
||||
const isMultilineActive = true
|
||||
|
||||
const canNavigateUp = isMultilineActive && currentLineIndex > 0
|
||||
expect(canNavigateUp).toBe(false)
|
||||
})
|
||||
|
||||
it("should navigate down in multiline mode", () => {
|
||||
const lines = ["line1", "line2", "line3"]
|
||||
let currentLineIndex = 0
|
||||
|
||||
currentLineIndex = currentLineIndex + 1
|
||||
expect(currentLineIndex).toBe(1)
|
||||
|
||||
currentLineIndex = currentLineIndex + 1
|
||||
expect(currentLineIndex).toBe(2)
|
||||
})
|
||||
|
||||
it("should not navigate down past last line", () => {
|
||||
const lines = ["line1", "line2"]
|
||||
const currentLineIndex = 1
|
||||
const isMultilineActive = true
|
||||
|
||||
const canNavigateDown = isMultilineActive && currentLineIndex < lines.length - 1
|
||||
expect(canNavigateDown).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("multiline submit", () => {
|
||||
it("should submit trimmed multiline text", () => {
|
||||
const lines = ["line 1", "line 2", "line 3"]
|
||||
const fullText = lines.join("\n").trim()
|
||||
expect(fullText).toBe("line 1\nline 2\nline 3")
|
||||
})
|
||||
|
||||
it("should not submit empty multiline text", () => {
|
||||
const onSubmit = vi.fn()
|
||||
const lines = ["", "", ""]
|
||||
const fullText = lines.join("\n").trim()
|
||||
|
||||
if (fullText) {
|
||||
onSubmit(fullText)
|
||||
}
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should reset lines after submit", () => {
|
||||
let lines = ["line1", "line2"]
|
||||
let currentLineIndex = 1
|
||||
|
||||
lines = [""]
|
||||
currentLineIndex = 0
|
||||
|
||||
expect(lines).toEqual([""])
|
||||
expect(currentLineIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
155
packages/ipuaro/tests/unit/tui/utils/syntax-highlighter.test.ts
Normal file
155
packages/ipuaro/tests/unit/tui/utils/syntax-highlighter.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Tests for syntax-highlighter utility.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { detectLanguage, highlightLine } from "../../../../src/tui/utils/syntax-highlighter.js"
|
||||
|
||||
describe("syntax-highlighter", () => {
|
||||
describe("detectLanguage", () => {
|
||||
it("should detect typescript from .ts extension", () => {
|
||||
expect(detectLanguage("src/index.ts")).toBe("typescript")
|
||||
})
|
||||
|
||||
it("should detect tsx from .tsx extension", () => {
|
||||
expect(detectLanguage("src/Component.tsx")).toBe("tsx")
|
||||
})
|
||||
|
||||
it("should detect javascript from .js extension", () => {
|
||||
expect(detectLanguage("dist/bundle.js")).toBe("javascript")
|
||||
})
|
||||
|
||||
it("should detect jsx from .jsx extension", () => {
|
||||
expect(detectLanguage("src/App.jsx")).toBe("jsx")
|
||||
})
|
||||
|
||||
it("should detect json from .json extension", () => {
|
||||
expect(detectLanguage("package.json")).toBe("json")
|
||||
})
|
||||
|
||||
it("should detect yaml from .yaml extension", () => {
|
||||
expect(detectLanguage("config.yaml")).toBe("yaml")
|
||||
})
|
||||
|
||||
it("should detect yaml from .yml extension", () => {
|
||||
expect(detectLanguage("config.yml")).toBe("yaml")
|
||||
})
|
||||
|
||||
it("should return unknown for unsupported extensions", () => {
|
||||
expect(detectLanguage("image.png")).toBe("unknown")
|
||||
expect(detectLanguage("file")).toBe("unknown")
|
||||
})
|
||||
|
||||
it("should handle case insensitive extensions", () => {
|
||||
expect(detectLanguage("FILE.TS")).toBe("typescript")
|
||||
expect(detectLanguage("FILE.JSX")).toBe("jsx")
|
||||
})
|
||||
})
|
||||
|
||||
describe("highlightLine", () => {
|
||||
describe("unknown language", () => {
|
||||
it("should return plain text for unknown language", () => {
|
||||
const tokens = highlightLine("hello world", "unknown")
|
||||
expect(tokens).toEqual([{ text: "hello world", color: "white" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("json language", () => {
|
||||
it("should return plain text for json", () => {
|
||||
const tokens = highlightLine('{"key": "value"}', "json")
|
||||
expect(tokens).toEqual([{ text: '{"key": "value"}', color: "white" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("yaml language", () => {
|
||||
it("should return plain text for yaml", () => {
|
||||
const tokens = highlightLine("key: value", "yaml")
|
||||
expect(tokens).toEqual([{ text: "key: value", color: "white" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("typescript/javascript highlighting", () => {
|
||||
it("should highlight keywords", () => {
|
||||
const tokens = highlightLine("const x = 10", "typescript")
|
||||
expect(tokens[0]).toEqual({ text: "const", color: "magenta" })
|
||||
expect(tokens.find((t) => t.text === "x")).toEqual({ text: "x", color: "white" })
|
||||
})
|
||||
|
||||
it("should highlight strings with double quotes", () => {
|
||||
const tokens = highlightLine('const s = "hello"', "typescript")
|
||||
expect(tokens.find((t) => t.text === '"hello"')).toEqual({
|
||||
text: '"hello"',
|
||||
color: "green",
|
||||
})
|
||||
})
|
||||
|
||||
it("should highlight strings with single quotes", () => {
|
||||
const tokens = highlightLine("const s = 'hello'", "typescript")
|
||||
expect(tokens.find((t) => t.text === "'hello'")).toEqual({
|
||||
text: "'hello'",
|
||||
color: "green",
|
||||
})
|
||||
})
|
||||
|
||||
it("should highlight template literals", () => {
|
||||
const tokens = highlightLine("const s = `hello`", "typescript")
|
||||
expect(tokens.find((t) => t.text === "`hello`")).toEqual({
|
||||
text: "`hello`",
|
||||
color: "green",
|
||||
})
|
||||
})
|
||||
|
||||
it("should highlight numbers", () => {
|
||||
const tokens = highlightLine("const n = 42", "typescript")
|
||||
expect(tokens.find((t) => t.text === "42")).toEqual({ text: "42", color: "cyan" })
|
||||
})
|
||||
|
||||
it("should highlight single-line comments", () => {
|
||||
const tokens = highlightLine("// this is a comment", "typescript")
|
||||
expect(tokens[0]).toEqual({ text: "// this is a comment", color: "gray" })
|
||||
})
|
||||
|
||||
it("should highlight multi-line comments", () => {
|
||||
const tokens = highlightLine("/* comment */", "typescript")
|
||||
expect(tokens[0]).toEqual({ text: "/* comment */", color: "gray" })
|
||||
})
|
||||
|
||||
it("should highlight operators", () => {
|
||||
const tokens = highlightLine("x + y = z", "typescript")
|
||||
expect(tokens.find((t) => t.text === "+")).toEqual({ text: "+", color: "yellow" })
|
||||
expect(tokens.find((t) => t.text === "=")).toEqual({ text: "=", color: "yellow" })
|
||||
})
|
||||
|
||||
it("should highlight parentheses and brackets", () => {
|
||||
const tokens = highlightLine("foo(bar[0])", "typescript")
|
||||
expect(tokens.find((t) => t.text === "(")).toEqual({ text: "(", color: "yellow" })
|
||||
expect(tokens.find((t) => t.text === "[")).toEqual({ text: "[", color: "yellow" })
|
||||
expect(tokens.find((t) => t.text === "]")).toEqual({ text: "]", color: "yellow" })
|
||||
expect(tokens.find((t) => t.text === ")")).toEqual({ text: ")", color: "yellow" })
|
||||
})
|
||||
|
||||
it("should handle mixed content", () => {
|
||||
const tokens = highlightLine('const x = "test" + 42', "typescript")
|
||||
expect(tokens.find((t) => t.text === "const")).toEqual({
|
||||
text: "const",
|
||||
color: "magenta",
|
||||
})
|
||||
expect(tokens.find((t) => t.text === '"test"')).toEqual({
|
||||
text: '"test"',
|
||||
color: "green",
|
||||
})
|
||||
expect(tokens.find((t) => t.text === "42")).toEqual({ text: "42", color: "cyan" })
|
||||
})
|
||||
|
||||
it("should preserve whitespace", () => {
|
||||
const tokens = highlightLine(" const x = 10 ", "typescript")
|
||||
expect(tokens[0]).toEqual({ text: " ", color: "white" })
|
||||
})
|
||||
|
||||
it("should handle empty lines", () => {
|
||||
const tokens = highlightLine("", "typescript")
|
||||
expect(tokens).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user