2026-07-27 22:21:38 +03:00
|
|
|
# Composer
|
|
|
|
|
|
|
|
|
|
The chat composer: the prompt language, the editor that renders it, and
|
|
|
|
|
everything between typing and sending.
|
|
|
|
|
|
|
|
|
|
`ChatInput.tsx` (one directory up) is the orchestrator. It holds the composer's
|
|
|
|
|
own state and wires these modules together; it should not grow logic that
|
|
|
|
|
belongs to one of them.
|
|
|
|
|
|
2026-08-21 17:27:07 +03:00
|
|
|
`ChatContainer.tsx` keeps one `ChatInput` mounted while a new-session draft
|
2026-08-22 00:51:32 +03:00
|
|
|
becomes its first session. Draft-only UI first fades for 120ms while the editor
|
2026-08-21 17:27:07 +03:00
|
|
|
stays in place. The parent then moves the editor to its final session position
|
2026-08-22 00:51:32 +03:00
|
|
|
with a 180ms transform-only FLIP animation. Reduced-motion mode skips these
|
2026-08-22 11:32:01 +03:00
|
|
|
transitions. `session-ui-store.ts` marks sessions materialized from a submitted
|
|
|
|
|
draft, so selecting an existing session while a draft is open switches without
|
|
|
|
|
animation. Do not restore separate draft and session composer branches:
|
2026-08-21 17:27:07 +03:00
|
|
|
remounting the editor loses focus and interrupts the transition. Keep the
|
|
|
|
|
existing mobile fixed-position rules unchanged.
|
|
|
|
|
|
2026-07-27 22:21:38 +03:00
|
|
|
## Layers
|
|
|
|
|
|
|
|
|
|
| Directory | Owns |
|
|
|
|
|
|---|---|
|
|
|
|
|
| `language/` | What the text *means*: `@` references, `/` and `#` tokens, markdown, and which picker a caret asks for |
|
|
|
|
|
| `editor/` | The CodeMirror view that renders the language and owns the caret |
|
2026-09-05 10:19:03 -06:00
|
|
|
| `state/` | Composer-local lifecycle state: ArrowUp/ArrowDown browsing, draft stash/restore, mobile shell, popup placement, draft targeting |
|
2026-07-27 22:21:38 +03:00
|
|
|
| `submit/` | Turning what the user has into what gets sent |
|
|
|
|
|
| `attachments/` | Files: paths, drop payloads |
|
|
|
|
|
| `ui/` | Presentation |
|
|
|
|
|
| `text.ts` | How inserted text meets the text already there |
|
2026-08-04 11:38:02 +00:00
|
|
|
| `largeTextPaste.ts` | Detect large plain-text pastes and build virtual `.txt` files |
|
2026-08-04 13:18:56 +00:00
|
|
|
| `largeTextPasteOffer.ts` | Ask-toast offer id begin/resolve (supersede + double-apply guards) |
|
2026-08-04 11:38:02 +00:00
|
|
|
|
|
|
|
|
`ChatInput.handlePaste` owns paste orchestration: URL-over-selection markdown
|
|
|
|
|
links, clipboard images (attach + citation), and large plain-text pastes.
|
|
|
|
|
Large pastes (about 2,000 characters or 25 lines) follow the composer setting
|
|
|
|
|
`largeTextPasteBehavior` (`ask` / `attach` / `inline`). Attaching creates an
|
|
|
|
|
in-memory `text/plain` file named `pasted-context-N.txt`, inserts a bracket
|
|
|
|
|
citation, and sends it through the same attachment pipeline as a manually
|
2026-08-04 13:18:56 +00:00
|
|
|
picked `.txt` file. Ask-toast actions read live composer/attachment state so
|
|
|
|
|
typing or other attaches between paste and choice stay consistent. Short text,
|
|
|
|
|
images, and URL wraps keep their existing paths.
|
2026-07-27 22:21:38 +03:00
|
|
|
|
|
|
|
|
## The prompt language
|
|
|
|
|
|
|
|
|
|
`language/` is the single source of truth for composer syntax. Everything that
|
|
|
|
|
needs to know what a token means — highlighting, send-time resolution, and the
|
|
|
|
|
autocomplete triggers — goes through it.
|
|
|
|
|
|
|
|
|
|
**This is the invariant that matters most in this module.** Before it existed,
|
|
|
|
|
the `@` rule was written four times with divergent cleanup and the `/` rule
|
|
|
|
|
three times with different valid character sets, so a token could be painted as
|
|
|
|
|
a reference and then not resolve as one. Adding a construct meant finding every
|
|
|
|
|
copy.
|
|
|
|
|
|
|
|
|
|
- `mentions.ts` — `@` references. The `start..end` span is the reference
|
|
|
|
|
itself and is what gets highlighted; in `see @a/b.ts,` the comma is sentence
|
|
|
|
|
punctuation, not part of the file being referenced. Mentions are plain
|
|
|
|
|
editable text: deleting a character edits the token and reopens the mention
|
|
|
|
|
picker, the same way `/skill` tokens behave — not an atomic delete.
|
|
|
|
|
- `prefixTokens.ts` — `/command`, `/skill`, `#snippet`. Scanning is deliberately
|
|
|
|
|
generous; **membership in the command, skill or snippet registry is the
|
|
|
|
|
authority**, not the pattern. An unknown `/token` stays plain prose.
|
|
|
|
|
- `triggers.ts` — which picker a caret position asks for. Exactly one can be
|
|
|
|
|
active, with precedence `command > skill > snippet > mention`.
|
|
|
|
|
- `tokenize.ts` — one pass producing every highlight range. Adding a construct
|
|
|
|
|
to the language means adding it here, once.
|
|
|
|
|
|
|
|
|
|
## The editor
|
|
|
|
|
|
|
|
|
|
`editor/` wraps CodeMirror. The document is a plain string: `getValue()` is
|
|
|
|
|
exactly what gets sent, so nothing downstream serializes a rich document model
|
|
|
|
|
back into a prompt.
|
|
|
|
|
|
2026-08-28 19:14:02 +03:00
|
|
|
The document is not, however, the string it was given: CodeMirror normalizes
|
|
|
|
|
line endings, so a `\r\n` pair becomes one break and the document ends up
|
|
|
|
|
shorter than the inserted string. **Never derive a caret position from the
|
|
|
|
|
length of text you are inserting** — a caret past the end makes `dispatch`
|
|
|
|
|
throw, the transaction never applies, and the un-normalized text stays in React
|
|
|
|
|
state to crash again on the next restore. Every edit that moves the caret goes
|
|
|
|
|
through `replaceWithCaret` (`editor/documentEdits.ts`), which measures the
|
|
|
|
|
change instead of the string.
|
|
|
|
|
|
2026-07-27 22:21:38 +03:00
|
|
|
The composer previously painted a transparent `<textarea>` over a mirror
|
|
|
|
|
`<div>`. That restricted highlighting to styles which do not change glyph
|
|
|
|
|
advance width — colour, background, underline — because anything else made the
|
|
|
|
|
mirror drift out from under the caret. Bold and italic were impossible, and the
|
|
|
|
|
overlay was disabled outright on mobile, where wrapped text drifted anyway.
|
|
|
|
|
**Those constraints are gone**; adding a width-affecting style is now a
|
|
|
|
|
question of design, not of feasibility.
|
|
|
|
|
|
|
|
|
|
Selection rendering: every device runs CodeMirror's `drawSelection()` — it
|
|
|
|
|
keeps typing on the drawn-selection code path, and removing it makes
|
|
|
|
|
CodeMirror enforce cursor association on the native selection, which iOS
|
2026-08-18 21:37:57 +03:00
|
|
|
answers with severe input lag. **That much is not platform-specific and must
|
|
|
|
|
not be undone.** What differs is who paints the selection, and
|
|
|
|
|
`composerSelectionExtension` (`editor/theme.ts`) picks that per platform.
|
|
|
|
|
|
|
|
|
|
When CodeMirror 6.43.9's iOS predicate does not match,
|
|
|
|
|
`composerNativeSelectionExtension` layers over `drawSelection()`: it re-shows
|
2026-07-27 22:21:38 +03:00
|
|
|
the native selection, and — only while a range is selected — the native caret,
|
|
|
|
|
hiding the painted layers those replace. The native selection is the one that
|
|
|
|
|
shows for two reasons: the painted layer sits behind the content, so tokens
|
2026-08-18 21:37:57 +03:00
|
|
|
with their own background (inline code, fences) cover it completely; and the
|
|
|
|
|
platform's selection drag handles attach to the visible native selection and
|
|
|
|
|
take their colour from the caret, so a transparent caret means invisible
|
|
|
|
|
handles. The range-only caret scoping is load-bearing — a native caret visible
|
|
|
|
|
while typing makes the browser re-render its caret UI after every keystroke,
|
|
|
|
|
felt as severe input lag.
|
|
|
|
|
|
|
|
|
|
When CodeMirror 6.43.9's exact iOS predicate matches,
|
|
|
|
|
`composerIOSSelectionExtension` leaves selection-handle geometry and appearance
|
|
|
|
|
to CodeMirror. CodeMirror puts the handles in `.cm-selectionLayer`, normally at
|
|
|
|
|
`z-index: -1`; the extension raises that layer above the content so opaque
|
|
|
|
|
token backgrounds cannot cover them, and leaves it transparent to touch.
|
|
|
|
|
The handle dots extend 8px past their range; matching scroller padding and
|
|
|
|
|
negative margin expand the clip area without moving the text or changing the
|
|
|
|
|
composer height. iOS still paints its taller system selection overlay even
|
|
|
|
|
when CSS makes `::selection` transparent. The extension therefore suppresses
|
|
|
|
|
CodeMirror's synthetic selection rectangles on iOS while leaving its handles,
|
|
|
|
|
cursor path and `nativeSelectionHidden` facet active. Otherwise the grey system
|
|
|
|
|
highlight and themed rectangle overlap with visibly different heights.
|
|
|
|
|
Do not add a second custom layer or custom handles here: overlapping translucent
|
|
|
|
|
rectangles make selection darker at their seams and imitated handles drift from
|
|
|
|
|
the geometry WebKit actually manipulates. What iOS avoids is installing the
|
|
|
|
|
native-selection workaround above: explicitly restoring native paint and caret
|
|
|
|
|
makes WebKit re-measure them after every decoration redraw, and the composer
|
|
|
|
|
rebuilds every decoration on every keystroke. That cost is felt worst during
|
|
|
|
|
IME composition.
|
|
|
|
|
|
|
|
|
|
The non-iOS native selection tint comes from `--primary`, not the selection
|
|
|
|
|
token: themes define `--interactive-selection` with its own alpha, so mixing it
|
|
|
|
|
with transparent again is nearly invisible. The iOS system overlay owns its
|
|
|
|
|
visible selection fill.
|
2026-07-27 22:21:38 +03:00
|
|
|
|
2026-07-30 00:17:05 +00:00
|
|
|
The content element keeps the existing correction policy: on in the mobile UI,
|
|
|
|
|
off elsewhere. CodeMirror also reads the attribute and reverts Apple and
|
|
|
|
|
Android's insert-period-on-double-space only when its value is exactly `off`.
|
|
|
|
|
`editor/autocorrect.ts` uses the HTML standard's
|
|
|
|
|
[ASCII case-insensitive `autocorrect` keywords](https://html.spec.whatwg.org/multipage/interaction.html#attr-autocorrect)
|
|
|
|
|
to keep desktop word correction off while avoiding that CodeMirror-only
|
|
|
|
|
revert. Its platform checks deliberately match CodeMirror's own browser flags.
|
2026-07-27 22:21:38 +03:00
|
|
|
|
|
|
|
|
`composerLanguage.ts` retokenizes the whole document on every change. The
|
|
|
|
|
composer holds a prompt, not a source file: it is short enough that a full pass
|
|
|
|
|
is cheaper and far simpler than incremental mapping, and it keeps the editor
|
|
|
|
|
and the send path reading the same grammar.
|
|
|
|
|
|
|
|
|
|
## Ordering rules worth knowing
|
|
|
|
|
|
2026-07-30 09:44:52 -07:00
|
|
|
- `editor/ComposerEditor.tsx` forwards a click on the composer's padding by
|
|
|
|
|
focusing the view *before* setting the selection: CodeMirror reveals its
|
|
|
|
|
drawn caret through a class it only writes while applying an update, so the
|
|
|
|
|
selection has to be the update that follows the focus.
|
2026-07-27 22:21:38 +03:00
|
|
|
- `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text,
|
2026-08-23 23:40:32 +03:00
|
|
|
context drafts and linked references into OpenCode's one-primary-plus-parts
|
|
|
|
|
shape. The oldest queued message becomes primary. **Every attached context
|
|
|
|
|
item (inline comments, terminal selections, browser annotations, PR context,
|
|
|
|
|
linked issue/PR) becomes its own synthetic text part carrying structured
|
|
|
|
|
metadata** built by `lib/messages/contextParts.ts`; the timeline reads that
|
|
|
|
|
metadata back to render context blocks. PR instructions precede the PR diff.
|
2026-09-04 22:55:35 +03:00
|
|
|
The same module's `buildComposerContext` captures that context when a message
|
|
|
|
|
is **queued** instead of sent: the chips leave the composer with the message
|
|
|
|
|
(as `QueuedContextPart`s on the queue item), the server or the VS Code
|
|
|
|
|
auto-send delivers them through `queuedContextToParts`, and editing the
|
|
|
|
|
queued message puts them back. A queued message is placed as captured — its
|
|
|
|
|
mention, file mentions, and skill instruction were resolved when it was
|
|
|
|
|
queued, never at delivery — and its context follows it before the next
|
|
|
|
|
queued message.
|
2026-09-05 06:28:17 -03:00
|
|
|
- Local slash commands are planned by `submit/slashCommands.ts` before any
|
|
|
|
|
attached context is consumed. Commands that act on session or UI state
|
|
|
|
|
(`/undo`, `/redo`, `/compact`, `/timeline`, `/handoff-review`) take only
|
|
|
|
|
their command text and leave comments, files, and linked context attached;
|
|
|
|
|
commands that produce a prompt (`/btw` and the magic prompts) send that
|
|
|
|
|
context with the prompt they produce. Session actions are planned only when
|
|
|
|
|
a session exists, so typing one into a new-session draft stays on the normal
|
|
|
|
|
send path. A local command is never queued as text: queueing runs it
|
|
|
|
|
instead. A failed prompt command restores everything it consumed: text,
|
|
|
|
|
confirmed mentions, files, comment drafts, and pending synthetic context.
|
2026-07-27 22:21:38 +03:00
|
|
|
- `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory,
|
|
|
|
|
session) identity. Writes are debounced while typing but forced at every edge
|
|
|
|
|
where the page may stop running, because a pending timer is not a saved
|
|
|
|
|
draft. Two orderings are load-bearing: the debounced write is skipped once
|
|
|
|
|
while a draft is being restored, and a deleted draft's empty signature is
|
|
|
|
|
recorded before a queued write could resurrect it.
|
2026-09-07 19:26:26 +02:00
|
|
|
Fork replay text and files arrive in `input-store.pendingComposerRestore`,
|
|
|
|
|
addressed to the fork's runtime, directory, and session. The hook consumes
|
|
|
|
|
them after loading that identity's draft. Selection alone is not enough:
|
|
|
|
|
the deferred chat column can still show the source composer. Ordinary
|
|
|
|
|
pending text insertions keep their existing path in `ChatInput`.
|
2026-07-27 22:21:38 +03:00
|
|
|
- `state/useDraftTarget.ts` — the draft can target a directory that does not
|
|
|
|
|
exist yet (a worktree being created). It must survive not appearing in the
|
2026-09-03 01:42:50 +03:00
|
|
|
branch list, or the selector snaps back to the project root mid-creation. It
|
|
|
|
|
also owns the advisory dirty state for the selected directory, clearing it as
|
|
|
|
|
soon as the target changes so a warning never names a previous branch.
|
2026-08-26 10:59:04 +03:00
|
|
|
- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker
|
2026-09-07 20:25:19 +03:00
|
|
|
state and registers its application shortcuts locally. The desktop project
|
|
|
|
|
picker is a searchable popup: it ranks the current projects with
|
|
|
|
|
`rankByQuery` over display label and path, keeps the query and the active
|
|
|
|
|
result as transient local state that resets on every close, and commits
|
|
|
|
|
through the existing project-change flow only on explicit activation.
|
|
|
|
|
Filtering changes the result area below the anchored input without moving
|
|
|
|
|
the search field. The
|
|
|
|
|
worktree Select and the mobile bottom sheets are unchanged. The selectors only
|
2026-08-26 10:59:04 +03:00
|
|
|
consume their shared prefix while the draft target UI is mounted.
|
2026-07-27 22:21:38 +03:00
|
|
|
|
2026-09-05 10:19:03 -06:00
|
|
|
## Input recall ownership
|
|
|
|
|
|
|
|
|
|
Prompt recall has two owners on purpose.
|
|
|
|
|
|
|
|
|
|
- `packages/ui/src/stores/useInputHistoryStore.ts` owns the persisted source of
|
|
|
|
|
truth. It keeps the runtime-scoped global bucket and the runtime + directory
|
|
|
|
|
+ session bucket, each capped by the configurable input-history limit. That
|
2026-09-05 20:13:35 +03:00
|
|
|
setting defaults to 40 entries. Recall reads the current session's bucket by
|
|
|
|
|
default; the Chat setting can widen it to every project on the runtime.
|
2026-09-05 10:19:03 -06:00
|
|
|
- `state/useMessageHistory.ts` owns only keyboard traversal through whichever
|
2026-09-05 20:13:35 +03:00
|
|
|
bucket the composer was given. Moving away from a position stores the
|
|
|
|
|
composer's current text and attachments as an overlay for that position, so
|
|
|
|
|
the live draft and any edit made to a recalled prompt survive a round trip
|
|
|
|
|
through history. Overlays never rewrite stored history; sending resets them.
|
|
|
|
|
- `ChatInput.tsx` applies the recalled text and attachments to the composer and
|
|
|
|
|
places the caret.
|
|
|
|
|
|
|
|
|
|
In session scope the composer merges two sources, oldest first: the visible
|
|
|
|
|
transcript's user prompts (`useUserMessageHistory` in `sync-context.tsx`), so
|
|
|
|
|
sessions that predate the persisted store still recall, and the persisted
|
|
|
|
|
session bucket, which adds attachments and keeps prompts a revert hid from the
|
|
|
|
|
timeline. A prompt present in both collapses to the persisted entry. Global
|
|
|
|
|
scope reads the persisted runtime bucket only.
|
2026-09-05 10:19:03 -06:00
|
|
|
|
2026-07-27 22:21:38 +03:00
|
|
|
## Mobile
|
|
|
|
|
|
|
|
|
|
`state/useMobileComposerShell.ts` and `state/useMobileViewportPin.ts` are
|
|
|
|
|
mostly not state machines but corrections for specific platform behaviors:
|
|
|
|
|
mobile browsers dismissing the keyboard before a tap's click lands, iOS
|
|
|
|
|
refusing programmatic focus outside a gesture, WebKit leaving the layout
|
|
|
|
|
viewport panned after the keyboard hides, overlay chains handing off through a
|
|
|
|
|
frame where nothing is open.
|
|
|
|
|
|
|
|
|
|
**Every timeout and `flushSync` in them has a reason recorded next to it, and
|
|
|
|
|
none of them is verifiable outside a real device.** Change them only against
|
|
|
|
|
hardware.
|
|
|
|
|
|
|
|
|
|
## Testing
|
|
|
|
|
|
|
|
|
|
The package has no DOM test environment, so coverage stops at the state and
|
|
|
|
|
logic layers: the language, the submit assembly, path and drop handling, text
|
2026-09-05 10:19:03 -06:00
|
|
|
splicing, large-paste detection, paste-offer invalidation, input-history
|
|
|
|
|
traversal, and the CodeMirror language extension at the `EditorState` level.
|
2026-07-27 22:21:38 +03:00
|
|
|
|
|
|
|
|
Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by
|
2026-09-05 10:19:03 -06:00
|
|
|
tests** and are verified by hand. That includes ArrowUp and ArrowDown recall,
|
|
|
|
|
caret placement after recall, restored drafts, and any edited-entry overlay.
|
|
|
|
|
Do not report a change to them as validated on the strength of type-check and
|
|
|
|
|
unit tests.
|
2026-07-27 22:21:38 +03:00
|
|
|
|
|
|
|
|
Run tests per file (`bun test <path>`): `mock.module` is process-global, so
|
|
|
|
|
suites that install module mocks are order-dependent.
|
2026-09-06 00:07:28 +08:00
|
|
|
|
|
|
|
|
## Enter preference
|
|
|
|
|
|
|
|
|
|
`keyboardPolicy.ts` owns the submission decision. Until the Chat setting is
|
|
|
|
|
changed, desktop Enter sends, mobile and focus mode require Ctrl/Cmd+Enter,
|
|
|
|
|
and Shift-modified Enter does not send. An explicit choice applies across
|
|
|
|
|
shared composers; Ctrl/Cmd+Enter sends in either configured mode.
|
|
|
|
|
|
|
|
|
|
CodeMirror's deferred mobile Enter loses modifier information. Untouched
|
|
|
|
|
settings restore Shift to keep the original policy. Once configured, with mobile
|
|
|
|
|
autocapitalization enabled, the editor cannot distinguish its Shift flag from
|
|
|
|
|
an intentional Shift press and does not restore Shift. Consequently, deferred
|
|
|
|
|
Shift+Enter can send when Enter-to-send is enabled and cannot serve as the send
|
|
|
|
|
shortcut when it is disabled. Ctrl/Cmd+Enter remains the supported modified
|
|
|
|
|
send shortcut on this path.
|