feat(ipuaro): add display configuration

Add DisplayConfigSchema with theme support (dark/light), stats/tool calls visibility toggles, bell notification on completion, and progress bar control. Includes theme utilities with dynamic color schemes and 46 new tests.
This commit is contained in:
imfozilbek
2025-12-02 01:01:54 +05:00
parent b5ee77d8b8
commit 077d160343
11 changed files with 664 additions and 41 deletions

View File

@@ -86,6 +86,17 @@ export const InputConfigSchema = z.object({
multiline: z.union([z.boolean(), z.literal("auto")]).default(false),
})
/**
* Display configuration schema.
*/
export const DisplayConfigSchema = z.object({
showStats: z.boolean().default(true),
showToolCalls: z.boolean().default(true),
theme: z.enum(["dark", "light"]).default("dark"),
bellOnComplete: z.boolean().default(false),
progressBar: z.boolean().default(true),
})
/**
* Full configuration schema.
*/
@@ -97,6 +108,7 @@ export const ConfigSchema = z.object({
undo: UndoConfigSchema.default({}),
edit: EditConfigSchema.default({}),
input: InputConfigSchema.default({}),
display: DisplayConfigSchema.default({}),
})
/**
@@ -110,6 +122,7 @@ export type WatchdogConfig = z.infer<typeof WatchdogConfigSchema>
export type UndoConfig = z.infer<typeof UndoConfigSchema>
export type EditConfig = z.infer<typeof EditConfigSchema>
export type InputConfig = z.infer<typeof InputConfigSchema>
export type DisplayConfig = z.infer<typeof DisplayConfigSchema>
/**
* Default configuration.

View File

@@ -17,6 +17,7 @@ import { Chat, ConfirmDialog, Input, StatusBar } from "./components/index.js"
import { type CommandResult, useCommands, useHotkeys, useSession } from "./hooks/index.js"
import type { AppProps, BranchInfo } from "./types.js"
import type { ConfirmChoice } from "../shared/types/index.js"
import { ringBell } from "./utils/bell.js"
export interface AppDependencies {
storage: IStorage
@@ -31,6 +32,10 @@ export interface ExtendedAppProps extends AppProps {
onExit?: () => void
multiline?: boolean | "auto"
syntaxHighlight?: boolean
theme?: "dark" | "light"
showStats?: boolean
showToolCalls?: boolean
bellOnComplete?: boolean
}
function LoadingScreen(): React.JSX.Element {
@@ -69,6 +74,10 @@ export function App({
onExit,
multiline = false,
syntaxHighlight = true,
theme = "dark",
showStats = true,
showToolCalls = true,
bellOnComplete = false,
}: ExtendedAppProps): React.JSX.Element {
const { exit } = useApp()
@@ -193,6 +202,12 @@ export function App({
}
}, [session])
useEffect(() => {
if (bellOnComplete && status === "ready") {
ringBell()
}
}, [bellOnComplete, status])
const handleSubmit = useCallback(
(text: string): void => {
if (isCommand(text)) {
@@ -228,8 +243,15 @@ export function App({
branch={branch}
sessionTime={sessionTime}
status={status}
theme={theme}
/>
<Chat
messages={messages}
isThinking={status === "thinking"}
theme={theme}
showStats={showStats}
showToolCalls={showToolCalls}
/>
<Chat messages={messages} isThinking={status === "thinking"} />
{commandResult && (
<Box
borderStyle="round"

View File

@@ -7,10 +7,14 @@ import { Box, Text } from "ink"
import type React from "react"
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
import type { ToolCall } from "../../domain/value-objects/ToolCall.js"
import { getRoleColor, type Theme } from "../utils/theme.js"
export interface ChatProps {
messages: ChatMessage[]
isThinking: boolean
theme?: Theme
showStats?: boolean
showToolCalls?: boolean
}
function formatTimestamp(timestamp: number): string {
@@ -42,11 +46,20 @@ function formatToolCall(call: ToolCall): string {
return `[${call.name} ${params}]`
}
function UserMessage({ message }: { message: ChatMessage }): React.JSX.Element {
interface MessageComponentProps {
message: ChatMessage
theme: Theme
showStats: boolean
showToolCalls: boolean
}
function UserMessage({ message, theme }: MessageComponentProps): React.JSX.Element {
const roleColor = getRoleColor("user", theme)
return (
<Box flexDirection="column" marginBottom={1}>
<Box gap={1}>
<Text color="green" bold>
<Text color={roleColor} bold>
You
</Text>
<Text color="gray" dimColor>
@@ -60,13 +73,19 @@ function UserMessage({ message }: { message: ChatMessage }): React.JSX.Element {
)
}
function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Element {
function AssistantMessage({
message,
theme,
showStats,
showToolCalls,
}: MessageComponentProps): React.JSX.Element {
const stats = formatStats(message.stats)
const roleColor = getRoleColor("assistant", theme)
return (
<Box flexDirection="column" marginBottom={1}>
<Box gap={1}>
<Text color="cyan" bold>
<Text color={roleColor} bold>
Assistant
</Text>
<Text color="gray" dimColor>
@@ -74,7 +93,7 @@ function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Elem
</Text>
</Box>
{message.toolCalls && message.toolCalls.length > 0 && (
{showToolCalls && message.toolCalls && message.toolCalls.length > 0 && (
<Box flexDirection="column" marginLeft={2} marginBottom={1}>
{message.toolCalls.map((call) => (
<Text key={call.id} color="yellow">
@@ -90,7 +109,7 @@ function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Elem
</Box>
)}
{stats && (
{showStats && stats && (
<Box marginLeft={2} marginTop={1}>
<Text color="gray" dimColor>
{stats}
@@ -101,7 +120,9 @@ function AssistantMessage({ message }: { message: ChatMessage }): React.JSX.Elem
)
}
function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
function ToolMessage({ message, theme }: MessageComponentProps): React.JSX.Element {
const roleColor = getRoleColor("tool", theme)
return (
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
{message.toolResults?.map((result) => (
@@ -115,31 +136,39 @@ function ToolMessage({ message }: { message: ChatMessage }): React.JSX.Element {
)
}
function SystemMessage({ message }: { message: ChatMessage }): React.JSX.Element {
function SystemMessage({ message, theme }: MessageComponentProps): React.JSX.Element {
const isError = message.content.toLowerCase().startsWith("error")
const roleColor = getRoleColor("system", theme)
return (
<Box marginBottom={1} marginLeft={2}>
<Text color={isError ? "red" : "gray"} dimColor={!isError}>
<Text color={isError ? "red" : roleColor} dimColor={!isError}>
{message.content}
</Text>
</Box>
)
}
function MessageComponent({ message }: { message: ChatMessage }): React.JSX.Element {
function MessageComponent({
message,
theme,
showStats,
showToolCalls,
}: MessageComponentProps): React.JSX.Element {
const props = { message, theme, showStats, showToolCalls }
switch (message.role) {
case "user": {
return <UserMessage message={message} />
return <UserMessage {...props} />
}
case "assistant": {
return <AssistantMessage message={message} />
return <AssistantMessage {...props} />
}
case "tool": {
return <ToolMessage message={message} />
return <ToolMessage {...props} />
}
case "system": {
return <SystemMessage message={message} />
return <SystemMessage {...props} />
}
default: {
return <></>
@@ -147,24 +176,35 @@ function MessageComponent({ message }: { message: ChatMessage }): React.JSX.Elem
}
}
function ThinkingIndicator(): React.JSX.Element {
function ThinkingIndicator({ theme }: { theme: Theme }): React.JSX.Element {
const color = getRoleColor("assistant", theme)
return (
<Box marginBottom={1}>
<Text color="yellow">Thinking...</Text>
<Text color={color}>Thinking...</Text>
</Box>
)
}
export function Chat({ messages, isThinking }: ChatProps): React.JSX.Element {
export function Chat({
messages,
isThinking,
theme = "dark",
showStats = true,
showToolCalls = true,
}: ChatProps): React.JSX.Element {
return (
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{messages.map((message, index) => (
<MessageComponent
key={`${String(message.timestamp)}-${String(index)}`}
message={message}
theme={theme}
showStats={showStats}
showToolCalls={showToolCalls}
/>
))}
{isThinking && <ThinkingIndicator />}
{isThinking && <ThinkingIndicator theme={theme} />}
</Box>
)
}

View File

@@ -6,6 +6,7 @@
import { Box, Text } from "ink"
import type React from "react"
import type { BranchInfo, TuiStatus } from "../types.js"
import { getContextColor, getStatusColor, type Theme } from "../utils/theme.js"
export interface StatusBarProps {
contextUsage: number
@@ -13,27 +14,30 @@ export interface StatusBarProps {
branch: BranchInfo
sessionTime: string
status: TuiStatus
theme?: Theme
}
function getStatusIndicator(status: TuiStatus): { text: string; color: string } {
function getStatusIndicator(status: TuiStatus, theme: Theme): { text: string; color: string } {
const color = getStatusColor(status, theme)
switch (status) {
case "ready": {
return { text: "ready", color: "green" }
return { text: "ready", color }
}
case "thinking": {
return { text: "thinking...", color: "yellow" }
return { text: "thinking...", color }
}
case "tool_call": {
return { text: "executing...", color: "cyan" }
return { text: "executing...", color }
}
case "awaiting_confirmation": {
return { text: "confirm?", color: "magenta" }
return { text: "confirm?", color }
}
case "error": {
return { text: "error", color: "red" }
return { text: "error", color }
}
default: {
return { text: "ready", color: "green" }
return { text: "ready", color }
}
}
}
@@ -48,9 +52,11 @@ export function StatusBar({
branch,
sessionTime,
status,
theme = "dark",
}: StatusBarProps): React.JSX.Element {
const statusIndicator = getStatusIndicator(status)
const statusIndicator = getStatusIndicator(status, theme)
const branchDisplay = branch.isDetached ? `HEAD@${branch.name.slice(0, 7)}` : branch.name
const contextColor = getContextColor(contextUsage, theme)
return (
<Box borderStyle="single" borderColor="gray" paddingX={1} justifyContent="space-between">
@@ -59,11 +65,7 @@ export function StatusBar({
[ipuaro]
</Text>
<Text color="gray">
[ctx:{" "}
<Text color={contextUsage > 0.8 ? "red" : "white"}>
{formatContextUsage(contextUsage)}
</Text>
]
[ctx: <Text color={contextColor}>{formatContextUsage(contextUsage)}</Text>]
</Text>
<Text color="gray">
[<Text color="blue">{projectName}</Text>]

View File

@@ -0,0 +1,11 @@
/**
* Bell notification utility for terminal.
*/
/**
* Ring the terminal bell.
* Works by outputting the ASCII bell character (\u0007).
*/
export function ringBell(): void {
process.stdout.write("\u0007")
}

View File

@@ -0,0 +1,115 @@
/**
* Theme color utilities for TUI.
*/
export type Theme = "dark" | "light"
/**
* Color scheme for a theme.
*/
export interface ColorScheme {
primary: string
secondary: string
success: string
warning: string
error: string
info: string
muted: string
background: string
foreground: string
}
/**
* Dark theme color scheme (default).
*/
const DARK_THEME: ColorScheme = {
primary: "cyan",
secondary: "blue",
success: "green",
warning: "yellow",
error: "red",
info: "cyan",
muted: "gray",
background: "black",
foreground: "white",
}
/**
* Light theme color scheme.
*/
const LIGHT_THEME: ColorScheme = {
primary: "blue",
secondary: "cyan",
success: "green",
warning: "yellow",
error: "red",
info: "blue",
muted: "gray",
background: "white",
foreground: "black",
}
/**
* Get color scheme for a theme.
*/
export function getColorScheme(theme: Theme): ColorScheme {
return theme === "dark" ? DARK_THEME : LIGHT_THEME
}
/**
* Get color for a status.
*/
export function getStatusColor(
status: "ready" | "thinking" | "error" | "tool_call" | "awaiting_confirmation",
theme: Theme = "dark",
): string {
const scheme = getColorScheme(theme)
switch (status) {
case "ready":
return scheme.success
case "thinking":
case "tool_call":
return scheme.warning
case "awaiting_confirmation":
return scheme.info
case "error":
return scheme.error
}
}
/**
* Get color for a message role.
*/
export function getRoleColor(
role: "user" | "assistant" | "system" | "tool",
theme: Theme = "dark",
): string {
const scheme = getColorScheme(theme)
switch (role) {
case "user":
return scheme.success
case "assistant":
return scheme.primary
case "system":
return scheme.muted
case "tool":
return scheme.secondary
}
}
/**
* Get color for context usage percentage.
*/
export function getContextColor(usage: number, theme: Theme = "dark"): string {
const scheme = getColorScheme(theme)
if (usage >= 0.8) {
return scheme.error
}
if (usage >= 0.6) {
return scheme.warning
}
return scheme.success
}