{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/app-shell",
  "title": "App Shell",
  "description": "Full-screen TUI layout with header, tip bar, full-width input, scrollable content, and hints footer",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/terminal-style.json",
    "https://termcn.dev/r/ink/terminal-text.json",
    "https://termcn.dev/r/ink/types.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/app-shell.tsx",
      "content": "import { useCursor, useStdout, Box, Text } from \"ink\";\nimport React, { useEffect, useState } from \"react\";\nimport type { ReactNode } 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\";\nimport { resolveBorderStyle } from \"@/lib/terminal-style\";\nimport {\n  graphemeLength,\n  removeGraphemeBefore,\n  terminalWidth,\n} from \"@/lib/terminal-text\";\nimport type { BorderStyle } from \"@/components/ui/types\";\n\nexport interface AppShellProps {\n  children: ReactNode;\n  fullscreen?: boolean;\n}\n\nexport interface AppShellHeaderProps {\n  children: ReactNode;\n}\n\nexport interface AppShellTipProps {\n  children: ReactNode;\n}\n\nexport interface AppShellInputProps extends InteractionProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  onChange?: (value: string) => void;\n  onSubmit?: (value: string) => void;\n  placeholder?: string;\n  borderStyle?: BorderStyle;\n  borderColor?: string;\n  prefix?: string;\n  readOnly?: boolean;\n  required?: boolean;\n  cursorOrigin?: { x: number; y: number };\n  \"aria-label\"?: string;\n}\n\nexport interface AppShellContentProps extends InteractionProps {\n  children: ReactNode;\n  autoscroll?: boolean;\n  height?: number;\n  \"aria-label\"?: string;\n}\n\nexport interface AppShellHintsProps {\n  items?: string[];\n  children?: ReactNode;\n}\n\nconst AppShellRoot = ({ children }: AppShellProps) => {\n  const { stdout } = useStdout();\n  const [terminalColumns, setTerminalColumns] = useState(stdout.columns);\n\n  useEffect(() => {\n    const updateColumns = () => setTerminalColumns(stdout.columns);\n\n    stdout.on(\"resize\", updateColumns);\n    return () => {\n      stdout.off(\"resize\", updateColumns);\n    };\n  }, [stdout]);\n\n  return (\n    <Box\n      flexDirection=\"column\"\n      flexGrow={1}\n      width={terminalColumns ?? \"100%\"}\n      aria-role=\"list\"\n    >\n      {children}\n    </Box>\n  );\n};\n\nconst AppShellHeader = ({ children }: AppShellHeaderProps) => (\n  <Box flexDirection=\"column\">{children}</Box>\n);\n\nconst AppShellTip = ({ children }: AppShellTipProps) => (\n  <Box paddingLeft={2} paddingY={0} width=\"100%\">\n    <Text dimColor wrap=\"wrap\">\n      {\"Tip: \"}\n      {children}\n    </Text>\n  </Box>\n);\n\nconst AppShellInput = ({\n  value: controlledValue,\n  defaultValue = \"\",\n  onValueChange,\n  onChange,\n  onSubmit,\n  placeholder = \"Type something...\",\n  borderStyle = \"single\",\n  borderColor,\n  prefix = \">\",\n  readOnly,\n  required,\n  cursorOrigin,\n  id,\n  autoFocus,\n  isActive,\n  disabled,\n  \"aria-label\": ariaLabel = \"Command input\",\n}: AppShellInputProps) => {\n  const unicode = useUnicode();\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const theme = useTheme();\n  const { setCursorPosition } = useCursor();\n  const value = controlledValue ?? internalValue;\n\n  const updateValue = (next: string) => {\n    if (controlledValue === undefined) {\n      setInternalValue(next);\n    }\n    onValueChange?.(next);\n    onChange?.(next);\n  };\n\n  const { isFocused } = useInteraction(\n    (input, key) => {\n      if (key.return) {\n        onSubmit?.(value);\n        if (controlledValue === undefined) {\n          updateValue(\"\");\n        }\n        return;\n      }\n      if (readOnly) {\n        return;\n      }\n      if (key.backspace || key.delete) {\n        updateValue(removeGraphemeBefore(value, graphemeLength(value)).value);\n        return;\n      }\n      if (key.escape || key.upArrow || key.downArrow || key.tab) {\n        return;\n      }\n      if (input && !key.ctrl && !key.meta) {\n        updateValue(value + input);\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  React.useEffect(() => {\n    if (isFocused && cursorOrigin) {\n      setCursorPosition({\n        x:\n          cursorOrigin.x +\n          terminalWidth(prefix ? `${prefix} ` : \"\") +\n          terminalWidth(value),\n        y: cursorOrigin.y,\n      });\n    } else {\n      setCursorPosition(undefined);\n    }\n    return () => setCursorPosition(undefined);\n  }, [cursorOrigin, isFocused, prefix, setCursorPosition, value]);\n\n  return (\n    <Box\n      borderStyle={resolveBorderStyle(borderStyle, unicode)}\n      borderColor={borderColor ?? theme.colors.border}\n      flexDirection=\"row\"\n      paddingX={1}\n      aria-role=\"textbox\"\n      aria-label={ariaLabel}\n      aria-state={{\n        disabled: disabled || undefined,\n        readonly: readOnly || undefined,\n        required: required || undefined,\n      }}\n    >\n      {prefix && (\n        <Text aria-hidden color={theme.colors.primary} bold>\n          {`${prefix} `}\n        </Text>\n      )}\n      <Text>{value || <Text dimColor>{placeholder}</Text>}</Text>\n      {isFocused && !cursorOrigin && (\n        <Text aria-hidden color={theme.colors.focusRing}>\n          {unicode ? \"█\" : \"|\"}\n        </Text>\n      )}\n    </Box>\n  );\n};\n\nconst AppShellContent = ({\n  children,\n  height = 20,\n  id,\n  autoFocus,\n  isActive,\n  disabled,\n  \"aria-label\": ariaLabel = \"Application content\",\n}: AppShellContentProps) => {\n  const [scrollTop, setScrollTop] = useState(0);\n\n  const { isFocused } = useInteraction(\n    (_input, key) => {\n      if (key.upArrow) {\n        setScrollTop((s) => Math.max(0, s - 1));\n      } else if (key.downArrow) {\n        setScrollTop((s) => s + 1);\n      } else if (key.home) {\n        setScrollTop(0);\n      } else if (key.pageUp) {\n        setScrollTop((value) => Math.max(0, value - height));\n      } else if (key.pageDown) {\n        setScrollTop((value) => value + height);\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  return (\n    <Box\n      flexDirection=\"row\"\n      height={height}\n      overflow=\"hidden\"\n      aria-role=\"list\"\n      aria-state={{ disabled: disabled || undefined }}\n    >\n      <Text\n        aria-label={`${ariaLabel}. Offset ${scrollTop}.${isFocused ? \" Focused.\" : \"\"}`}\n      >\n        {\"\"}\n      </Text>\n      <Box flexGrow={1} flexDirection=\"column\" marginTop={-scrollTop as number}>\n        {children}\n      </Box>\n    </Box>\n  );\n};\n\nconst AppShellHints = ({ items, children }: AppShellHintsProps) => {\n  const theme = useTheme();\n  const content = items ? items.join(\" | \") : children;\n  return (\n    <Box paddingX={1} width=\"100%\">\n      <Text dimColor color={theme.colors.mutedForeground} wrap=\"wrap\">\n        {content as string}\n      </Text>\n    </Box>\n  );\n};\n\nexport const AppShell = Object.assign(AppShellRoot, {\n  Content: AppShellContent,\n  Header: AppShellHeader,\n  Hints: AppShellHints,\n  Input: AppShellInput,\n  Tip: AppShellTip,\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "templates"
  ],
  "type": "registry:ui"
}