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:
imfozilbek
2025-12-02 00:31:21 +05:00
parent 908c2f50d7
commit a589b0dfc4
9 changed files with 756 additions and 19 deletions

View File

@@ -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/), 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). 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) ## [0.21.1] - 2025-12-01 - TUI Enhancements (Part 2)
### Added ### Added

View File

@@ -76,6 +76,14 @@ export const UndoConfigSchema = z.object({
*/ */
export const EditConfigSchema = z.object({ export const EditConfigSchema = z.object({
autoApply: z.boolean().default(false), 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({}), watchdog: WatchdogConfigSchema.default({}),
undo: UndoConfigSchema.default({}), undo: UndoConfigSchema.default({}),
edit: EditConfigSchema.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 WatchdogConfig = z.infer<typeof WatchdogConfigSchema>
export type UndoConfig = z.infer<typeof UndoConfigSchema> export type UndoConfig = z.infer<typeof UndoConfigSchema>
export type EditConfig = z.infer<typeof EditConfigSchema> export type EditConfig = z.infer<typeof EditConfigSchema>
export type InputConfig = z.infer<typeof InputConfigSchema>
/** /**
* Default configuration. * Default configuration.

View File

@@ -29,6 +29,8 @@ export interface AppDependencies {
export interface ExtendedAppProps extends AppProps { export interface ExtendedAppProps extends AppProps {
deps: AppDependencies deps: AppDependencies
onExit?: () => void onExit?: () => void
multiline?: boolean | "auto"
syntaxHighlight?: boolean
} }
function LoadingScreen(): React.JSX.Element { function LoadingScreen(): React.JSX.Element {
@@ -65,6 +67,8 @@ export function App({
autoApply: initialAutoApply = false, autoApply: initialAutoApply = false,
deps, deps,
onExit, onExit,
multiline = false,
syntaxHighlight = true,
}: ExtendedAppProps): React.JSX.Element { }: ExtendedAppProps): React.JSX.Element {
const { exit } = useApp() const { exit } = useApp()
@@ -253,6 +257,7 @@ export function App({
} }
onSelect={handleConfirmSelect} onSelect={handleConfirmSelect}
editableContent={pendingConfirmation.diff?.newLines} editableContent={pendingConfirmation.diff?.newLines}
syntaxHighlight={syntaxHighlight}
/> />
)} )}
<Input <Input
@@ -263,6 +268,7 @@ export function App({
storage={deps.storage} storage={deps.storage}
projectRoot={projectPath} projectRoot={projectPath}
autocompleteEnabled={true} autocompleteEnabled={true}
multiline={multiline}
/> />
</Box> </Box>
) )

View File

@@ -15,6 +15,7 @@ export interface ConfirmDialogProps {
diff?: DiffViewProps diff?: DiffViewProps
onSelect: (choice: ConfirmChoice, editedContent?: string[]) => void onSelect: (choice: ConfirmChoice, editedContent?: string[]) => void
editableContent?: string[] editableContent?: string[]
syntaxHighlight?: boolean
} }
type DialogMode = "confirm" | "edit" type DialogMode = "confirm" | "edit"
@@ -42,6 +43,7 @@ export function ConfirmDialog({
diff, diff,
onSelect, onSelect,
editableContent, editableContent,
syntaxHighlight = false,
}: ConfirmDialogProps): React.JSX.Element { }: ConfirmDialogProps): React.JSX.Element {
const [mode, setMode] = useState<DialogMode>("confirm") const [mode, setMode] = useState<DialogMode>("confirm")
const [selected, setSelected] = useState<ConfirmChoice | null>(null) const [selected, setSelected] = useState<ConfirmChoice | null>(null)
@@ -113,7 +115,7 @@ export function ConfirmDialog({
{diff && ( {diff && (
<Box marginBottom={1}> <Box marginBottom={1}>
<DiffView {...diff} /> <DiffView {...diff} syntaxHighlight={syntaxHighlight} />
</Box> </Box>
)} )}

View File

@@ -5,12 +5,15 @@
import { Box, Text } from "ink" import { Box, Text } from "ink"
import type React from "react" import type React from "react"
import { detectLanguage, highlightLine, type Language } from "../utils/syntax-highlighter.js"
export interface DiffViewProps { export interface DiffViewProps {
filePath: string filePath: string
oldLines: string[] oldLines: string[]
newLines: string[] newLines: string[]
startLine: number startLine: number
language?: Language
syntaxHighlight?: boolean
} }
interface DiffLine { interface DiffLine {
@@ -97,20 +100,37 @@ function formatLineNumber(num: number | undefined, width: number): string {
function DiffLine({ function DiffLine({
line, line,
lineNumberWidth, lineNumberWidth,
language,
syntaxHighlight,
}: { }: {
line: DiffLine line: DiffLine
lineNumberWidth: number lineNumberWidth: number
language?: Language
syntaxHighlight?: boolean
}): React.JSX.Element { }): React.JSX.Element {
const prefix = getLinePrefix(line) const prefix = getLinePrefix(line)
const color = getLineColor(line) const color = getLineColor(line)
const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth) const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth)
const shouldHighlight = syntaxHighlight && language && line.type === "add"
return ( return (
<Box> <Box>
<Text color="gray">{lineNum} </Text> <Text color="gray">{lineNum} </Text>
<Text color={color}> {shouldHighlight ? (
{prefix} {line.content} <Box>
</Text> <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> </Box>
) )
} }
@@ -166,6 +186,8 @@ export function DiffView({
oldLines, oldLines,
newLines, newLines,
startLine, startLine,
language,
syntaxHighlight = false,
}: DiffViewProps): React.JSX.Element { }: DiffViewProps): React.JSX.Element {
const diffLines = computeDiff(oldLines, newLines, startLine) const diffLines = computeDiff(oldLines, newLines, startLine)
const endLine = startLine + newLines.length - 1 const endLine = startLine + newLines.length - 1
@@ -174,6 +196,8 @@ export function DiffView({
const additions = diffLines.filter((l) => l.type === "add").length const additions = diffLines.filter((l) => l.type === "add").length
const deletions = diffLines.filter((l) => l.type === "remove").length const deletions = diffLines.filter((l) => l.type === "remove").length
const detectedLanguage = language ?? detectLanguage(filePath)
return ( return (
<Box flexDirection="column" paddingX={1}> <Box flexDirection="column" paddingX={1}>
<DiffHeader filePath={filePath} startLine={startLine} endLine={endLine} /> <DiffHeader filePath={filePath} startLine={startLine} endLine={endLine} />
@@ -183,6 +207,8 @@ export function DiffView({
key={`${line.type}-${String(index)}`} key={`${line.type}-${String(index)}`}
line={line} line={line}
lineNumberWidth={lineNumberWidth} lineNumberWidth={lineNumberWidth}
language={detectedLanguage}
syntaxHighlight={syntaxHighlight}
/> />
))} ))}
</Box> </Box>

View File

@@ -17,6 +17,7 @@ export interface InputProps {
storage?: IStorage storage?: IStorage
projectRoot?: string projectRoot?: string
autocompleteEnabled?: boolean autocompleteEnabled?: boolean
multiline?: boolean | "auto"
} }
export function Input({ export function Input({
@@ -27,10 +28,15 @@ export function Input({
storage, storage,
projectRoot = "", projectRoot = "",
autocompleteEnabled = true, autocompleteEnabled = true,
multiline = false,
}: InputProps): React.JSX.Element { }: InputProps): React.JSX.Element {
const [value, setValue] = useState("") const [value, setValue] = useState("")
const [historyIndex, setHistoryIndex] = useState(-1) const [historyIndex, setHistoryIndex] = useState(-1)
const [savedInput, setSavedInput] = useState("") 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 * Initialize autocomplete hook if storage is provided
@@ -62,6 +68,8 @@ export function Input({
} }
onSubmit(text) onSubmit(text)
setValue("") setValue("")
setLines([""])
setCurrentLineIndex(0)
setHistoryIndex(-1) setHistoryIndex(-1)
setSavedInput("") setSavedInput("")
autocomplete.reset() autocomplete.reset()
@@ -69,6 +77,31 @@ export function Input({
[disabled, onSubmit, autocomplete], [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(() => { const handleTabKey = useCallback(() => {
if (storage && autocompleteEnabled && value.trim()) { if (storage && autocompleteEnabled && value.trim()) {
const suggestions = autocomplete.suggestions const suggestions = autocomplete.suggestions
@@ -116,11 +149,22 @@ export function Input({
if (key.tab) { if (key.tab) {
handleTabKey() handleTabKey()
} }
if (key.return && key.shift && isMultilineActive) {
handleAddLine()
}
if (key.upArrow) { if (key.upArrow) {
handleUpArrow() if (isMultilineActive && currentLineIndex > 0) {
setCurrentLineIndex(currentLineIndex - 1)
} else if (!isMultilineActive) {
handleUpArrow()
}
} }
if (key.downArrow) { if (key.downArrow) {
handleDownArrow() if (isMultilineActive && currentLineIndex < lines.length - 1) {
setCurrentLineIndex(currentLineIndex + 1)
} else if (!isMultilineActive) {
handleDownArrow()
}
} }
}, },
{ isActive: !disabled }, { isActive: !disabled },
@@ -130,21 +174,56 @@ export function Input({
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<Box borderStyle="single" borderColor={disabled ? "gray" : "cyan"} paddingX={1}> <Box
<Text color={disabled ? "gray" : "green"} bold> borderStyle="single"
{">"}{" "} borderColor={disabled ? "gray" : "cyan"}
</Text> paddingX={1}
flexDirection="column"
>
{disabled ? ( {disabled ? (
<Text color="gray" dimColor> <Box>
{placeholder} <Text color="gray" bold>
</Text> {">"}{" "}
</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 <Box>
value={value} <Text color="green" bold>
onChange={handleChange} {">"}{" "}
onSubmit={handleSubmit} </Text>
placeholder={placeholder} <TextInput
/> value={value}
onChange={handleChange}
onSubmit={handleSubmit}
placeholder={placeholder}
/>
</Box>
)} )}
</Box> </Box>
{hasSuggestions && !disabled && ( {hasSuggestions && !disabled && (

View 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
}

View File

@@ -181,4 +181,170 @@ describe("Input", () => {
expect(savedInput).toBe("") 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)
})
})
})
}) })

View 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([])
})
})
})
})