# Selection Actions

> Offer AI edits for a piece of selected text, then ask the person to keep or discard the result.

Source: https://ai.honestui.com/docs/components/selection-actions

```tsx
"use client"

import * as React from "react"
import { Scissors as ScissorsIcon, Sparkles as SparklesIcon } from "honestui/icons"

import {
  SelectionActions,
  type SelectionActionsStatus,
} from "@/components/ai/selection-actions"

// Sample passage and the rewrite each action stands in for.
const original = "Round the total once after the rows have all been added up together."
const rewrites: Record<string, string> = {
  improve: "Round the total once, after the rows are summed.",
  shorten: "Round once, after summing.",
}

const EDIT_MILLISECONDS = 1200

/**
 * No model edits this text. A timer stands in for the request, and each
 * action swaps in a fixed rewrite so you can keep or discard it.
 */
export function SelectionActionsDemo() {
  const [text, setText] = React.useState(original)
  const [proposal, setProposal] = React.useState<string | null>(null)
  const [status, setStatus] = React.useState<SelectionActionsStatus>("idle")
  const pending = React.useRef<number | undefined>(undefined)
  React.useEffect(() => () => window.clearTimeout(pending.current), [])

  const run = (rewrite: string) => {
    setStatus("working")
    pending.current = window.setTimeout(() => {
      setProposal(rewrite)
      setStatus("result")
    }, EDIT_MILLISECONDS)
  }

  return (
    <div className="flex w-full max-w-md min-w-0 flex-col items-start gap-[var(--hui-space-3)]">
      <p className="m-0 [font-size:var(--hui-font-size-regular)] [line-height:1.6]">
        The export drifts by a cent.{" "}
        <mark className="rounded-[var(--hui-radius-1)] bg-[var(--hui-color-background-accent-primary)] px-0.5 text-[var(--hui-color-foreground-base-primary)]">
          {proposal ?? text}
        </mark>
      </p>
      <SelectionActions
        status={status}
        actions={[
          { id: "improve", label: "Improve", icon: <SparklesIcon aria-hidden="true" /> },
          { id: "shorten", label: "Shorten", icon: <ScissorsIcon aria-hidden="true" /> },
        ]}
        onAction={(actionId) => run(rewrites[actionId])}
        onPrompt={(instruction) => run(`${text} (${instruction})`)}
        onKeep={() => {
          if (proposal) setText(proposal)
          setProposal(null)
          setStatus("idle")
        }}
        onDiscard={() => {
          setProposal(null)
          setStatus("idle")
        }}
      />
    </div>
  )
}

```

## Overview [#overview]

Selection Actions is a small bar of edits for selected text: Improve, Shorten, or an instruction the person types. While the edit runs it shows progress. When the edit returns, it asks the person to keep or discard it.

The bar is the controls only. Tracking the selection, placing the bar near it, making the edit, and showing the rewritten text are your application's work. You tell the bar where things stand through `status`.

## Anatomy [#anatomy]

<Anatomy title="SelectionActions">
  <AnatomyItem name="Actions" description="One button for each edit you offer" />

  <AnatomyItem name="Prompt" description="A field for a typed instruction, when you handle it" />

  <AnatomyItem name="Status" description="Working, or an error" />

  <AnatomyItem name="Decision" description="Keep, Discard, and Try again" />
</Anatomy>

Installing `selection-actions` brings [Button](https://www.honestui.com/docs/components/button) and [Text Shimmer](https://www.honestui.com/docs/animated/text-shimmer) along as registry dependencies.

## Behavior [#behavior]

### Status [#status]

* `idle` shows the actions and the prompt field.
* `working` shows a shimmering label and no controls.
* `result` shows Keep and Discard.
* `error` shows a message and Discard.

Pass `onRetry` to add Try again to a result or an error.

### Prompt [#prompt]

Pass `onPrompt` to add the field. Send is disabled while it is empty, and the field clears after sending.

## Accessibility [#accessibility]

The bar is a toolbar with a name. Working and the error are announced through a status that stays mounted. The prompt field has a name, and Send is a submit button.

When the status changes, the buttons under focus are replaced. Move focus to the new controls from your handler if the person triggered the change from the keyboard.

With reduced motion, the working label does not shimmer.

| Key              | Action                                                              |
| ---------------- | ------------------------------------------------------------------- |
| <kbd>Tab</kbd>   | Move across the actions, the prompt field, and the decision buttons |
| <kbd>Enter</kbd> | Send the typed instruction, or activate the focused button          |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/selection-actions&#x22;]" />
  

  
    
      
        Add the components it is built from.
      

      <CliBlock commands="[&#x22;button&#x22;, &#x22;text-shimmer&#x22;]" />

      
        Copy and paste this file into your project.
      

      ### components/ai/selection-actions.tsx

```tsx
"use client"

import * as React from "react"
import { ArrowUp as ArrowUpIcon, CircleAlert as CircleAlertIcon } from "honestui/icons"

import { cn } from "@/lib/utils"
import { TextShimmer } from "@/components/animated/text-shimmer"
import { Button } from "@/components/ui/button"

export type SelectionAction = {
  id: string
  label: string
  icon?: React.ReactNode
}

export type SelectionActionsStatus = "idle" | "working" | "result" | "error"

export type SelectionActionsLabels = {
  toolbar: string
  prompt: string
  promptPlaceholder: string
  send: string
  working: string
  keep: string
  discard: string
  retry: string
  error: string
}

export const defaultSelectionActionsLabels: SelectionActionsLabels = {
  toolbar: "Edit selection",
  prompt: "Describe an edit",
  promptPlaceholder: "Describe an edit…",
  send: "Send",
  working: "Working…",
  keep: "Keep",
  discard: "Discard",
  retry: "Try again",
  error: "The edit did not finish.",
}

const smallText =
  "[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"

export type SelectionActionsProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** One button each, such as Improve and Shorten. */
  actions: SelectionAction[]
  /** Where the edit stands. Your application sets it. */
  status?: SelectionActionsStatus
  onAction: (actionId: string) => void
  /** Passing it adds a field for a typed instruction. */
  onPrompt?: (instruction: string) => void
  /** Accepts the result. */
  onKeep: () => void
  /** Rejects the result, or dismisses an error. */
  onDiscard: () => void
  /** Passing it offers another attempt on a result or an error. */
  onRetry?: () => void
  labels?: Partial<SelectionActionsLabels>
}

/**
 * A bar of AI edits for a piece of selected text. It offers the actions,
 * shows progress, and then asks the person to keep or discard the result.
 * Tracking the selection, placing the bar, and making the edit are yours.
 */
function SelectionActions({
  actions,
  status = "idle",
  onAction,
  onPrompt,
  onKeep,
  onDiscard,
  onRetry,
  labels: labelsProp,
  className,
  ...props
}: SelectionActionsProps) {
  const labels = { ...defaultSelectionActionsLabels, ...labelsProp }
  const [instruction, setInstruction] = React.useState("")
  const trimmed = instruction.trim()

  return (
    <div
      role="toolbar"
      aria-label={labels.toolbar}
      data-slot="selection-actions"
      data-status={status}
      className={cn(
        "inline-flex w-fit max-w-full min-w-0 flex-wrap items-center gap-[var(--hui-space-2)] rounded-[var(--hui-radius-4)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] p-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-primary)] shadow-[var(--hui-shadow-lifted)]",
        smallText,
        className
      )}
      {...props}
    >
      {/* Always mounted, so each change of status is announced. */}
      <span role="status" className={cn(status === "working" || status === "error" ? "contents" : "sr-only")}>
        {status === "working" && (
          <TextShimmer duration={1.6} className="px-[var(--hui-space-3)] py-[var(--hui-space-2)] [font-weight:var(--hui-font-weight-medium)]">
            {labels.working}
          </TextShimmer>
        )}
        {status === "error" && (
          <span className="flex items-center gap-[var(--hui-space-2)] px-[var(--hui-space-3)] py-[var(--hui-space-2)]">
            <CircleAlertIcon strokeWidth={1.5} strokeLinecap="round"
              aria-hidden="true"
              className="size-3.5 shrink-0 text-[var(--hui-color-foreground-danger-primary)]"
            />
            {labels.error}
          </span>
        )}
      </span>

      {status === "idle" && (
        <>
          {actions.map((action) => (
            <Button key={action.id} variant="link" size="sm" onClick={() => onAction(action.id)}>
              {action.icon}
              {action.label}
            </Button>
          ))}
          {onPrompt && (
            <form
              className="flex min-w-0 items-center gap-[var(--hui-space-2)]"
              onSubmit={(event) => {
                event.preventDefault()
                if (trimmed === "") return
                onPrompt(trimmed)
                setInstruction("")
              }}
            >
              <input
                value={instruction}
                onChange={(event) => setInstruction(event.target.value)}
                aria-label={labels.prompt}
                placeholder={labels.promptPlaceholder}
                className={cn(
                  "h-[var(--hui-space-7)] w-40 min-w-0 rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-tertiary)] bg-transparent px-[var(--hui-space-3)] text-[var(--hui-color-foreground-base-primary)] outline-none placeholder:text-[var(--hui-color-foreground-base-tertiary)] focus:border-[var(--hui-color-border-accent-emphasis)]",
                  smallText
                )}
              />
              <Button type="submit" size="icon-sm" disabled={trimmed === ""} aria-label={labels.send}>
                <ArrowUpIcon aria-hidden="true" strokeWidth={2} />
              </Button>
            </form>
          )}
        </>
      )}

      {(status === "result" || status === "error") && (
        <>
          {status === "result" && (
            <Button size="sm" onClick={onKeep}>
              {labels.keep}
            </Button>
          )}
          <Button variant="secondary" size="sm" onClick={onDiscard}>
            {labels.discard}
          </Button>
          {onRetry && (
            <Button variant="link" size="sm" onClick={onRetry}>
              {labels.retry}
            </Button>
          )}
        </>
      )}
    </div>
  )
}

export { SelectionActions }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { SelectionActions } from "@/components/ai/selection-actions"

<SelectionActions
  status={edit.status}
  actions={[
    { id: "improve", label: "Improve" },
    { id: "shorten", label: "Shorten" },
  ]}
  onAction={(actionId) => edit.run(actionId, selection.text)}
  onPrompt={(instruction) => edit.runCustom(instruction, selection.text)}
  onKeep={edit.accept}
  onDiscard={edit.reject}
  onRetry={edit.retry}
/>
```

## Examples [#examples]

### A failed edit [#a-failed-edit]

The error state, with Try again.

```tsx
"use client"

import * as React from "react"

import {
  SelectionActions,
  type SelectionActionsStatus,
} from "@/components/ai/selection-actions"

/** Shows the error state. Try again returns to it, as a request that keeps failing would. */
export function SelectionActionsErrorExample() {
  const [status, setStatus] = React.useState<SelectionActionsStatus>("error")
  const pending = React.useRef<number | undefined>(undefined)
  React.useEffect(() => () => window.clearTimeout(pending.current), [])

  const fail = () => {
    setStatus("working")
    pending.current = window.setTimeout(() => setStatus("error"), 900)
  }

  return (
    <SelectionActions
      status={status}
      labels={{ error: "The edit did not finish. The text is unchanged." }}
      actions={[{ id: "improve", label: "Improve" }]}
      onAction={fail}
      onKeep={() => setStatus("idle")}
      onDiscard={() => setStatus("idle")}
      onRetry={fail}
    />
  )
}

```

## API reference [#api-reference]

### SelectionActions [#selectionactions]

Accepts native div props beyond those listed.

| Prop        | Type                                         | Default  | Description                                                       |
| ----------- | -------------------------------------------- | -------- | ----------------------------------------------------------------- |
| `actions`   | `SelectionAction[]`                          | Required | Each has `id`, `label`, and an optional `icon`.                   |
| `status`    | `"idle" \| "working" \| "result" \| "error"` | `"idle"` | Where the edit stands.                                            |
| `onAction`  | `(actionId: string) => void`                 | Required | Runs when an action is pressed.                                   |
| `onPrompt`  | `(instruction: string) => void`              | None     | Passing it adds the prompt field.                                 |
| `onKeep`    | `() => void`                                 | Required | Accepts the result.                                               |
| `onDiscard` | `() => void`                                 | Required | Rejects the result, or dismisses an error.                        |
| `onRetry`   | `() => void`                                 | None     | Passing it adds Try again.                                        |
| `labels`    | `Partial<SelectionActionsLabels>`            | English  | The toolbar name, field names, status messages, and button names. |
