Mischief

122 / Agent UI

Inline Edit

Select a span of a document, say how it should change, and watch the replacement stream in beside the struck-out original. Nothing changes until you accept it.

Incident note

Select text, then press Control K to rewrite it.

Select a sentence and ask for it to be tighter. The rewrites in this example are prepared in the page; in an app, onRequestEdit streams them from your model.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/inline-edit
import { InlineEdit } from "mischief-ui/inline-edit"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/inline-edit/inline-edit.tsx
"use client" import * as React from "react"import { ArrowUp } from "lucide-react" import { cn } from "@/lib/utils" export type InlineEditSelection = { start: number; end: number; text: string } export type InlineEditRequest = {  selection: InlineEditSelection  instruction: string  signal: AbortSignal}

Usage

export function Draft() {
  const [text, setText] = useState(initialDraft)

  return (
    <InlineEdit
      value={text}
      onChange={setText}
      onRequestEdit={({ selection, instruction, signal }) =>
        streamRewrite(selection.text, instruction, { signal })
      }
    />
  )
}

Select, ask, review

Selecting text, with a pointer or with Shift and the arrow keys, brings up Edit selection beside it. That button, or Cmd+K or Ctrl+K inside the document, opens a small prompt. Submitting it calls onRequestEdit, and the replacement streams in next to the original, which is struck through. Accept writes it into the value through onChange. Reject leaves the value as it was.

Either way the document gets focus back with a selection: the new text after Accept, the original after Reject. A second edit on the same span is one shortcut away.

Accept writes into the value as it is now, and only while the selected text is still at the offsets it was selected from. If your code changed that text, or moved it, during the edit, Accept is withdrawn and changedMessage takes its place. The proposal stays on screen. Retry asks again for the same text where it now sits, and Dismiss leaves the document alone. A change elsewhere in the document does not block Accept, and is kept.

What onRequestEdit returns

The request carries the selection with its offsets and text, the instruction, and an AbortSignal. Return the replacement as a string, a promise of one, an async iterable of chunks, or a ReadableStream of strings. A host that receives chunks through callbacks can enqueue them into a ReadableStream.

async function* streamRewrite({ selection, instruction, signal }) {
  const response = await fetch("/api/rewrite", {
    method: "POST",
    body: JSON.stringify({ text: selection.text, instruction }),
    signal,
  })
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()

  for (;;) {
    const { done, value } = await reader.read()
    if (done) return
    yield value
  }
}

Stop and Reject abort the signal. A rejection shows errorMessage in place with Try again, which asks once more with the same instruction.

Keys

Cmd+K or Ctrl+K opens the prompt for the current selection. Enter sends the instruction. Once the replacement has arrived, focus moves to Accept, so Enter accepts; Cmd+Enter or Ctrl+Enter accepts from anywhere in the bar. Escape closes the prompt, stops a stream, or rejects, whichever is open.

readOnly stops typing in the document but keeps editing through requests, for text that should only change by review.

API

valuestringThe document text, when controlled.
defaultValuestringThe starting text, when uncontrolled.
onChange(value: string) => voidCalled with the new text after typing or an accepted edit.
onRequestEdit(request: InlineEditRequest) => InlineEditResultProduces the replacement for a selection, whole or streamed.
selection, instruction, signal (InlineEditRequest)InlineEditSelection, string, AbortSignalWhat was selected, what was asked, and the signal Stop and Reject abort.
start, end, text (InlineEditSelection)number, number, stringOffsets of the selection in the value, and its text.
InlineEditResultAsyncIterable<string> | ReadableStream<string> | Promise<string> | stringAny of these is accepted as the replacement.
data-phase (InlineEditPhase)"idle" | "prompting" | "streaming" | "review" | "error"The step the edit is on, written to the root for styling.
readOnlybooleanBlocks typing. Edits through requests still work.
labelstringThe accessible name of the document. Defaults to Document.
placeholderstringPlaceholder for the instruction field.
errorMessagestringShown when a request fails.
changedMessagestringShown in place of Accept when the selected text changed during the edit.
classNamestringClasses for the root.
...divPropsHTMLAttributesNative div attributes.

Accessibility

The document is a native textarea, so selection works with the keyboard as well as a pointer, and its description names the Cmd+K or Ctrl+K shortcut. The instruction field is labelled. While an edit is under review the original is a del element and the replacement an ins element, each with a hidden Removed or Added prefix, and the bar holding Accept and Reject is a named toolbar that takes focus. A live region says when rewriting starts, reads the suggested replacement when it is ready, confirms Accept or Reject, and says so when the document changed under the edit. Reduced motion removes the bar's entrance and holds the streaming caret still.