Topic 10.5
The Prompt Input
In one line
The prompt box looks like a text field and behaves like a small editor: it grows, sends on Enter but not on Shift+Enter, must not send while an input method is composing a word, must cope with a huge paste, shows limits honestly, keeps its draft, and stays usable while an answer streams.
Think of it like this
A writing desk with a notepad and an envelope. You write as much as you need, the pad gets longer as you go, you seal the envelope only when you mean to, and if someone bumps the desk your half-written note is still there. A prompt box is that desk, and each rule in this topic keeps something from being lost or sent by accident.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- IME
- Input method editor: software that turns key presses into characters for languages with large character sets, by offering candidate words.
- compositionstart / compositionend
- Events that mark when an IME composition begins and ends.
- isComposing
- A
KeyboardEventproperty that is true while an IME composition is in progress. - Grapheme
- What a person sees as one character. An emoji with several joined parts is one grapheme.
- Intl.Segmenter
- A browser API that splits text into graphemes, words or sentences.
- scrollHeight
- The full height of an element's content, including the part you must scroll to see.
- field-sizing: content
- A CSS property that makes a textarea size itself to its content.
- Draft
- Text typed but not yet sent, saved so a reload does not lose it.
- isWellFormed()
- A string method that returns false when the string contains a lone half of a surrogate pair.
Step by step
01Decide what each key press means
The first example puts the decision in one function, decideKey, and shows its answer for every case. Read the log from top to bottom: Enter with text sends and prevents the line break. Enter while an answer runs is ignored. Shift+Enter is left to the browser. Three IME cases are ignored: an Enter whose isComposing is true, an Enter whose isComposing reads false while the composition events say a word is still open, and the Enter that arrives right after compositionend with keyCode 229. A normal Enter after that sends.
02Block the double send inside one instant
At the bottom of the first example three Enter events are fired back to back. Only one message is sent, because the DOM value was cleared inside the first handler. Compare with Break it, where the clearing is removed and three messages are sent.
03Grow and shrink
The second example is a controlled textarea with a layout effect. After each change it resets the height, measures, and sets the new height up to five lines. The log is a set of yes-or-no answers (taller, capped, scrolls, shrinks back) instead of pixel values, because the preview frame is small and pixel numbers would differ between machines.
textarea {
field-sizing: content; /* grow with the content */
max-height: 8lh; /* stop growing after about eight lines, then scroll */
min-height: 2.5rem;
resize: none;
}04Count what a person sees and refuse what is too much
The third example checks nine inputs against a limit of 4,000: empty, short, near the limit, exactly at it, one over, and a 5,000-character paste. Send is enabled only for 1 to 4,000. Then it trims a string that ends in a family emoji two ways. The naive slice cuts the emoji in half, which isWellFormed() catches. Trimming by graphemes keeps it whole.
05Save and restore the draft
The fourth example mounts a composer, types, unmounts (a reload) and mounts again: the text is back. It sends, and after the next reload the box is empty. With storage that throws QuotaExceededError, typing still works and save reports false. Storage is passed in as an argument, so the failure can be tested without breaking the browser.
06Keep the box usable while streaming
During a stream the textarea stays enabled and keeps focus. The button changes from Send to Stop. If the person types and presses Enter, nothing is sent and the text stays, ready for when the answer ends.
Try it yourself
- 1
Add Ctrl+Enter
Some products send on Ctrl+Enter or Cmd+Enter and let plain Enter add a line. In
decideKey(first example) add this line right after the line that checks for IME composition (so a Ctrl+Enter that confirms a word never sends):if (e.ctrlKey || e.metaKey) return busy ? "ignored: an answer is running" : text.trim() ? "send" : "ignored: nothing to send";. Then add onepresswithctrlKey: trueto the list.You will also need to pass
ctrlKeythroughpressinto theKeyboardEvent. In a product that sends on Ctrl+Enter, plain Enter would instead return"newline". This is a product choice. Whatever you pick, put the hint next to the box. - 2
Change the limit
In the third example set
LIMITto5000and run.The "paste of 5,000" line is now
nearwith Send enabled, and the "4,001" line isok. The states move together because everything reads the one constant. - 3
Count the emoji both ways
In the third example change
familyto"🇮🇳"(a flag) and read the first emoji line.A flag is two code points that belong together, one character to a person.
.lengthand the grapheme count disagree again, which is why counting by.lengthis not what a user sees.
Code & diagrams
Real people cannot type in this example, so it fires the same events a keyboard and an input method would. Each line is one key press: what the function decided, whether the browser's own line break was blocked, and how many messages have been sent. The box does not need a real IME to prove the rule.
Preview
Console
Nothing logged yet.Expected console output
Enter with text -> send | default prevented: true | sent so far: 1
Enter while the answer runs -> ignored: an answer is running | default prevented: false | sent so far: 1
Shift+Enter -> newline | default prevented: false | sent so far: 1
Enter while the IME word is open -> ignored: IME is choosing a word | default prevented: false | sent so far: 1
Same, but isComposing reads false -> ignored: IME is choosing a word | default prevented: false | sent so far: 1
Enter right after compositionend -> ignored: IME is choosing a word | default prevented: false | sent so far: 1
Enter again, a normal key press -> send | default prevented: true | sent so far: 2
Enter on an empty box -> ignored: nothing to send | default prevented: false | sent so far: 2
three quick Enters sent: 1
everything sent: [ 'Where is ORD-1042?', 'こんにちは', 'Do you ship abroad?' ]The log holds yes-or-no answers, not pixel sizes, because sizes depend on the screen. Try the same code without the height = auto line (the Break it below) to see the box that only ever grows.
Preview
Console
Nothing logged yet.Expected console output
3 lines are taller than 1 line: true
12 lines are taller than 3 lines: true | capped, not 12 lines tall: true | scrolls inside: true
a long wrapped line also grows: true
shrinks back when cleared: true
same text gives the same height again: trueA 5,000-character paste is over the 4,000 limit, so Send would be disabled and the text kept. The server line shows a different, looser rule on purpose: the server limits the size of the request body in UTF-16 units to stop abuse, and your product limit stays in the page. Pick the numbers for your product; these are examples.
Preview
Console
Nothing logged yet.Expected console output
empty {"chars":0,"state":"ok","aboutTokens":0,"sendEnabled":false}
short question {"chars":18,"state":"ok","aboutTokens":5,"sendEnabled":true}
3,600 characters {"chars":3600,"state":"near","aboutTokens":900,"sendEnabled":true}
exactly 4,000 {"chars":4000,"state":"near","aboutTokens":1000,"sendEnabled":true}
4,001 {"chars":4001,"state":"over","aboutTokens":1001,"sendEnabled":false}
paste of 5,000 {"chars":5000,"state":"over","aboutTokens":1250,"sendEnabled":false}
family emoji: .length 8 | code points 5 | what a person sees 1
naive slice well-formed: false | ends with: "a\ud83d"
grapheme-safe trim well-formed: true | length in characters: 4000
server accepts the 5,000 paste: true | a 60,000-unit body: falseReloading is simulated by unmounting and mounting the component again against the same storage. The storage is a small object passed in, so the full-storage case can be tested. In the browser you would pass `localStorage`.
Preview
Console
Nothing logged yet.Expected console output
typed, saved draft: "Where is ORD-10"
after reload the box holds: "Where is ORD-10"
sent: ["Where is ORD-10"] | draft after sending: null
after sending and reloading: ""
storage full, box still holds the text: "a long question that cannot be saved" | save worked: falseBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget to clear the box when sending
In the first example delete the line box.current.value = ""; // cleared now, in the DOM, … inside onKeyDown.
Break #2
Trust only isComposing
In the first example change the IME line to if (e.nativeEvent.isComposing || e.keyCode === 229) return … (delete || composing).
Break #3
Forget to reset the height
In the second example delete the line el.style.height = "auto";.
Myth vs fact
Myth
Enter always means send.
Fact
Not while an input method is composing a word, not with Shift held, and not when nothing is typed. And on phones Enter often means a new line.
Myth
text.length is the number of characters.
Fact
It counts UTF-16 units. An emoji can be 2 to 8 units. Count graphemes with Intl.Segmenter for what a person sees.
Myth
A character counter on the page protects the server.
Fact
The page can be bypassed. The server must check size too (Topic 1.5).
Myth
Disable the whole form while an answer streams.
Fact
That blocks typing the next question and drops keyboard focus. Disable only what must be disabled (the send action) and keep the text box editable.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Draft keys should include the conversation id (
mitra-draft-v1:<chat id>), or a draft typed in one chat will appear in another. Version the key so a change in format does not restore garbage. With several tabs open, thestorageevent tells you when another tab changed the draft. - ▸
A very long paste can freeze a page if every change triggers a layout measure of a huge textarea. Measure once per frame, or skip the auto-grow measurement above a few thousand characters.
- ▸
Read the current value from the DOM (
box.current.value) in key handlers instead of from React state when a decision must be right inside one task. State is correct after the next render; the DOM is correct now. - ▸
Intl.Segmenteris built into current browsers, but check support for your targets and keep a fallback (the code point count,[...text].length) that is close enough.
Remember this
- 1
The problem this solves: People paste a whole email, write in Japanese or Hindi with an input method, press Enter by habit, double-tap Send on a slow phone, and close the tab halfway through a long question. A plain
<input>fails every one of those. A small number of precise rules fixes them, and each rule is easy to test with fake key events, as the examples do. - 2
**Use a
<textarea>, not an<input>.** An input is one line. Questions to an AI are often several lines (a pasted error, a list). A textarea grows to fit what is typed. It needs an accessible name (aria-label="Your question"; a placeholder alone is not a label). The send control is a real<button>. - 3
Enter and Shift+Enter. In chat apps people expect Enter to send and Shift+Enter to add a line. Handle
keydown: ifkey === "Enter"andshiftKeyis false, callpreventDefault()(otherwise the textarea also inserts a line break) and send. IfshiftKeyis true, do nothing and let the browser add the line. On phones, Enter usually adds a line and people send with the button, so never make Enter the only way to send. - 4
IME composition. Typing Japanese, Chinese or Korean (and several other scripts) goes through an input method editor (IME): you type sounds, the IME shows candidate words, and Enter confirms the chosen word. That Enter is not "send". The browser tells you:
compositionstartfires when a composition begins,compositionendwhen it ends, andKeyboardEvent.isComposingistruefor key events fired inside that window (MDN: "aftercompositionstartand beforecompositionend"). If your handler sends on every Enter, Japanese users send half-typed sentences. The first example checksisComposing, a flag set by the composition events, andkeyCode === 229, because browsers differ on the order of events and some report the confirming key outside the composition window. MDN marksisComposingas limited availability, so test on the browsers you support instead of trusting one rule. - 5
Rapid Enter presses. A second Enter can arrive before React has re-rendered with
busy = true. The first example sends once for three Enters in the same instant, because it clears the box in the DOM at the moment of sending (box.current.value = ""), and the next Enter sees an empty box. React state is the wrong tool for a guard that must work inside one task (Topic 10.4 shows the same with a ref). - 6
Growing, then scrolling. The box should grow with the text up to a limit of about five lines, then scroll inside itself. The second example does this in JavaScript: set the height to
auto(so it can shrink), measurescrollHeight, set the height to the smaller of that and the maximum, and turn scrolling on only at the maximum. Forgettingautois the classic bug: the box grows and never shrinks back. The CSS propertyfield-sizing: contentdoes this without JavaScript for a textarea; MDN lists it as newly available across browsers since June 2026, so check the compatibility table before you drop the script, and keepmax-heightso it stops growing. - 7
Character and token limits. Say the limit in the units a person can see. A person counts what they see: a family emoji such as 👨👩👧 is one character to them, but
.lengthis 8 and it is 5 code points, so count graphemes withIntl.Segmenter. A token estimate (the example uses length divided by four) is only a rough rule for English and is not a count. Real counts depend on the model's tokenizer, so show the estimate as "about", and let the server be the enforcer. The limit itself is your product choice; the model's own context window is a separate, larger number (/llm-engineering). - 8
A very long paste. Do not silently cut it. Keep what was pasted, show the count ("5,000 / 4,000"), disable Send, and offer a clear next step: shorten it or attach it as a file (Phase 11). If you do trim, trim by graphemes: cutting at a UTF-16 position can split a character in half and leave a broken string (the example shows
isWellFormed()returningfalse). And repeat the limit on the server, because the page can be bypassed. - 9
Disabled states. While an answer streams, leave the textarea enabled so the person can start typing the next question, and turn the Send button into Stop (Topic 3.1). Disabling the whole form blocks them and also moves keyboard focus away (Topic 10.6). Enter while busy must not send; in the first example it does what a textarea normally does and the text stays.
- 10
Draft saving. If the tab closes, the page reloads or the network drops, what the person typed should survive. Save on every change under a versioned key, restore on mount, and clear on a successful send. Storage can be full or blocked and then it throws, so wrap it and keep typing working. Do not save a draft for a message the person has already sent; the fourth example shows exactly that bug.
Explain it without notes
Why must a chat input check for IME composition before sending on Enter?
How do you make three quick Enter presses send only once?
Why does an auto-growing textarea need height = "auto" before measuring?
What should happen when a user pastes 5,000 characters into a box that allows 4,000?
Practice
Make the Send button's label say "Too long" when check(text).state === "over", using the third example's check function, and log the label for 3,000 and 4,500 characters.
Make the fourth example save drafts per conversation. The storage key must change with chatId, and the draft of chat B must not appear in chat A.
Trade-offs
- ↔
Enter-to-send is fast for short chats and dangerous for long, multi-line prompts. Many products choose Enter to send and show a hint, some use Ctrl+Enter for long-form tools. Pick for the typical user and state it next to the box.
- ↔
The JavaScript auto-grow works everywhere and costs a layout measurement per change.
field-sizing: contentis simpler where supported. Use the CSS version with the script as a fallback until your support matrix allows dropping it.
Done when you can
I can make Enter send and Shift+Enter add a line, and say what the first line of the handler guards against.
I can explain IME composition and use
isComposing, the composition events andkeyCode229 together.I can make a textarea grow to a maximum, scroll, and shrink back.
I can count characters the way a person does, handle a 5,000-character paste, and trim safely.
I can save, restore and clear a draft, and survive full or blocked storage.