GenCN UI

Input

GencnUI-powered input component with automatic text rewriting and tone customization.

Try out the component below to see how it works in practice. You can interact with the input field and explore its AI-powered features.

Loading preview...
"use client";

import { useState } from "react";
import { GencnUIInput } from "@/registry/new-york/gencn-ui/items/input/gencn-ui-input";
import { Label } from "@/components/ui/label";

export function GencnUIInputExample() {
  const [inputValue, setInputValue] = useState("how was your recent trip to");
  return (
    <>
      <div className="space-y-4">
        <div className="space-y-2">
          <Label htmlFor="example-input" className="text-sm font-medium">
            AI-Powered Input{" "}
          </Label>
          <GencnUIInput
            id="example-input"
            placeholder="Type something and use AI to improve, translate, or rewrite it..."
            features={[
              "compose",
              "improve",
              "fix-grammar",
              "translate",
              "inline-suggest",
            ]}
            translateTargets={["en", "fr", "es", "de", "hi", "ja", "zh-CN"]}
            translateLanguageMap={{
              en: "English",
              fr: "French",
              es: "Spanish",
              de: "German",
              hi: "Hindi",
              ja: "Japanese",
              "zh-CN": "Chinese (Simplified)",
            }}
            value={inputValue}
            onAccept={(text) => setInputValue(text)}
            onChange={(e) => setInputValue(e.target.value)}
            className="w-full"
          />
        </div>
        <p className="text-muted-foreground text-sm">
          Click the AI button to access writing assistance features.
        </p>
      </div>
    </>
  );
}

Server API

Path:
/api/compose
Source:
import { google, createGoogleGenerativeAI } from "@ai-sdk/google";
import { streamText } from "ai";

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

interface ComposeRequest {
  prompt?: string;
  text?: string;
  context?: string;
  options?: {
    tone?: string;
    format?: string;
    length?: string;
  };
  streaming?: boolean;
  LLM_API_KEY?: string;
}

function buildSystemPrompt(options?: ComposeRequest["options"]): string {
  const tone = options?.tone || "neutral";
  const format = options?.format || "plain-text";
  const length = options?.length || "medium";
  return `You are a helpful writing assistant. Generate content based on the user's prompt.
- Tone: ${tone}
- Format: ${format}
- Length: ${length}
Generate high-quality, appropriate content that matches the user's requirements.`;
}

export async function POST(req: Request) {
  try {
    const request: ComposeRequest = await req.json();
    const {
      context,
      options,
      streaming = true,
      LLM_API_KEY,
    } = request;

    const prompt = request.prompt || "";
    const systemPrompt = buildSystemPrompt(options);

    // Add context if provided
    const fullUserPrompt = context ? `${context}\n\n${prompt}` : prompt;

    if (!fullUserPrompt.trim()) {
      return new Response(
        JSON.stringify({ error: "Prompt is required" }),
        {
          status: 400,
          headers: { "Content-Type": "application/json" },
        }
      );
    }

    // Create provider instance with manual API key if provided, otherwise use default
    const googleProvider = LLM_API_KEY
      ? createGoogleGenerativeAI({ apiKey: LLM_API_KEY })
      : google;

    const result = streamText({
      model: googleProvider("gemini-2.5-flash-lite"),
      system: systemPrompt,
      prompt: fullUserPrompt,
      maxOutputTokens: 2000,
    });

    if (streaming) {
      // Use toTextStreamResponse for streaming text
      return result.toTextStreamResponse();
    } else {
      // For non-streaming, collect all chunks and return
      const text = await result.text;
      return new Response(JSON.stringify({ text }), {
        headers: { "Content-Type": "application/json" },
      });
    }
  } catch (error) {
    console.error("Compose API error:", error);
    return new Response(
      JSON.stringify({
        error: "Internal server error",
        message: (error as Error).message,
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
}

Path:
/api/improve
Source:
import { google, createGoogleGenerativeAI } from "@ai-sdk/google";
import { streamText } from "ai";

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

interface ImproveRequest {
  prompt?: string;
  text?: string;
  context?: string;
  options?: {
    tone?: string;
    format?: string;
    length?: string;
  };
  streaming?: boolean;
  LLM_API_KEY?: string;
}

function buildSystemPrompt(): string {
  return `You are a helpful writing assistant. Improve the given text for clarity and readability while preserving its original meaning and intent.`;
}

export async function POST(req: Request) {
  try {
    const request: ImproveRequest = await req.json();
    const {
      context,
      streaming = true,
      LLM_API_KEY,
    } = request;

    const text = request.text || "";
    const systemPrompt = buildSystemPrompt();

    // Add context if provided
    const fullUserPrompt = context ? `${context}\n\n${text}` : text;

    if (!fullUserPrompt.trim()) {
      return new Response(
        JSON.stringify({ error: "Text is required" }),
        {
          status: 400,
          headers: { "Content-Type": "application/json" },
        }
      );
    }

    // Create provider instance with manual API key if provided, otherwise use default
    const googleProvider = LLM_API_KEY
      ? createGoogleGenerativeAI({ apiKey: LLM_API_KEY })
      : google;

    const result = streamText({
      model: googleProvider("gemini-2.5-flash-lite"),
      system: systemPrompt,
      prompt: fullUserPrompt,
      maxOutputTokens: 2000,
    });

    if (streaming) {
      // Use toTextStreamResponse for streaming text
      return result.toTextStreamResponse();
    } else {
      // For non-streaming, collect all chunks and return
      const text = await result.text;
      return new Response(JSON.stringify({ text }), {
        headers: { "Content-Type": "application/json" },
      });
    }
  } catch (error) {
    console.error("Improve API error:", error);
    return new Response(
      JSON.stringify({
        error: "Internal server error",
        message: (error as Error).message,
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
}

Path:
/api/fix-grammar
Source:
import { google, createGoogleGenerativeAI } from "@ai-sdk/google";
import { streamText } from "ai";

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

interface FixGrammarRequest {
  prompt?: string;
  text?: string;
  context?: string;
  options?: {
    tone?: string;
    format?: string;
    length?: string;
  };
  streaming?: boolean;
  LLM_API_KEY?: string;
}

function buildSystemPrompt(): string {
  return `You are a grammar and proofreading assistant. Fix any grammatical errors, spelling mistakes, and improve the clarity of the given text while preserving the original meaning and style. if there is no change and grammar is correct then return only the text without any changes or correct an return the text only`;
}

export async function POST(req: Request) {
  try {
    const request: FixGrammarRequest = await req.json();
    const {
      context,
      streaming = true,
      LLM_API_KEY,
    } = request;

    const text = request.text || "";
    const systemPrompt = buildSystemPrompt();

    // Add context if provided
    const fullUserPrompt = context ? `${context}\n\n${text}` : text;

    if (!fullUserPrompt.trim()) {
      return new Response(
        JSON.stringify({ error: "Text is required" }),
        {
          status: 400,
          headers: { "Content-Type": "application/json" },
        }
      );
    }

    // Create provider instance with manual API key if provided, otherwise use default
    const googleProvider = LLM_API_KEY
      ? createGoogleGenerativeAI({ apiKey: LLM_API_KEY })
      : google;

    const result = streamText({
      model: googleProvider("gemini-2.5-flash-lite"),
      system: systemPrompt,
      prompt: fullUserPrompt,
      maxOutputTokens: 2000,
    });

    if (streaming) {
      // Use toTextStreamResponse for streaming text
      return result.toTextStreamResponse();
    } else {
      // For non-streaming, collect all chunks and return
      const text = await result.text;
      return new Response(JSON.stringify({ text }), {
        headers: { "Content-Type": "application/json" },
      });
    }
  } catch (error) {
    console.error("Fix Grammar API error:", error);
    return new Response(
      JSON.stringify({
        error: "Internal server error",
        message: (error as Error).message,
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
}

Path:
/api/translate
Source:
import { google, createGoogleGenerativeAI } from "@ai-sdk/google";
import { streamText } from "ai";

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

interface TranslateRequest {
  prompt?: string;
  text?: string;
  context?: string;
  options?: {
    tone?: string;
    format?: string;
    length?: string;
    sourceLanguage?: string;
    targetLanguage?: string;
  };
  streaming?: boolean;
  LLM_API_KEY?: string;
}

function buildSystemPrompt(): string {
  return `You are a translation assistant. Translate the given text accurately, preserving the meaning, tone, and style of the original text. The target language may be specified in the text itself.`;
}

export async function POST(req: Request) {
  try {
    const request: TranslateRequest = await req.json();
    const {
      context,
      streaming = true,
      LLM_API_KEY,
    } = request;

    const text = request.text || "";
    const systemPrompt = buildSystemPrompt();

    // Add context if provided
    const fullUserPrompt = context ? `${context}\n\n${text}` : text;

    if (!fullUserPrompt.trim()) {
      return new Response(
        JSON.stringify({ error: "Text is required" }),
        {
          status: 400,
          headers: { "Content-Type": "application/json" },
        }
      );
    }

    // Create provider instance with manual API key if provided, otherwise use default
    const googleProvider = LLM_API_KEY
      ? createGoogleGenerativeAI({ apiKey: LLM_API_KEY })
      : google;

    const result = streamText({
      model: googleProvider("gemini-2.5-flash-lite"),
      system: systemPrompt,
      prompt: fullUserPrompt,
      maxOutputTokens: 2000,
    });

    if (streaming) {
      // Use toTextStreamResponse for streaming text
      return result.toTextStreamResponse();
    } else {
      // For non-streaming, collect all chunks and return
      const text = await result.text;
      return new Response(JSON.stringify({ text }), {
        headers: { "Content-Type": "application/json" },
      });
    }
  } catch (error) {
    console.error("Translate API error:", error);
    return new Response(
      JSON.stringify({
        error: "Internal server error",
        message: (error as Error).message,
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
}

Path:
/api/suggestions
Source:
import { google, createGoogleGenerativeAI } from "@ai-sdk/google";
import { generateObject } from "ai";
import { z } from "zod";

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

interface SuggestionsRequest {
  prompt: string;
  schema: Record<string, unknown>;
  omitResponseConstraintInput?: boolean;
  LLM_API_KEY?: string;
}

// Convert JSON Schema to Zod schema
function jsonSchemaToZod(schema: Record<string, unknown>): z.ZodTypeAny {
  if (!schema || typeof schema !== "object") {
    return z.any();
  }

  if (schema.type === "object") {
    const shape: Record<string, z.ZodTypeAny> = {};
    if (schema.properties) {
      for (const [key, prop] of Object.entries(
        schema.properties as Record<string, Record<string, unknown>>
      )) {
        const fieldSchema = jsonSchemaToZod(prop);
        // Make optional if not in required array
        if (
          schema.required &&
          Array.isArray(schema.required) &&
          schema.required.includes(key)
        ) {
          shape[key] = fieldSchema;
        } else {
          shape[key] = fieldSchema.optional();
        }
      }
    }
    return z.object(shape);
  } else if (schema.type === "array") {
    const itemSchema = schema.items && typeof schema.items === "object" && !Array.isArray(schema.items)
      ? jsonSchemaToZod(schema.items as Record<string, unknown>)
      : z.any();
    let arraySchema = z.array(itemSchema);

    // Handle minItems and maxItems
    if (typeof schema.minItems === "number") {
      arraySchema = arraySchema.min(schema.minItems) as z.ZodArray<z.ZodTypeAny>;
    }
    if (typeof schema.maxItems === "number") {
      arraySchema = arraySchema.max(schema.maxItems) as z.ZodArray<z.ZodTypeAny>;
    }

    return arraySchema;
  } else if (schema.type === "string") {
    let stringSchema = z.string();
    if (typeof schema.minLength === "number") {
      stringSchema = stringSchema.min(schema.minLength);
    }
    if (typeof schema.maxLength === "number") {
      stringSchema = stringSchema.max(schema.maxLength);
    }
    if (schema.enum && Array.isArray(schema.enum)) {
      return z.enum(schema.enum as [string, ...string[]]);
    }
    return stringSchema;
  } else if (schema.type === "number") {
    let numberSchema = z.number();
    if (typeof schema.minimum === "number") {
      numberSchema = numberSchema.min(schema.minimum);
    }
    if (typeof schema.maximum === "number") {
      numberSchema = numberSchema.max(schema.maximum);
    }
    return numberSchema;
  } else if (schema.type === "boolean") {
    return z.boolean();
  } else if (schema.type === "integer") {
    let intSchema = z.number().int();
    if (typeof schema.minimum === "number") {
      intSchema = intSchema.min(schema.minimum);
    }
    if (typeof schema.maximum === "number") {
      intSchema = intSchema.max(schema.maximum);
    }
    return intSchema;
  }

  return z.any();
}

export async function POST(req: Request) {
  try {
    const request: SuggestionsRequest = await req.json();
    const { prompt, schema, LLM_API_KEY } = request;

    if (!prompt) {
      return new Response(JSON.stringify({ error: "Prompt is required" }), {
        status: 400,
        headers: { "Content-Type": "application/json" },
      });
    }

    if (!schema) {
      return new Response(JSON.stringify({ error: "Schema is required" }), {
        status: 400,
        headers: { "Content-Type": "application/json" },
      });
    }

    // Create provider instance with manual API key if provided, otherwise use default
    const googleProvider = LLM_API_KEY
      ? createGoogleGenerativeAI({ apiKey: LLM_API_KEY })
      : google;

    // Convert JSON Schema to Zod schema
    const zodSchema = jsonSchemaToZod(schema);

    const result = await generateObject({
      model: googleProvider("gemini-2.0-flash-exp"),
      schema: zodSchema,
      prompt,
    });

    return new Response(JSON.stringify(result.object), {
      headers: { "Content-Type": "application/json" },
    });
  } catch (error) {
    console.error("Suggestions API error:", error);
    return new Response(
      JSON.stringify({
        error: "Internal server error",
        message: (error as Error).message,
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
}

Installation

Setup Required: Make sure you have configured components.json first. See the installation guide for setup instructions.

npx shadcn@latest add @gencn-ui/gencn-ui-input
"use client";

import {
  forwardRef,
  useState,
  useRef,
  useMemo,
  useImperativeHandle,
  useCallback,
  type ComponentProps,
  type FocusEvent,
  type FormEvent,
  type KeyboardEvent,
} from "react";
import { Wand2, Languages, Repeat2, SpellCheck } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
  Popover,
  PopoverAnchor,
} from "@/components/ui/popover";
import { useRewriter } from "@/registry/new-york/gencn-ui/items/shared/hooks/internal/use-gencn-ui-rewriter";
import { useWriter } from "@/registry/new-york/gencn-ui/items/shared/hooks/internal/use-gencn-ui-writer";
import { useProofreader } from "@/registry/new-york/gencn-ui/items/shared/hooks/internal/use-gencn-ui-proofreader";
import { useGencnUI } from "@/registry/new-york/gencn-ui/items/shared/hooks/use-gencn-ui";
import { TranslateFeature } from "@/registry/new-york/gencn-ui/items/shared/components/gencn-ui-translate-feature";
import { ImproveFeature } from "@/registry/new-york/gencn-ui/items/shared/components/gencn-ui-improve-feature";
import { FixGrammarFeature } from "@/registry/new-york/gencn-ui/items/shared/components/gencn-ui-fix-grammar-feature";
import { ComposeFeature } from "@/registry/new-york/gencn-ui/items/shared/components/gencn-ui-compose-feature";
import { InlineSuggest } from "@/registry/new-york/gencn-ui/items/shared/components/gencn-ui-inline-suggest";

export type ButtonVisibility = "ALWAYS" | "ON_FOCUS";

export interface GencnUIInputProps extends ComponentProps<"input"> {
  buttonVisibility?: ButtonVisibility;
  containerClassName?: string;
  buttonClassName?: string;
  features?: Array<
    | "compose"
    | "improve"
    | "fix-grammar"
    | "translate"
    | "inline-suggest"
  >;
  translateTargets?: string[];
  translateLanguageMap?: Record<string, string>;
  placeholderPrompt?: string;
  writerOptions?: {
    tone?: "formal" | "neutral" | "casual";
    format?: "markdown" | "plain-text";
    length?: "short" | "medium" | "long";
    sharedContext?: string;
    expectedInputLanguages?: string[];
    expectedContextLanguages?: string[];
    outputLanguage?: string;
  };
  autoSuggestDebounceMs?: number;
  autoSuggestMinChars?: number;
  autoSuggestMaxChars?: number;
  autoSuggestPrompt?: string;
  onAccept?: (text: string) => void;
  onAIError?: (error: Error) => void;
}



export const GencnUIInput = forwardRef<HTMLInputElement, GencnUIInputProps>(
  (
    {
      buttonVisibility = "ALWAYS",
      containerClassName,
      buttonClassName,
      className,
      onFocus,
      onBlur,
      features,
      translateTargets,
      translateLanguageMap,
      placeholderPrompt,
      writerOptions,
      autoSuggestDebounceMs = 500,
      autoSuggestMinChars = 3,
      autoSuggestMaxChars = 48,
      autoSuggestPrompt,
      onAccept,
      onAIError,
      ...props
    },
    ref
  ) => {
    const [isFocused, setIsFocused] = useState(false);
    const inputRef = useRef<HTMLInputElement>(null);
    const containerRef = useRef<HTMLDivElement>(null);
    const [activeFeature, setActiveFeature] = useState<
      "compose" | "improve" | "fix-grammar" | "translate" | null
    >(null);
    const [suggestionsOpen, setSuggestionsOpen] = useState(false);
    const [suggestMode, setSuggestMode] = useState<"inline" | null>(null);
    const lastKeyPressedRef = useRef<string | null>(null);

    const rewriter = useRewriter();
    const writer = useWriter();
    const proofreader = useProofreader();
    const {
      enableServerLLM,
    } = useGencnUI();

    const inlineSuggestActive = useMemo(() => {
      return (
        (features?.includes("inline-suggest") ?? false) &&
        (rewriter.isSupported === true || enableServerLLM)
      );
    }, [features, rewriter.isSupported, enableServerLLM]);

    useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, []);

    const handleFocus = useCallback(
      (e: FocusEvent<HTMLInputElement>) => {
        setIsFocused(true);
        onFocus?.(e);
      },
      [onFocus]
    );

    const handleBlur = useCallback(
      (e: FocusEvent<HTMLInputElement>) => {
        setIsFocused(false);
        onBlur?.(e);
      },
      [onBlur]
    );

    const handleKeyDown = useCallback(
      (e: KeyboardEvent<HTMLInputElement>) => {
        // Track the last key pressed for suggestion trigger checks
        lastKeyPressedRef.current = e.key;
        props.onKeyDown?.(e);
      },
      [props]
    );

    const handleInput = useCallback(
      (e: FormEvent<HTMLInputElement>) => {
        if (inlineSuggestActive && !suggestionsOpen) {
          const value = e.currentTarget.value;
          
          // Don't trigger if backspace was pressed
          if (lastKeyPressedRef.current === "Backspace") {
            props.onInput?.(e);
            return;
          }

          // Don't trigger if last character is not a space
          if (value.length > 0 && value[value.length - 1] !== " ") {
            props.onInput?.(e);
            return;
          }

          // Don't trigger if trimmed text ends with a dot
          const trimmedValue = value.trim();
          if (trimmedValue.length > 0 && trimmedValue[trimmedValue.length - 1] === ".") {
            props.onInput?.(e);
            return;
          }

          if (value.trim().length >= autoSuggestMinChars) {
            setSuggestMode("inline");
            setSuggestionsOpen(true);
          }
        }
        props.onInput?.(e);
      },
      [inlineSuggestActive, autoSuggestMinChars, suggestionsOpen, props]
    );

    const openCompose = useCallback(() => {
      setActiveFeature("compose");
    }, []);

    const openImprove = useCallback(() => {
      setActiveFeature("improve");
    }, []);

    const openFixGrammar = useCallback(() => {
      
      setActiveFeature("fix-grammar");
    }, []);

    const openTranslate = useCallback(() => {
      setActiveFeature("translate");
    }, []);

    const handleAcceptResult = useCallback(
      (text: string) => {
        if (inputRef.current) {
          inputRef.current.value = text;
          const event = new Event("input", { bubbles: true });
          inputRef.current.dispatchEvent(event);
        }
        onAccept?.(text);
        setActiveFeature(null);
      },
      [onAccept]
    );


    const hasAnySupportedFeature = useMemo(() => {
      if (!features || features.length === 0) return false;
      return features.some((feature) => {
        switch (feature) {
          case "compose":
          case "improve":
          case "fix-grammar":
          case "translate":
            return enableServerLLM;
          case "inline-suggest":
            return rewriter.isSupported === true || enableServerLLM;
          default:
            return false;
        }
      });
    }, [features, rewriter.isSupported, enableServerLLM]);

    const featureIcon = useMemo(() => {
      if (!activeFeature && (features?.length ?? 0) === 1) {
        const f = features![0];
        if (f === "compose") return <Wand2 className="size-4" />;
        if (f === "translate") return <Languages className="size-4" />;
        if (f === "improve" || f === "fix-grammar")
          return <Repeat2 className="size-4" />;
      }
      return <Wand2 className="size-4" />;
    }, [activeFeature, features]);

    const shouldShowButton =
      buttonVisibility === "ALWAYS" ||
      (buttonVisibility === "ON_FOCUS" && isFocused);
    const hasFeatureUI = Array.isArray(features) && features.length > 0;
    const shouldShowMagicIcon = hasFeatureUI && hasAnySupportedFeature;

    return (
      <div ref={containerRef} className={cn("relative", containerClassName)}>
        <Popover 
          open={suggestionsOpen} 
          onOpenChange={(open) => {
            setSuggestionsOpen(open);
            if (!open) setSuggestMode(null);
          }}
        >
          <PopoverAnchor asChild>
            <div className="relative">
              <Input
                ref={inputRef}
                className={cn(
                  className,
                  shouldShowButton && shouldShowMagicIcon && "pr-12"
                )}
                onFocus={handleFocus}
                onBlur={handleBlur}
                onKeyDown={handleKeyDown}
                onInput={handleInput}
                {...props}
              />
            </div>
          </PopoverAnchor>

          <InlineSuggest
            inputRef={inputRef}
            active={inlineSuggestActive}
            debounceMs={autoSuggestDebounceMs}
            minChars={autoSuggestMinChars}
            maxChars={autoSuggestMaxChars}
            prompt={autoSuggestPrompt}
            open={suggestionsOpen && suggestMode === "inline"}
            onOpenChange={setSuggestionsOpen}
            onModeChange={setSuggestMode}
            rewriter={rewriter}
            onChange={props.onChange ? (e) => {
              if (e.target instanceof HTMLInputElement) {
                props.onChange?.(e as React.ChangeEvent<HTMLInputElement>);
              }
            } : undefined}
            onInput={props.onInput ? (e) => {
              if (e.currentTarget instanceof HTMLInputElement) {
                props.onInput?.(e as React.FormEvent<HTMLInputElement>);
              }
            } : undefined}
          />
        </Popover>

        {shouldShowButton &&
          shouldShowMagicIcon &&
          (features!.length === 1 ? (
            <Button
              type="button"
              size="icon"
              variant="ghost"
              onClick={() => {
                if (features![0] === "compose") {
                  openCompose();
                } else if (features![0] === "improve") {
                  openImprove();
                } else if (features![0] === "fix-grammar") {
                  openFixGrammar();
                } else if (features![0] === "translate") {
                  openTranslate();
                }
              }}
              className={cn(
                "hover:bg-accent/80 absolute top-1/2 right-2 z-10 h-7 w-7 -translate-y-1/2 rounded-md shadow-sm transition-opacity",
                buttonClassName
              )}
              aria-label="AI actions"
            >
              {featureIcon}
            </Button>
          ) : (
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button
                  type="button"
                  size="icon"
                  variant="ghost"
                  className={cn(
                    "hover:bg-accent/80 absolute top-1/2 right-2 z-10 h-7 w-7 -translate-y-1/2 rounded-md shadow-sm transition-opacity",
                    buttonClassName
                  )}
                  aria-label="Choose AI action"
                >
                  <Wand2 className="size-4" />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end" className="min-w-44">
                {features!.includes("compose") && (
                  <DropdownMenuItem onClick={openCompose}>
                    <Wand2 className="mr-2 h-4 w-4" /> Compose
                  </DropdownMenuItem>
                )}
                {features!.includes("improve") && (
                  <DropdownMenuItem onClick={openImprove}>
                    <Repeat2 className="mr-2 h-4 w-4" /> Improve
                  </DropdownMenuItem>
                )}
                {features!.includes("fix-grammar") && (
                  <DropdownMenuItem onClick={openFixGrammar}>
                    <SpellCheck className="mr-2 h-4 w-4" /> Fix grammar
                  </DropdownMenuItem>
                )}
                {features!.includes("translate") && (
                  <DropdownMenuItem onClick={openTranslate}>
                    <Languages className="mr-2 h-4 w-4" /> Translate
                  </DropdownMenuItem>
                )}
              </DropdownMenuContent>
            </DropdownMenu>
          ))}

        {features?.includes("compose") && (
          <ComposeFeature
            open={activeFeature === "compose"}
            onOpenChange={(open) => {
              if (!open) setActiveFeature(null);
            }}
            onAccept={handleAcceptResult}
            onError={onAIError}
            writerOptions={writerOptions}
            placeholderPrompt={placeholderPrompt}
          />
        )}

        {features?.includes("improve") && (
          <ImproveFeature
            open={activeFeature === "improve"}
            onOpenChange={(open) => {
              if (!open) setActiveFeature(null);
            }}
            onAccept={handleAcceptResult}
            onError={onAIError}
            getCurrentValue={() => inputRef.current?.value || ""}
          />
        )}

        {features?.includes("fix-grammar") && (
          <FixGrammarFeature
            open={activeFeature === "fix-grammar"}
            onOpenChange={(open) => {
              if (!open) setActiveFeature(null);
            }}
            onAccept={handleAcceptResult}
            onError={onAIError}
            getCurrentValue={() => inputRef.current?.value || ""}
          />
        )}

        {features?.includes("translate") && (
          <TranslateFeature
            open={activeFeature === "translate"}
            onOpenChange={(open) => {
              if (!open) setActiveFeature(null);
            }}
            onAccept={handleAcceptResult}
            onError={onAIError}
            translateTargets={translateTargets}
            translateLanguageMap={translateLanguageMap}
            getCurrentValue={() => inputRef.current?.value || ""}
          />
        )}
      </div>
    );
  }
);

GencnUIInput.displayName = "GencnUIInput";

Component API