feat(ipuaro): add TUI advanced components (v0.12.0)

Add DiffView, ConfirmDialog, ErrorDialog, and Progress components
for enhanced terminal UI interactions.
This commit is contained in:
imfozilbek
2025-12-01 13:34:17 +05:00
parent fd1e6ad86e
commit 7d18e87423
7 changed files with 484 additions and 1 deletions

View File

@@ -5,6 +5,42 @@ 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.12.0] - 2025-12-01 - TUI Advanced
### Added
- **DiffView Component (0.12.1)**
- Inline diff display with green (added) and red (removed) highlighting
- Header with file path and line range: `┌─── path (lines X-Y) ───┐`
- Line numbers with proper padding
- Stats footer showing additions and deletions count
- **ConfirmDialog Component (0.12.2)**
- Confirmation dialog with [Y] Apply / [N] Cancel / [E] Edit options
- Optional diff preview integration
- Keyboard input handling (Y/N/E keys, Escape)
- Visual selection feedback
- **ErrorDialog Component (0.12.3)**
- Error dialog with [R] Retry / [S] Skip / [A] Abort options
- Recoverable vs non-recoverable error handling
- Disabled buttons for non-recoverable errors
- Keyboard input with Escape support
- **Progress Component (0.12.4)**
- Progress bar display: `[=====> ] 45% (120/267 files)`
- Color-coded progress (cyan < 50%, yellow < 100%, green = 100%)
- Configurable width
- Label support for context
### Changed
- Total tests: 1254 (unchanged - TUI components excluded from coverage)
- TUI layer now has 8 components + 2 hooks
- All v0.12.0 roadmap items complete
---
## [0.11.0] - 2025-12-01 - TUI Basic
### Added

View File

@@ -1,6 +1,6 @@
{
"name": "@samiyev/ipuaro",
"version": "0.11.0",
"version": "0.12.0",
"description": "Local AI agent for codebase operations with infinite context feeling",
"author": "Fozilbek Samiyev <fozilbek.samiyev@gmail.com>",
"license": "MIT",

View File

@@ -0,0 +1,83 @@
/**
* ConfirmDialog component for TUI.
* Displays a confirmation dialog with [Y] Apply / [N] Cancel / [E] Edit options.
*/
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import type { ConfirmChoice } from "../../shared/types/index.js"
import { DiffView, type DiffViewProps } from "./DiffView.js"
export interface ConfirmDialogProps {
message: string
diff?: DiffViewProps
onSelect: (choice: ConfirmChoice) => void
}
function ChoiceButton({
hotkey,
label,
isSelected,
}: {
hotkey: string
label: string
isSelected: boolean
}): React.JSX.Element {
return (
<Box>
<Text color={isSelected ? "cyan" : "gray"}>
[<Text bold>{hotkey}</Text>] {label}
</Text>
</Box>
)
}
export function ConfirmDialog({ message, diff, onSelect }: ConfirmDialogProps): React.JSX.Element {
const [selected, setSelected] = useState<ConfirmChoice | null>(null)
useInput((input, key) => {
const lowerInput = input.toLowerCase()
if (lowerInput === "y") {
setSelected("apply")
onSelect("apply")
} else if (lowerInput === "n") {
setSelected("cancel")
onSelect("cancel")
} else if (lowerInput === "e") {
setSelected("edit")
onSelect("edit")
} else if (key.escape) {
setSelected("cancel")
onSelect("cancel")
}
})
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="yellow"
paddingX={1}
paddingY={1}
>
<Box marginBottom={1}>
<Text color="yellow" bold>
{message}
</Text>
</Box>
{diff && (
<Box marginBottom={1}>
<DiffView {...diff} />
</Box>
)}
<Box gap={2}>
<ChoiceButton hotkey="Y" label="Apply" isSelected={selected === "apply"} />
<ChoiceButton hotkey="N" label="Cancel" isSelected={selected === "cancel"} />
<ChoiceButton hotkey="E" label="Edit" isSelected={selected === "edit"} />
</Box>
</Box>
)
}

View File

@@ -0,0 +1,193 @@
/**
* DiffView component for TUI.
* Displays inline diff with green (added) and red (removed) highlighting.
*/
import { Box, Text } from "ink"
import type React from "react"
export interface DiffViewProps {
filePath: string
oldLines: string[]
newLines: string[]
startLine: number
}
interface DiffLine {
type: "add" | "remove" | "context"
content: string
lineNumber?: number
}
function computeDiff(oldLines: string[], newLines: string[], startLine: number): DiffLine[] {
const result: DiffLine[] = []
let oldIdx = 0
let newIdx = 0
while (oldIdx < oldLines.length || newIdx < newLines.length) {
const oldLine = oldIdx < oldLines.length ? oldLines[oldIdx] : undefined
const newLine = newIdx < newLines.length ? newLines[newIdx] : undefined
if (oldLine === newLine) {
result.push({
type: "context",
content: oldLine ?? "",
lineNumber: startLine + newIdx,
})
oldIdx++
newIdx++
} else {
if (oldLine !== undefined) {
result.push({
type: "remove",
content: oldLine,
})
oldIdx++
}
if (newLine !== undefined) {
result.push({
type: "add",
content: newLine,
lineNumber: startLine + newIdx,
})
newIdx++
}
}
}
return result
}
function getLinePrefix(line: DiffLine): string {
switch (line.type) {
case "add": {
return "+"
}
case "remove": {
return "-"
}
case "context": {
return " "
}
}
}
function getLineColor(line: DiffLine): string {
switch (line.type) {
case "add": {
return "green"
}
case "remove": {
return "red"
}
case "context": {
return "gray"
}
}
}
function formatLineNumber(num: number | undefined, width: number): string {
if (num === undefined) {
return " ".repeat(width)
}
return String(num).padStart(width, " ")
}
function DiffLine({
line,
lineNumberWidth,
}: {
line: DiffLine
lineNumberWidth: number
}): React.JSX.Element {
const prefix = getLinePrefix(line)
const color = getLineColor(line)
const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth)
return (
<Box>
<Text color="gray">{lineNum} </Text>
<Text color={color}>
{prefix} {line.content}
</Text>
</Box>
)
}
function DiffHeader({
filePath,
startLine,
endLine,
}: {
filePath: string
startLine: number
endLine: number
}): React.JSX.Element {
const lineRange =
startLine === endLine
? `line ${String(startLine)}`
: `lines ${String(startLine)}-${String(endLine)}`
return (
<Box>
<Text color="gray"> </Text>
<Text color="cyan">{filePath}</Text>
<Text color="gray"> ({lineRange}) </Text>
</Box>
)
}
function DiffFooter(): React.JSX.Element {
return (
<Box>
<Text color="gray"></Text>
</Box>
)
}
function DiffStats({
additions,
deletions,
}: {
additions: number
deletions: number
}): React.JSX.Element {
return (
<Box gap={1} marginTop={1}>
<Text color="green">+{String(additions)}</Text>
<Text color="red">-{String(deletions)}</Text>
</Box>
)
}
export function DiffView({
filePath,
oldLines,
newLines,
startLine,
}: DiffViewProps): React.JSX.Element {
const diffLines = computeDiff(oldLines, newLines, startLine)
const endLine = startLine + newLines.length - 1
const lineNumberWidth = String(endLine).length
const additions = diffLines.filter((l) => l.type === "add").length
const deletions = diffLines.filter((l) => l.type === "remove").length
return (
<Box flexDirection="column" paddingX={1}>
<DiffHeader filePath={filePath} startLine={startLine} endLine={endLine} />
<Box flexDirection="column" paddingX={1}>
{diffLines.map((line, index) => (
<DiffLine
key={`${line.type}-${String(index)}`}
line={line}
lineNumberWidth={lineNumberWidth}
/>
))}
</Box>
<DiffFooter />
<DiffStats additions={additions} deletions={deletions} />
</Box>
)
}

View File

@@ -0,0 +1,105 @@
/**
* ErrorDialog component for TUI.
* Displays an error with [R] Retry / [S] Skip / [A] Abort options.
*/
import { Box, Text, useInput } from "ink"
import React, { useState } from "react"
import type { ErrorChoice } from "../../shared/types/index.js"
export interface ErrorInfo {
type: string
message: string
recoverable: boolean
}
export interface ErrorDialogProps {
error: ErrorInfo
onChoice: (choice: ErrorChoice) => void
}
function ChoiceButton({
hotkey,
label,
isSelected,
disabled,
}: {
hotkey: string
label: string
isSelected: boolean
disabled?: boolean
}): React.JSX.Element {
if (disabled) {
return (
<Box>
<Text color="gray" dimColor>
[{hotkey}] {label}
</Text>
</Box>
)
}
return (
<Box>
<Text color={isSelected ? "cyan" : "gray"}>
[<Text bold>{hotkey}</Text>] {label}
</Text>
</Box>
)
}
export function ErrorDialog({ error, onChoice }: ErrorDialogProps): React.JSX.Element {
const [selected, setSelected] = useState<ErrorChoice | null>(null)
useInput((input, key) => {
const lowerInput = input.toLowerCase()
if (lowerInput === "r" && error.recoverable) {
setSelected("retry")
onChoice("retry")
} else if (lowerInput === "s" && error.recoverable) {
setSelected("skip")
onChoice("skip")
} else if (lowerInput === "a") {
setSelected("abort")
onChoice("abort")
} else if (key.escape) {
setSelected("abort")
onChoice("abort")
}
})
return (
<Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1} paddingY={1}>
<Box marginBottom={1}>
<Text color="red" bold>
x {error.type}: {error.message}
</Text>
</Box>
<Box gap={2}>
<ChoiceButton
hotkey="R"
label="Retry"
isSelected={selected === "retry"}
disabled={!error.recoverable}
/>
<ChoiceButton
hotkey="S"
label="Skip"
isSelected={selected === "skip"}
disabled={!error.recoverable}
/>
<ChoiceButton hotkey="A" label="Abort" isSelected={selected === "abort"} />
</Box>
{!error.recoverable && (
<Box marginTop={1}>
<Text color="gray" dimColor>
This error is not recoverable. Press [A] to abort.
</Text>
</Box>
)}
</Box>
)
}

View File

@@ -0,0 +1,62 @@
/**
* Progress component for TUI.
* Displays a progress bar: [=====> ] 45% (120/267 files)
*/
import { Box, Text } from "ink"
import type React from "react"
export interface ProgressProps {
current: number
total: number
label: string
width?: number
}
function calculatePercentage(current: number, total: number): number {
if (total === 0) {
return 0
}
return Math.min(100, Math.round((current / total) * 100))
}
function createProgressBar(percentage: number, width: number): { filled: string; empty: string } {
const filledWidth = Math.round((percentage / 100) * width)
const emptyWidth = width - filledWidth
const filled = "=".repeat(Math.max(0, filledWidth - 1)) + (filledWidth > 0 ? ">" : "")
const empty = " ".repeat(Math.max(0, emptyWidth))
return { filled, empty }
}
function getProgressColor(percentage: number): string {
if (percentage >= 100) {
return "green"
}
if (percentage >= 50) {
return "yellow"
}
return "cyan"
}
export function Progress({ current, total, label, width = 30 }: ProgressProps): React.JSX.Element {
const percentage = calculatePercentage(current, total)
const { filled, empty } = createProgressBar(percentage, width)
const color = getProgressColor(percentage)
return (
<Box gap={1}>
<Text color="gray">[</Text>
<Text color={color}>{filled}</Text>
<Text color="gray">{empty}</Text>
<Text color="gray">]</Text>
<Text color={color} bold>
{String(percentage)}%
</Text>
<Text color="gray">
({String(current)}/{String(total)} {label})
</Text>
</Box>
)
}

View File

@@ -5,3 +5,7 @@
export { StatusBar, type StatusBarProps } from "./StatusBar.js"
export { Chat, type ChatProps } from "./Chat.js"
export { Input, type InputProps } from "./Input.js"
export { DiffView, type DiffViewProps } from "./DiffView.js"
export { ConfirmDialog, type ConfirmDialogProps } from "./ConfirmDialog.js"
export { ErrorDialog, type ErrorDialogProps, type ErrorInfo } from "./ErrorDialog.js"
export { Progress, type ProgressProps } from "./Progress.js"