mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user