{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/log",
  "title": "Log",
  "description": "Scrolling log viewer with severity levels and filtering",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/log.tsx",
      "content": "import { Box, Text } from \"ink\";\nimport React, { useState, useMemo, useEffect } from \"react\";\n\nimport { useInteraction } from \"@/hooks/use-interaction\";\nimport type { InteractionProps } from \"@/hooks/use-interaction\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { useUnicode } from \"@/hooks/use-unicode\";\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nexport interface LogEntry {\n  level: LogLevel;\n  message: string;\n  timestamp?: Date;\n}\n\nexport interface LogProps extends InteractionProps {\n  entries: LogEntry[];\n  height?: number;\n  showTimestamp?: boolean;\n  filter?: string;\n  follow?: boolean;\n  \"aria-label\"?: string;\n}\n\nconst LEVEL_COLORS: Record<LogLevel, string> = {\n  debug: \"gray\",\n  error: \"red\",\n  info: \"cyan\",\n  warn: \"yellow\",\n};\n\nconst LEVEL_LABELS: Record<LogLevel, string> = {\n  debug: \"DBG\",\n  error: \"ERR\",\n  info: \"INF\",\n  warn: \"WRN\",\n};\n\nconst formatTimestamp = (date: Date): string => {\n  const h = String(date.getHours()).padStart(2, \"0\");\n  const m = String(date.getMinutes()).padStart(2, \"0\");\n  const s = String(date.getSeconds()).padStart(2, \"0\");\n  return `${h}:${m}:${s}`;\n};\n\nexport const Log = ({\n  entries,\n  height = 10,\n  showTimestamp = true,\n  filter,\n  follow: followProp = false,\n  id,\n  autoFocus,\n  isActive,\n  disabled,\n  \"aria-label\": ariaLabel = \"Application log\",\n}: LogProps) => {\n  const theme = useTheme();\n  const unicode = useUnicode();\n  const [scrollOffset, setScrollOffset] = useState(0);\n  const [follow, setFollow] = useState(followProp);\n\n  const filtered = useMemo(() => {\n    if (!filter) {\n      return entries;\n    }\n    const lower = filter.toLowerCase();\n    return entries.filter(\n      (e) =>\n        e.message.toLowerCase().includes(lower) ||\n        e.level.toLowerCase().includes(lower)\n    );\n  }, [entries, filter]);\n\n  const maxOffset = Math.max(0, filtered.length - height);\n\n  useEffect(() => {\n    if (follow) {\n      setScrollOffset(maxOffset);\n    }\n  }, [follow, maxOffset]);\n\n  const { isFocused } = useInteraction(\n    (input, key) => {\n      if (input === \"j\" || key.downArrow) {\n        setScrollOffset((o) => Math.min(maxOffset, o + 1));\n        if (follow) {\n          setFollow(false);\n        }\n      } else if (input === \"k\" || key.upArrow) {\n        setScrollOffset((o) => Math.max(0, o - 1));\n        if (follow) {\n          setFollow(false);\n        }\n      } else if (input === \"f\") {\n        setFollow((f) => !f);\n      } else if (key.home) {\n        setScrollOffset(0);\n        setFollow(false);\n      } else if (key.end) {\n        setScrollOffset(maxOffset);\n      } else if (key.pageUp) {\n        setScrollOffset((offset) => Math.max(0, offset - height));\n      } else if (key.pageDown) {\n        setScrollOffset((offset) => Math.min(maxOffset, offset + height));\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  const visible = filtered.slice(scrollOffset, scrollOffset + height);\n\n  return (\n    <Box\n      flexDirection=\"column\"\n      gap={0}\n      aria-role=\"list\"\n      aria-state={{ disabled: disabled || undefined }}\n    >\n      <Text\n        aria-label={`${ariaLabel}. Showing ${visible.length} of ${filtered.length} entries.${isFocused ? \" Focused.\" : \"\"}`}\n      >\n        {\"\"}\n      </Text>\n      <Box flexDirection=\"column\" height={height}>\n        {visible.map((entry, i) => {\n          const levelColor = LEVEL_COLORS[entry.level];\n          const levelLabel = LEVEL_LABELS[entry.level];\n          let messageColor: string;\n          if (entry.level === \"error\") {\n            messageColor = \"red\";\n          } else if (entry.level === \"warn\") {\n            messageColor = \"yellow\";\n          } else {\n            messageColor = theme.colors.foreground;\n          }\n          return (\n            <Box\n              key={`${entry.timestamp?.getTime() ?? scrollOffset + i}-${i}`}\n              gap={1}\n              aria-role=\"listitem\"\n              aria-label={`${entry.level}${entry.timestamp ? ` at ${formatTimestamp(entry.timestamp)}` : \"\"}: ${entry.message}`}\n            >\n              {showTimestamp && entry.timestamp && (\n                <Text color={theme.colors.mutedForeground} dimColor>\n                  {formatTimestamp(entry.timestamp)}\n                </Text>\n              )}\n              <Text color={levelColor} bold>\n                {levelLabel}\n              </Text>\n              <Text color={messageColor}>{entry.message}</Text>\n            </Box>\n          );\n        })}\n      </Box>\n      <Box aria-hidden gap={2} marginTop={0}>\n        <Text color={theme.colors.mutedForeground} dimColor>\n          {scrollOffset + 1}\n          {unicode ? \"–\" : \"-\"}\n          {Math.min(scrollOffset + height, filtered.length)}/{filtered.length}\n        </Text>\n        <Text\n          color={follow ? theme.colors.success : theme.colors.mutedForeground}\n          dimColor\n        >\n          {follow ? (unicode ? \"↓ follow\" : \"down follow\") : \"f follow\"}\n        </Text>\n        <Text color={theme.colors.mutedForeground} dimColor>\n          j/k scroll\n        </Text>\n        {filter && (\n          <Text color={theme.colors.mutedForeground} dimColor>\n            filter: {filter}\n          </Text>\n        )}\n      </Box>\n    </Box>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "utility"
  ],
  "type": "registry:ui"
}