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 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.
"use client"
import * as React from "react"
import {
InlineEdit,
type InlineEditRequest,
} from "mischief-ui/inline-edit"
const initial =
"We just wanted to let you know that exports were actually failing for some workspaces between 09:00 and 11:30 UTC on Tuesday. It was really caused by a timeout in the step that zips large files. We have raised the limit and run every failed export again, so you do not need to do anything. If a download still looks incomplete, reply to this email and we will look at it."
const prepared: Record<string, string> = {
"We just wanted to let you know that exports were actually failing for some workspaces between 09:00 and 11:30 UTC on Tuesday.":
"Between 09:00 and 11:30 UTC on Tuesday, exports failed for some workspaces.",
"It was really caused by a timeout in the step that zips large files.":
"A timeout in the step that zips large files caused it.",
}
const filler = /\b(just|actually|really|very|simply)\s+/gi
function rewrite({ selection, instruction }: InlineEditRequest) {
const text = selection.text.trim()
const tightened = prepared[text] ?? text.replace(filler, "")
if (/upper|caps|shout/i.test(instruction)) return tightened.toUpperCase()
return tightened
}
async function* streamRewrite(request: InlineEditRequest) {
const words = rewrite(request).split(/(?<=\s)/)
for (const word of words) {
await new Promise((resolve) => setTimeout(resolve, 45))
if (request.signal.aborted) return
yield word
}
}
export function InlineEditDemo() {
const [text, setText] = React.useState(initial)
return (
<div className="bg-background border-border grid w-full max-w-[36rem] gap-3 rounded-[1.25rem] border p-5 shadow-sm">
<div className="flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold">Incident note</h3>
<button
type="button"
onClick={() => setText(initial)}
disabled={text === initial}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-md px-1.5 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-40 motion-reduce:transition-none"
>
Reset
</button>
</div>
<InlineEdit
label="Incident note"
onChange={setText}
onRequestEdit={streamRewrite}
value={text}
/>
<p className="text-muted-foreground text-xs leading-relaxed">
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.
</p>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/inline-editimport { InlineEdit } from "mischief-ui/inline-edit"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"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.