Interactive Answers
Tone Rewrite
An AI rewrite of a selection — prompt, model and length, a Formal–Casual × Brief–Detailed tone pad, a streamed result and Replace or Insert.
1 variantMIT
Preview
Q3 board memo· 142 wordsfrom Docs
Churn rose 4% in Q3, mostly in self-serve. Expansion revenue from 18 accounts offset it, net retention held at 112%.
Tone
Neutral≈ 70 wordsLeft and right move from formal to casual, up and down from brief to detailed. Home resets to neutral.
Installation
Copies the component into components/ui and installs its dependencies and theme tokens.
pnpm dlx shadcn@latest add https://tuffui.com/r/tone-rewrite.jsonUsage
Pass your own data and wire the callbacks to your agent runtime.
import { ToneRewrite, type ToneValue } from "@/components/ui/tone-rewrite";
export function RewriteSelection({ doc, selection }: { doc: Doc; selection: string }) {
const [prompt, setPrompt] = useState("");
const [tone, setTone] = useState<ToneValue>({ x: 0, y: 0 });
// useRewrite streams bullets from your model: ["Net retention held…", "Expansion…"]
const { status, bullets, total, start, stop } = useRewrite();
return (
<ToneRewrite
attachment={{ name: doc.title, words: doc.words, origin: "Docs" }}
onRemoveAttachment={() => closeRewrite()}
source={selection}
prompt={prompt}
onPromptChange={setPrompt}
models={[
{ value: "fast", label: "Chord Fast", hint: "fastest" },
{ value: "pro", label: "Chord Pro", hint: "best writing" },
]}
lengths={[
{ value: "concise", label: "Concise" },
{ value: "thorough", label: "Thorough" },
]}
tone={tone}
onToneChange={setTone}
wordRange={[20, 120]}
status={status} // "idle" | "writing" | "done"
output={bullets}
progress={{ current: bullets.length, total }}
onSend={({ prompt, tone, words, model, length }) =>
start({ selection, prompt, tone, words, model, length })
}
onStop={stop}
onReplace={(text) => editor.replaceSelection(text)}
onInsert={(text) => editor.insertAfterSelection(text)}
followUps={[
{ id: "shorter", label: "Shorter", nudge: { y: -0.3 } },
{ id: "numbers", label: "Add numbers" },
{ id: "formal", label: "More formal", nudge: { x: -0.3 } },
]}
onFollowUp={(f, tone) => start({ selection, prompt: f.label, tone })}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| source* | string | — | The selected text being rewritten, shown as a quote with an accent bar. |
| attachment | ToneRewriteAttachment | — | Chip above the quote: document name, word count and "from Docs" at the right. |
| onRemoveAttachment | () => void | — | Shows × on the attachment chip. |
| sourceWords | number | attachment.words, then words in source | Left side of "142 → 46" in the status row. |
| prompt | string | — | Controlled instructions. Enter sends; once a result is in, Enter replaces the selection until the prompt changes, and ⌘/Ctrl+Enter inserts below. |
| defaultPrompt | string | "" | Starting instructions when uncontrolled. |
| onPromptChange | (prompt: string) => void | — | Called as the user types. |
| placeholder | string | "Tell the model how to rewrite it" | Shown while the prompt is empty. |
| models | ToneRewriteOption[] | [] | Model menu with a sparkle icon. Hidden when empty. |
| model / defaultModel / onModelChange | string / string / (model: string) => void | first model | Selected model, controlled or not. |
| lengths | ToneRewriteOption[] | [] | Length menu, e.g. "Concise". Hidden when empty. |
| length / defaultLength / onLengthChange | string / string / (length: string) => void | first length | Selected length, controlled or not. |
| tone | ToneValue | — | Controlled handle on the tone pad. Changes made from outside, like an agent or a follow-up, glide into place; drags and arrow keys follow at once. |
| defaultTone | ToneValue | { x: 0, y: 0 } | Starting handle when uncontrolled. |
| onToneChange | (tone: ToneValue) => void | — | Called while the handle is dragged or moved with the keyboard, and when a follow-up nudges it. Arrows move it, Shift moves further, Home resets to neutral. |
| wordRange | [number, number] | [20, 120] | Target words at the Brief and Detailed ends of the pad. |
| estimateWords | (tone: ToneValue) => number | — | Your own target length. Defaults to `wordRange` along y, rounded to 5. |
| formatEstimate | (words: number) => string | — | Text right of "Tone". Defaults to "≈ 45 words" with a rolling number. |
| describeRegister | (x: number) => string | defaultRegister | Name for the formal–casual position, next to "Tone" and in the slider's screen-reader text, e.g. "Fairly formal". |
| labels | { formal?; casual?; brief?; detailed? } | — | Axis labels on the pad. |
| status | "idle" | "writing" | "done" | "idle" | Writing shows the status row and a caret; done adds Replace, Insert, Copy and the follow-ups. |
| output | string[] | string | — | The rewrite as you stream it: bullets, or one block of text. New words fade in as they arrive. |
| progress | { current: number; total: number } | — | "2 of 3" in the status row. The bar fills toward the target length. |
| writingLabel / doneLabel | string | "Writing" / "Done" | Status row label. |
| onSend | (request: ToneRewriteRequest) => void | — | Called with the prompt, tone, target words, model and length. |
| onStop | () => void | — | Shows Stop with an esc key cap while writing; Esc anywhere in the card stops. |
| onReplace | (output: string) => void | — | Shows Replace selection. Bullets arrive joined with “• ” and new lines. |
| onInsert | (output: string) => void | — | Shows Insert below. |
| onCopy | (output: string) => void | writes to the clipboard | Copy button at the end of the action row. |
| followUps | ToneRewriteFollowUp[] | [] | Chips in the "Then" row once a result is in. |
| onFollowUp | (followUp, tone: ToneValue) => void | — | Called after the chip's `nudge` moves the handle, with the new tone. Start the rerun here. |
| onAttach | () => void | — | Shows the round + button. |
| onVoice | () => void | — | Shows the mic button. |
| className | string | — | Extra classes for the root element. |
// x: -1 formal … 1 casual. y: -1 brief … 1 detailed.
type ToneValue = { x: number; y: number };
type ToneRewriteAttachment = { name: string; words?: number; origin?: string };
type ToneRewriteOption = { value: string; label: string; hint?: string };
type ToneRewriteFollowUp = {
id: string;
label: string;
nudge?: Partial<ToneValue>; // added to the tone before the rerun
};
type ToneRewriteRequest = {
prompt: string;
tone: ToneValue;
words: number; // target length from the pad
model?: string;
length?: string;
};
// "Formal", "Fairly formal", "Neutral", "Fairly casual", "Casual".
function defaultRegister(x: number): string;