diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index 16b01ff..9421638 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -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 diff --git a/packages/ipuaro/package.json b/packages/ipuaro/package.json index 2cd9dcf..50f1d25 100644 --- a/packages/ipuaro/package.json +++ b/packages/ipuaro/package.json @@ -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 ", "license": "MIT", diff --git a/packages/ipuaro/src/tui/components/ConfirmDialog.tsx b/packages/ipuaro/src/tui/components/ConfirmDialog.tsx new file mode 100644 index 0000000..7bbee83 --- /dev/null +++ b/packages/ipuaro/src/tui/components/ConfirmDialog.tsx @@ -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 ( + + + [{hotkey}] {label} + + + ) +} + +export function ConfirmDialog({ message, diff, onSelect }: ConfirmDialogProps): React.JSX.Element { + const [selected, setSelected] = useState(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 ( + + + + ⚠ {message} + + + + {diff && ( + + + + )} + + + + + + + + ) +} diff --git a/packages/ipuaro/src/tui/components/DiffView.tsx b/packages/ipuaro/src/tui/components/DiffView.tsx new file mode 100644 index 0000000..473910d --- /dev/null +++ b/packages/ipuaro/src/tui/components/DiffView.tsx @@ -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 ( + + {lineNum} + + {prefix} {line.content} + + + ) +} + +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 ( + + ┌─── + {filePath} + ({lineRange}) ───┐ + + ) +} + +function DiffFooter(): React.JSX.Element { + return ( + + └───────────────────────────────────────┘ + + ) +} + +function DiffStats({ + additions, + deletions, +}: { + additions: number + deletions: number +}): React.JSX.Element { + return ( + + +{String(additions)} + -{String(deletions)} + + ) +} + +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 ( + + + + {diffLines.map((line, index) => ( + + ))} + + + + + ) +} diff --git a/packages/ipuaro/src/tui/components/ErrorDialog.tsx b/packages/ipuaro/src/tui/components/ErrorDialog.tsx new file mode 100644 index 0000000..55f2b2c --- /dev/null +++ b/packages/ipuaro/src/tui/components/ErrorDialog.tsx @@ -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 ( + + + [{hotkey}] {label} + + + ) + } + + return ( + + + [{hotkey}] {label} + + + ) +} + +export function ErrorDialog({ error, onChoice }: ErrorDialogProps): React.JSX.Element { + const [selected, setSelected] = useState(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 ( + + + + x {error.type}: {error.message} + + + + + + + + + + {!error.recoverable && ( + + + This error is not recoverable. Press [A] to abort. + + + )} + + ) +} diff --git a/packages/ipuaro/src/tui/components/Progress.tsx b/packages/ipuaro/src/tui/components/Progress.tsx new file mode 100644 index 0000000..912a78e --- /dev/null +++ b/packages/ipuaro/src/tui/components/Progress.tsx @@ -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 ( + + [ + {filled} + {empty} + ] + + {String(percentage)}% + + + ({String(current)}/{String(total)} {label}) + + + ) +} diff --git a/packages/ipuaro/src/tui/components/index.ts b/packages/ipuaro/src/tui/components/index.ts index 8b9616c..91cb2e2 100644 --- a/packages/ipuaro/src/tui/components/index.ts +++ b/packages/ipuaro/src/tui/components/index.ts @@ -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"