KitlenoKitlenov2.0.0

Forms & Input / AI Chat Input

AI Chat Input

A minimalist chat input built for AI tools. Typing "/" opens a searchable command menu that can be navigated with the keyboard, keeping quick actions one keystroke away.

AI Chat Input

A minimalist chat input built for AI tools. Typing "/" opens a searchable command menu that can be navigated with the keyboard.

No matching commands

AiChatInput.jsx
'use client'
import { useEffect, useRef, useState } from 'react'
import { ArrowUp, Code2, FileText, Languages, Lightbulb, Sparkles, SpellCheck2 } from 'lucide-react'

// Add or edit your slash commands here
const COMMANDS = [
  { id: 'summarize', label: 'Summarize', description: 'Condense a long piece of text', icon: FileText },
  { id: 'translate', label: 'Translate', description: 'Convert text into another language', icon: Languages },
  { id: 'explain', label: 'Explain', description: 'Break a concept down into simple terms', icon: Lightbulb },
  { id: 'code', label: 'Write Code', description: 'Generate or debug a snippet of code', icon: Code2 },
  { id: 'brainstorm', label: 'Brainstorm', description: 'Generate ideas around a topic', icon: Sparkles },
  { id: 'fix-grammar', label: 'Fix Grammar', description: 'Clean up grammar and tone', icon: SpellCheck2 },
]

export default function AiChatInput() {
  const [value, setValue] = useState('')
  const [highlightedIndex, setHighlightedIndex] = useState(0)
  const [forceClosed, setForceClosed] = useState(false)
  const textareaRef = useRef(null)
  const wrapRef = useRef(null)

  const isMenuOpen = value.startsWith('/') && !value.includes(' ') && !forceClosed
  const query = value.slice(1).toLowerCase()
  const filteredCommands = isMenuOpen
    ? COMMANDS.filter((cmd) => cmd.id.includes(query) || cmd.label.toLowerCase().includes(query))
    : []

  useEffect(() => {
    setHighlightedIndex(0)
  }, [value])

  useEffect(() => {
    const textarea = textareaRef.current
    if (!textarea) return
    textarea.style.height = 'auto'
    textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px'
  }, [value])

  useEffect(() => {
    if (!isMenuOpen) return
    const handlePointerDown = (e) => {
      if (wrapRef.current && !wrapRef.current.contains(e.target)) {
        setForceClosed(true)
      }
    }
    document.addEventListener('mousedown', handlePointerDown)
    return () => document.removeEventListener('mousedown', handlePointerDown)
  }, [isMenuOpen])

  const selectCommand = (command) => {
    setValue('/' + command.id + ' ')
    setForceClosed(true)
    textareaRef.current?.focus()
  }

  const handleChange = (e) => {
    setValue(e.target.value)
    setForceClosed(false)
  }

  const handleKeyDown = (e) => {
    if (!isMenuOpen) return
    if (e.key === 'ArrowDown') {
      e.preventDefault()
      setHighlightedIndex((i) => (filteredCommands.length ? (i + 1) % filteredCommands.length : 0))
    } else if (e.key === 'ArrowUp') {
      e.preventDefault()
      setHighlightedIndex((i) => (filteredCommands.length ? (i - 1 + filteredCommands.length) % filteredCommands.length : 0))
    } else if (e.key === 'Enter') {
      if (!filteredCommands.length) return
      e.preventDefault()
      selectCommand(filteredCommands[highlightedIndex])
    } else if (e.key === 'Escape') {
      e.preventDefault()
      setForceClosed(true)
    }
  }

  const canSend = value.trim().length > 0

  return (
    <div ref={wrapRef} className="relative w-full max-w-xl mx-auto">
      {/* Slash-command menu */}
      <div
        className={`absolute bottom-full left-0 right-0 mb-2 grid transition-all duration-200 ease-out ${
          isMenuOpen ? 'grid-rows-[1fr] opacity-100 translate-y-0' : 'grid-rows-[0fr] opacity-0 translate-y-1 pointer-events-none'
        }`}
      >
        <div className="overflow-hidden rounded-2xl border border-[#E5E5E5] dark:border-[#3A3A3A] bg-white/95 dark:bg-[#161616]/95 backdrop-blur-xl shadow-[0_20px_44px_-24px_rgba(0,0,0,0.35)]">
          <div className="p-1.5" role="listbox">
            {filteredCommands.length === 0 ? (
              <p className="px-3 py-3 text-[13px] text-[#6e6e73] dark:text-[#a1a1a6]">No matching commands</p>
            ) : (
              filteredCommands.map((command, index) => {
                const Icon = command.icon
                const isHighlighted = index === highlightedIndex
                return (
                  <button
                    key={command.id}
                    type="button"
                    role="option"
                    aria-selected={isHighlighted}
                    onMouseDown={(e) => e.preventDefault()}
                    onClick={() => selectCommand(command)}
                    onMouseEnter={() => setHighlightedIndex(index)}
                    className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left cursor-pointer transition-colors duration-100 ${
                      isHighlighted ? 'bg-[#7e60d6]/10 dark:bg-[#a98cf3]/10' : ''
                    }`}
                  >
                    <span className="flex items-center justify-center w-8 h-8 rounded-lg shrink-0 bg-[#7e60d6]/10 dark:bg-[#a98cf3]/10 text-[#7e60d6] dark:text-[#a98cf3]">
                      <Icon size={16} strokeWidth={1.8} />
                    </span>
                    <span className="min-w-0">
                      <span className="block text-[13.5px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7]">
                        {command.label}
                      </span>
                      <span className="block text-[12px] text-[#6e6e73] dark:text-[#a1a1a6] truncate">
                        {command.description}
                      </span>
                    </span>
                  </button>
                )
              })
            )}
          </div>
        </div>
      </div>

      {/* Input row */}
      <div className="flex items-center gap-2 rounded-2xl border border-[#E5E5E5] dark:border-[#3A3A3A] bg-white/70 dark:bg-[#161616]/70 backdrop-blur-xl px-4 py-3 transition-colors duration-150 focus-within:border-[#7e60d6] dark:focus-within:border-[#a98cf3]">
        <textarea
          ref={textareaRef}
          value={value}
          onChange={handleChange}
          onKeyDown={handleKeyDown}
          rows={1}
          placeholder="Ask anything, or type / for commands"
          className="flex-1 resize-none bg-transparent outline-none text-[14.5px] leading-relaxed text-[#1d1d1f] dark:text-[#f5f5f7] placeholder:text-[#6e6e73] dark:placeholder:text-[#a1a1a6] max-h-40"
        />
        <button
          type="button"
          disabled={!canSend}
          className={`flex items-center justify-center w-8 h-8 rounded-full shrink-0 transition-all duration-150 ${
            canSend
              ? 'bg-[#7e60d6] text-white cursor-pointer hover:bg-[#6d4fc4]'
              : 'bg-[#E5E5E5] dark:bg-[#3A3A3A] text-[#a1a1a6] cursor-not-allowed'
          }`}
        >
          <ArrowUp size={16} strokeWidth={2.2} />
        </button>
      </div>
    </div>
  )
}

AI Chat Input — Mono

A monochrome chat input for AI tools. The "/" command menu shows just an icon and a name for each choice, using only light and dark mode colors.

No matching commands

AiChatInputMono.jsx
'use client'
import { useEffect, useRef, useState } from 'react'
import { ArrowUp, Code2, FileText, Languages, Lightbulb, Sparkles, SpellCheck2 } from 'lucide-react'

// Add or edit your slash commands here
const COMMANDS = [
  { id: 'summarize', label: 'Summarize', icon: FileText },
  { id: 'translate', label: 'Translate', icon: Languages },
  { id: 'explain', label: 'Explain', icon: Lightbulb },
  { id: 'code', label: 'Write Code', icon: Code2 },
  { id: 'brainstorm', label: 'Brainstorm', icon: Sparkles },
  { id: 'fix-grammar', label: 'Fix Grammar', icon: SpellCheck2 },
]

export default function AiChatInputMono() {
  const [value, setValue] = useState('')
  const [highlightedIndex, setHighlightedIndex] = useState(0)
  const [forceClosed, setForceClosed] = useState(false)
  const textareaRef = useRef(null)
  const wrapRef = useRef(null)

  const isMenuOpen = value.startsWith('/') && !value.includes(' ') && !forceClosed
  const query = value.slice(1).toLowerCase()
  const filteredCommands = isMenuOpen
    ? COMMANDS.filter((cmd) => cmd.id.includes(query) || cmd.label.toLowerCase().includes(query))
    : []

  useEffect(() => {
    setHighlightedIndex(0)
  }, [value])

  useEffect(() => {
    const textarea = textareaRef.current
    if (!textarea) return
    textarea.style.height = 'auto'
    textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px'
  }, [value])

  useEffect(() => {
    if (!isMenuOpen) return
    const handlePointerDown = (e) => {
      if (wrapRef.current && !wrapRef.current.contains(e.target)) {
        setForceClosed(true)
      }
    }
    document.addEventListener('mousedown', handlePointerDown)
    return () => document.removeEventListener('mousedown', handlePointerDown)
  }, [isMenuOpen])

  const selectCommand = (command) => {
    setValue('/' + command.id + ' ')
    setForceClosed(true)
    textareaRef.current?.focus()
  }

  const handleChange = (e) => {
    setValue(e.target.value)
    setForceClosed(false)
  }

  const handleKeyDown = (e) => {
    if (!isMenuOpen) return
    if (e.key === 'ArrowDown') {
      e.preventDefault()
      setHighlightedIndex((i) => (filteredCommands.length ? (i + 1) % filteredCommands.length : 0))
    } else if (e.key === 'ArrowUp') {
      e.preventDefault()
      setHighlightedIndex((i) => (filteredCommands.length ? (i - 1 + filteredCommands.length) % filteredCommands.length : 0))
    } else if (e.key === 'Enter') {
      if (!filteredCommands.length) return
      e.preventDefault()
      selectCommand(filteredCommands[highlightedIndex])
    } else if (e.key === 'Escape') {
      e.preventDefault()
      setForceClosed(true)
    }
  }

  const canSend = value.trim().length > 0

  return (
    <div ref={wrapRef} className="relative w-full max-w-xl mx-auto">
      {/* Slash-command menu */}
      <div
        className={`absolute bottom-full left-0 right-0 mb-2 grid transition-all duration-200 ease-out ${
          isMenuOpen ? 'grid-rows-[1fr] opacity-100 translate-y-0' : 'grid-rows-[0fr] opacity-0 translate-y-1 pointer-events-none'
        }`}
      >
        <div className="overflow-hidden rounded-2xl border border-[#E5E5E5] dark:border-[#3A3A3A] bg-white/95 dark:bg-[#161616]/95 backdrop-blur-xl shadow-[0_20px_44px_-24px_rgba(0,0,0,0.35)]">
          <div className="p-1.5" role="listbox">
            {filteredCommands.length === 0 ? (
              <p className="px-3 py-3 text-[13px] text-[#6e6e73] dark:text-[#a1a1a6]">No matching commands</p>
            ) : (
              filteredCommands.map((command, index) => {
                const Icon = command.icon
                const isHighlighted = index === highlightedIndex
                return (
                  <button
                    key={command.id}
                    type="button"
                    role="option"
                    aria-selected={isHighlighted}
                    onMouseDown={(e) => e.preventDefault()}
                    onClick={() => selectCommand(command)}
                    onMouseEnter={() => setHighlightedIndex(index)}
                    className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left cursor-pointer transition-colors duration-100 ${
                      isHighlighted ? 'bg-black/5 dark:bg-white/10' : ''
                    }`}
                  >
                    <span className="flex items-center justify-center w-8 h-8 rounded-lg shrink-0 bg-black/5 dark:bg-white/10 text-[#1d1d1f] dark:text-[#f5f5f7]">
                      <Icon size={16} strokeWidth={1.8} />
                    </span>
                    <span className="text-[13.5px] font-medium text-[#1d1d1f] dark:text-[#f5f5f7]">
                      {command.label}
                    </span>
                  </button>
                )
              })
            )}
          </div>
        </div>
      </div>

      {/* Input row */}
      <div className="flex items-center gap-2 rounded-2xl border border-[#E5E5E5] dark:border-[#3A3A3A] bg-white/70 dark:bg-[#161616]/70 backdrop-blur-xl px-4 py-3 transition-colors duration-150 focus-within:border-[#1d1d1f] dark:focus-within:border-[#f5f5f7]">
        <textarea
          ref={textareaRef}
          value={value}
          onChange={handleChange}
          onKeyDown={handleKeyDown}
          rows={1}
          placeholder="Ask anything, or type / for commands"
          className="flex-1 resize-none bg-transparent outline-none text-[14.5px] leading-relaxed text-[#1d1d1f] dark:text-[#f5f5f7] placeholder:text-[#6e6e73] dark:placeholder:text-[#a1a1a6] max-h-40"
        />
        <button
          type="button"
          disabled={!canSend}
          className={`flex items-center justify-center w-8 h-8 rounded-full shrink-0 transition-all duration-150 ${
            canSend
              ? 'bg-[#1d1d1f] text-white cursor-pointer hover:bg-[#3a3a3a] dark:bg-[#f5f5f7] dark:text-[#1d1d1f] dark:hover:bg-[#e5e5e5]'
              : 'bg-[#E5E5E5] dark:bg-[#3A3A3A] text-[#a1a1a6] cursor-not-allowed'
          }`}
        >
          <ArrowUp size={16} strokeWidth={2.2} />
        </button>
      </div>
    </div>
  )
}