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

@@ -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>
)

View File

@@ -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>
)}

View File

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

View File

@@ -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 && (

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
}