Skip to content
TuffTasks, docs and chat for teams and AI agents

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 words

Left 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.json

Usage

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

PropTypeDefaultDescription
source*string—The selected text being rewritten, shown as a quote with an accent bar.
attachmentToneRewriteAttachment—Chip above the quote: document name, word count and "from Docs" at the right.
onRemoveAttachment() => void—Shows × on the attachment chip.
sourceWordsnumberattachment.words, then words in sourceLeft side of "142 → 46" in the status row.
promptstring—Controlled instructions. Enter sends; once a result is in, Enter replaces the selection until the prompt changes, and ⌘/Ctrl+Enter inserts below.
defaultPromptstring""Starting instructions when uncontrolled.
onPromptChange(prompt: string) => void—Called as the user types.
placeholderstring"Tell the model how to rewrite it"Shown while the prompt is empty.
modelsToneRewriteOption[][]Model menu with a sparkle icon. Hidden when empty.
model / defaultModel / onModelChangestring / string / (model: string) => voidfirst modelSelected model, controlled or not.
lengthsToneRewriteOption[][]Length menu, e.g. "Concise". Hidden when empty.
length / defaultLength / onLengthChangestring / string / (length: string) => voidfirst lengthSelected length, controlled or not.
toneToneValue—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.
defaultToneToneValue{ 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) => stringdefaultRegisterName 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.
outputstring[] | 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 / doneLabelstring"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) => voidwrites to the clipboardCopy button at the end of the action row.
followUpsToneRewriteFollowUp[][]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.
classNamestring—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;