Composer: CodeMirror editor, unified prompt language, ChatInput decomposition (#2419)

* refactor(ui): unify composer @mention grammar

Extract the composer's @mention rule into composer/language/mentions.ts and
route highlighting, send-time extraction, backspace-deletes-the-mention and
the file-path check through it. The rule previously lived as four separate
regexes in ChatInput.tsx with divergent cleanup, so every new reference type
had to be taught to all four.

Bracket handling is now symmetric: [ and { were accepted before @ but ] and }
were not stripped from the tail, so [@plan] resolved to the name 'plan]'.

Add characterization tests for the markdown tokenizer, which had none, to
pin current behavior ahead of the editor migration.

* refactor(ui): unify composer slash, snippet and trigger grammar

Extract /skill, /command and #snippet scanning into
composer/language/prefixTokens.ts, and the rule deciding which autocomplete a
caret asks for into composer/language/triggers.ts.

Each sigil previously had three separate implementations: one for
highlighting, one for send-time collection, and one for opening the picker.
They disagreed on the valid character set — the send-time skill scanner
accepted only lowercase names, so a /My_Skill token was painted as a command
but never collected. Scanning is now generous and membership in the command,
skill or snippet registry is the authority.

resolveAutocompleteTrigger replaces the 90-line branch chain in
updateAutocompleteState with a pure function, keeping the previous
command > skill > snippet > mention precedence.

* refactor(ui): tokenize the composer in a single pass

Add composer/language/tokenize.ts as the one entry point producing every
highlight range from the text plus what the composer knows about the
workspace. It replaces six independent memos in ChatInput.tsx that each
re-scanned the same string for markdown, fenced code, mentions, slash tokens,
snippet tokens and attachment citations.

Mentions now distinguish the reference span from the raw token: 'see @a/b.ts,'
highlights @a/b.ts and leaves the comma as sentence punctuation, while
backspace still deletes the whole token so a wrapping bracket is not orphaned.

* feat(ui): add the CodeMirror composer editor

Add composer/editor: a CodeMirror view that renders the prompt language as
mark decorations, and a controlled React primitive around it.

The composer previously painted a transparent textarea over a mirror div,
which restricted highlighting to styles that do not change glyph advance
width -- so bold and italic were impossible and the overlay was disabled
outright on mobile, where wrapped text drifted from the caret anyway.
CodeMirror owns the text and the caret together, removing the second layer.
The document stays a plain string, so nothing downstream has to serialize a
rich model back into a prompt.

Split resolveHighlightSegments out of buildHighlightParts so the mirror
overlay and the editor decorations share one priority resolution.

Not yet wired into ChatInput. Editor rendering is unverified: the package has
no DOM test environment, so only the state-level extension is covered.

* refactor(ui): move the composer onto the CodeMirror editor

Replace the transparent-textarea-over-mirror-div composer with ComposerEditor.
The mirror is gone, and with it the constraint that highlighting may only use
styles which do not change glyph advance width, and the mobile carve-out that
disabled highlighting entirely because wrapped text drifted from the caret.

Removals the editor makes unnecessary:
- measureCaretInTextarea, 46 lines of hand-built text mirroring for popup
  placement, replaced by the editor reporting caret coordinates
- adjustTextareaHeight and its two layout effects, replaced by the editor
  sizing itself; the dictation transcript height is now an explicit floor
- getInsertedTextFromChange, which diffed old against new text to recover what
  a paste inserted; the editor reports the change directly
- the highlight mirror element, its scroll-sync and its parts memo
- a _commandMetadata property stashed on the textarea and never read

Caret placement no longer needs a requestAnimationFrame after a text edit:
text and selection travel in one transaction.

Runtime behavior is unverified -- the package has no DOM test environment.
Type-check, lint, the 295 chat tests and a production web build pass.

* refactor(ui): extract composer text and path helpers

Move the composer's text-splicing rules to composer/text.ts and its path
handling to composer/attachments/filePaths.ts, with tests. None of this logic
was covered before, despite handling VS Code drop payloads, percent-encoded
file URIs and Windows drive letters.

normalizeDroppedPath and toProjectRelativeMentionPath were useCallbacks that
closed over nothing but their argument and the search directory; they are now
plain functions taking the root explicitly.

* refactor(ui): make the composer's slash commands a table

Nine of the composer's local commands did the same thing -- render a visible
magic prompt plus synthetic instructions and send them as one message -- and
that shape was written out nine times as an else-if chain. Adding a command
meant copying twenty lines and remembering to change every string.

composer/submit/slashCommands.ts holds the commands as data and the shape as
one executor; ChatInput keeps only the five that manipulate session state or
open UI. The command names, prompts and failure toasts are now typed against
the magic-prompt and i18n key unions, so a mistyped key fails to compile
rather than failing at runtime.

Net: 257 lines of branching become 68.

* refactor(ui): move the composer's footer components out of ChatInput

RevertedMessageDock, ComposerAttachmentControls, PermissionAutoAcceptButton,
FocusModeButton and ComposerActionButtons each own a piece of the composer
chrome and were defined inline above the 4500-line component. They now live
under composer/ui with the imports they actually need.

Pure moves; getRevertedPreview travels with the dock, its only caller.

* refactor(ui): extract composer draft persistence

Move the draft lifecycle -- identity switching, debounced writes, external
deletion, and the flush-on-hide/freeze/pagehide edges -- into
composer/state/useComposerDraft.

It was seven interleaved effects and five refs sharing state through the
component body, which made the ordering constraints between them (skip the
next debounced write while restoring; record the empty signature before a
queued write can resurrect a deleted draft) invisible. They are now stated
where they apply.

* refactor(ui): extract drop payload inspection

hasDraggedFiles, collectDroppedFiles and collectDroppedFileUris were
useCallbacks with empty dependency arrays -- pure DataTransfer readers wearing
React clothing. They move to composer/attachments/dataTransfer.ts with tests
covering the host differences they exist for: browser File lists, VS Code's
proprietary tree types, OpenChamber's own internal-drag marker, and getData
throwing during dragover.

* refactor(ui): extract the mobile composer shell

Move the pill state machine into composer/state/useMobileComposerShell and
the visual-viewport pinning into composer/state/useMobileViewportPin.

Between them they held eight refs, four pieces of state and eleven effects
interleaved with the rest of the composer, which hid what they are: not one
state machine but a state machine plus a set of 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.

Verbatim moves: every timeout, flushSync and guard keeps its value and its
reason, because none of them is verifiable outside a real device.

* refactor(ui): extract outgoing message assembly

A single send can carry queued messages, the composer text, inline review
comments, resolved @file attachments, a linked issue or PR, synthetic parts
from conflict resolution, and a skills instruction -- all flattened into
OpenCode's one-primary-plus-parts shape. The flattening rules were spread
through handleSubmit with no coverage, so the ordering they encode (oldest
queued message becomes primary; inline comments attach to the last authored
body, not a new part; PR instructions precede the diff) was trusted rather
than checked.

buildOutgoingMessage is a pure function over injected resolvers, with 25 tests
covering that ordering.

* refactor(ui): extract new-session draft targeting

Move project and worktree selection for the new-session draft into
composer/state/useDraftTarget.

The rule worth naming is that the draft can point at a directory that does not
exist yet -- a worktree still being created. It has to survive not appearing
in the branch list, or the selector snaps back to the project root mid-creation
and the session starts in the wrong place.

* refactor(ui): extract composer context chips and linked reference rows

The chips standing for attached-but-not-typed context (review comments, dev
server logs, preview annotations, terminal selections) were four near-identical
inline blocks; three of them collapse into one CountChip.

The linked issue and linked PR rows were 100 lines of duplicated markup
differing only in their number label, their branch line and which picker they
reopen. They are now one LinkedReferenceRow.

* refactor(ui): extract draft target selectors

Move the project and branch pickers into composer/ui/DraftTargetSelectors:
inline selects for desktop, trigger buttons and bottom sheets for mobile, all
rendering the same options from useDraftTarget.

The project label -- custom icon image, configured icon, or a folder fallback,
with the project name -- was a useCallback rendering JSX and used from four
places; it is now a ProjectLabel component.

* refactor(ui): extract the collapsed mobile pill composer

Move the pill into composer/ui/MobilePillComposer. Both places that ask
dictation to start now go through one toggleDictation callback rather than
dispatching the global event inline.

* refactor(ui): extract the composer footer

Move the footer row into composer/ui/ComposerFooter, which now owns the
desktop and mobile layouts and the components they place. ChatInput keeps only
the handlers it passes in.

* refactor(ui): collapse the composer's four autocomplete states into one

The composer tracked each picker with its own show flag and query string,
which encoded 'exactly one is open' as four booleans that had to be kept
mutually exclusive by hand. It is now one openAutocomplete kind plus one
query, which is what resolveAutocompleteTrigger already returns.

The four popup blocks -- identical apart from their component and caret width
-- become ComposerAutocompletePopups.

* refactor(ui): extract autocomplete positioning and message history

useAutocompletePosition owns caret-relative popup placement, which only
applies in focus mode.

useMessageHistory owns arrow-key recall. Its transitions are pure functions
with tests: entering history stashes the draft exactly once, so walking back
several messages and returning still restores what the user actually typed
rather than the last recalled message.

* docs: document the composer module

Record what each layer owns and the invariants that are not visible from the
code: that the prompt language is the single source of truth for syntax, which
ordering rules in the submit assembly and draft lifecycle are load-bearing,
that the mobile hooks are platform corrections rather than state machines, and
that rendering, focus, keyboard and WKWebView behavior are not covered by
tests and must be verified by hand.

* fix(ui): restore the composer caret colour and click-to-focus

Two regressions from the editor migration.

The caret rendered black in dark themes. CodeMirror's base theme hard-codes it
through '.cm-editor.cm-light .cm-content', one class more specific than the
plain '.cm-content' rule the composer theme used, so the base theme won.
Matching that specificity with '&.cm-editor' fixes both variants.

Clicking the composer's empty space no longer focused it. A textarea filled
its box, so the browser placed the caret for any click inside it; CodeMirror's
content element covers only the text. The content box now stretches to the
full editor height, and clicks landing outside it — in the composer's padding
— are forwarded to the nearest text position.

The theme moves to its own module with a test that installs it. EditorView.theme
compiles selectors at import and throws on scopes it was not given, including
'&light' and '&dark'; neither the build nor the type-check catches that, and
the failure takes the whole composer down at runtime.

* fix(ui): colour the composer caret where it is actually drawn

The previous fix styled caret-color, which drawSelection() overrides with
'transparent !important' at the highest precedence -- it hides the native
caret and draws its own .cm-cursor element, whose base style is a hard-coded
'border-left: 1.2px solid black'. So the caret stayed black on dark themes.

CodeMirror recolours that cursor only for editors that declare themselves
dark. OpenChamber themes are not merely light or dark, so the cursor takes the
surface foreground directly instead.

The theme spec is exported and asserted against: the caret rule must target
.cm-cursor, must not style caret-color, and must carry enough specificity to
beat CodeMirror's own &dark override.

* feat(ui): add emphasis, attention and path highlighting to the composer

The constructs the editor migration was for.

- **bold** and *italic* render as real weight and slant. They are additive
  styles: a segment carries one class string, so choosing between weight and
  colour would lose one of them -- bold inside a heading now keeps the heading
  colour and gains weight.
- '!!! ' marks an attention line. Three marks, so a sentence ending in '!!'
  is not swallowed.
- '~path' highlights a path without attaching it, unlike '@path'. Inert by
  design: it feeds neither the autocomplete nor the send path.

False positives are excluded positionally rather than by character, since
these delimiters are ordinary prose: '2 * 3' and 'foo_bar' are not emphasis,
'~approximately' and '~1.2 seconds' are not paths.

Also fix the expanded composer, which kept the collapsed composer's eight-line
height cap: the editor scrolled inside an invisible window while the rest of
the surface sat empty.

* fix(ui): style the composer's selection instead of leaving CodeMirror's

Selecting text rendered it in CodeMirror's stock lavender, which buried the
token colours. drawSelection() paints its own layer and CodeMirror styles the
focused case through a six-class selector; the composer's rule was three deep
and lost.

The tint is translucent rather than the flat selection token: an opaque
selection hides the colours the composer exists to show, and selecting text
here is for moving it, not for stopping reading it.

Same failure shape as the caret, so the theme test now covers both.

* fix(ui): mute the composer placeholder

The placeholder rendered at full text brightness. Its colour referenced
--surface-mutedForeground, but the theme emits --surface-muted-foreground:
an unknown custom property makes the declaration invalid rather than falling
back, and since color inherits, the placeholder simply took the editor's text
colour while the source looked correct.

The theme test now rejects camelCased tokens outright, since this failure is
invisible in every check that does not render.

* fix(ui): create the composer editor before the expand gesture ends

The mobile pill expands with flushSync and focuses the editor on the very next
line, still inside the tap's call stack, because that is the only way a mobile
browser raises the keyboard. The EditorView was created in a passive effect,
which flushSync makes no promise about — so at the moment focus() was called
there was no view to focus.

With a textarea the element existed as soon as flushSync returned, which is
why this worked before the migration.

Creating the view in a layout effect restores that ordering.

* perf(ui): keep the composer editor alive across the mobile pill swap

The pill and the full composer are different subtrees, so expanding or
collapsing unmounted and rebuilt the editor. With a textarea that was one DOM
node. A CodeMirror view is extensions, state, document, decorations and a
first measure — all inside the tap's flushSync, before the browser is allowed
to paint the swap. The shape change therefore landed late enough to look
driven by the keyboard rather than by the tap.

The view now lives in a store owned by ChatInput and is detached and
re-attached instead of destroyed and rebuilt. Its extensions read callbacks
through a ref held by the store, so a kept view always calls into the mounted
instance; compartments move to module scope, since per-instance ones would be
unknown to a reused view's configuration.

Also restore the caret hold: WKWebView draws the caret as a native layer that
ignores CSS transforms and visibly flies across the screen during the keyboard
slide. The rule hiding it targeted textarea and input, which the composer is
no longer — and its caret is now a drawn .cm-cursor element rather than the
native one.

* debug(ui): on-screen timeline for the mobile composer swap

TEMPORARY, Capacitor-only. Two plausible fixes for the swap lagging behind
the keyboard changed nothing, so the theory behind them was wrong. This
overlay draws the event timeline straight onto the screen -- the tap, the
committed swap, the first paints after it, the keyboard choreography, and
whether the editor was created or re-attached -- so one screenshot replaces
guessing. It doubles as an asset-freshness check: no overlay means the app
runs a bundle from before this commit.

* fix(ui): put the composer swap on glass before the keyboard moves

The overlay timelines settled it. The swap itself was never slow: commit in
12ms, editor re-attach in 3ms, focus immediate. What lagged was presentation:
WKWebView stops presenting web frames the moment focus starts the keyboard
transition and holds the last presented frame until it ends. Focusing in the
same task as the swap meant the last presented frame still showed the pill —
paint-1 fired at 29ms, the next frame at 190ms, exactly when the keyboard
was already moving.

On expand, Capacitor now waits two frames before focusing, so the swapped
composer is presented first and the keyboard rises under it. The Capacitor
WebView raises the keyboard for a focus() outside the gesture task; mobile
browsers do not, so they keep the synchronous path.

The collapse direction had a genuine race, caught on one screenshot: the
oc:keyboard-intent collapse arrives a few milliseconds after blur on a
setTimeout(0), and React's scheduling of setFocused(false) can lose to it —
busyRef stays stale, the intent handler skips the instant collapse, and the
pill appears via the 250ms fallback, 370ms after the keyboard has gone.
The Capacitor blur branch now commits the state with flushSync.

The diagnostic overlay stays in until this is confirmed on device.

* fix(ui): raise the keyboard from the swap's first frame

Two frames of delay before focusing made expand visibly sequential: swap,
then keyboard. Focusing inside the first frame after the commit puts the
swap's frame into the rendering pipeline before the keyboard transaction
starts, so the keyboard rises from the tap and the composer appears during
the rise rather than after it.

* fix(ui): restructure the draft screen in the same frame as the pill swap

The draft screen centers its title over the space the composer leaves, and
its starter chips leave when the keyboard is up. The chips were keyed on
oc-keyboard-open, which lands with the keyboardWillShow bridge event ~100ms
after the tap — so expanding the composer restructured the page twice: once
at the swap (composer grows, title re-centers) and again mid-keyboard-rise
(chips vanish, title re-centers again). Chat has no centered content, which
is why it was already smooth and the draft screen was not.

A root class now announces the expanded composer from a layout effect, in the
same frame as the swap, and the chips key on it: one restructure, fused with
the pill morph, before the keyboard moves. The keyboard classes remain as
fallbacks for keyboard-up states that do not go through the pill.

* debug(ui): remove the mobile swap timeline overlay

The diagnostic did its job: it identified WKWebView's presentation pause
during keyboard transitions, the React-scheduling race in the collapse path,
and the draft screen's double restructure — all fixed and confirmed on
device.

* feat(ui): grow the mobile composer with content, drop the fullscreen handle

The swipe-up handle promised a fullscreen composer but the normal eight-line
cap already reached within a line of the same height, so the gesture bought
almost nothing and cost a 28px bar above the editor.

The composer now just grows with what is typed: a generous line cap plus a
CSS ceiling of the space the keyboard actually leaves, whichever is smaller
(the editor cap accepts both and takes min()). The handle, its swipe
gestures and the shell's touch plumbing are gone.

* fix(ui): measure the mobile composer ceiling instead of estimating it

The 220px chrome constant guessed at what surrounds the editor. The old
fullscreen handle guessed at nothing — it let flex distribute real space (and
on Capacitor even that silently failed: its h-full resolved against a
shrink-wrap parent, which is why the gesture bought almost nothing).

The ceiling is now measured the way the handle meant to: the screen container
is marked data-composer-bound, and the editor may grow until the composer
fills it — chrome around the editor read live from the DOM, so attachment
chips, the model row and keyboard resizes all shift the cap by themselves.

* fix(ui): keep a 4px gap between the grown composer and the header

On the chat screen the fully grown composer's border landed exactly on the
header's bottom edge. The gap is a visual design choice, not another chrome
estimate: the ceiling itself stays measured.

* fix(ui): show iOS selection handles without giving up typing speed

iOS pins its selection drag handles to the visible native selection and
colours them from the caret, while drawSelection() hides both. Removing
drawSelection(), or leaving the native caret visible while typing, both
make iOS answer every keystroke with severe input lag. Touch devices now
keep drawSelection() and layer a theme over it that re-shows the native
selection, plus an .oc-native-range marker that enables the native caret
only while a range is selected — when there is no caret to lag on.

* feat(ui): make file mentions editable instead of atomic-delete

Deleting a character inside an @file mention edited nothing and erased the
whole token. Mentions now edit like /skill tokens: a deletion changes the
text and the caret position reopens the file picker on its own. findMentionAt
and MentionToken.rawEnd existed only for the atomic delete and are removed.

* fix(ui): composer selection visibility and external-insert caret

Selection was nearly invisible for two reasons: the tint was mixed down from
--interactive-selection, which themes define with its own alpha (often under
10%); and the painted selection layer sits behind the content, so tokens with
their own background (inline code, fences) covered it entirely. The native
selection now shows on every device, not only touch — it paints over token
backgrounds — and its tint comes from --primary at 25%, a full-strength
colour in every theme. drawSelection() and the range-scoped native caret stay
exactly as before, so typing keeps the lag-free path.

External rewrites (add-to-chat, draft restore, history, dictation) also left
the caret at its old position, so the next insertion landed inside the
previous one. They now put the caret at the end, as the old textarea did, and
pin the scroller to the bottom once the layout settles — a transaction-time
scrollIntoView fires before the max-height cap exists and scrolls nothing.

* fix(ui): render ***triple emphasis*** as bold italic

The emphasis tokenizer capped delimiter runs at two characters, so ***x***
parsed as a stray asterisk plus an italic span. Runs of three now emit both a
strong and an emphasis range over the same content; the two are additive
styles, so they compose into bold italic.

* fix: insert mentions through editor dispatch

Places the caret immediately after an inserted mention
Avoids rewriting the whole message and jumping the scroll to the bottom
Falls back to appending inline text when no editor is available
This commit is contained in:
Bohdan Triapitsyn
2026-07-27 22:21:38 +03:00
committed by GitHub
parent 8801d69c66
commit 005b2e61b0
51 changed files with 8433 additions and 3671 deletions
@@ -0,0 +1,525 @@
/**
* The composer's text editor.
*
* This replaces the transparent-textarea-over-mirror-div arrangement the
* composer used before. That arrangement could only paint styles which do not
* change glyph advance width — colour, background, underline — because any
* metric change made the mirror drift out from under the caret. Bold, italic
* and any width-affecting affordance were therefore impossible, and the
* overlay had to be disabled outright on mobile, where wrapped text drifted
* anyway.
*
* CodeMirror owns the text and the caret together, so there is no second layer
* to keep aligned. The document remains a plain string — `getValue()` is
* exactly what gets sent — so nothing downstream has to serialize a rich
* document model back into a prompt.
*
* The component is a controlled primitive: it renders `value`, reports edits,
* and exposes an imperative handle for the caret-level operations the composer
* performs (insert a mention, restore a draft, replace a token). Every policy
* decision — what a key means, which picker opens, when to send — stays with
* the caller.
*/
import React from 'react';
import { history, historyKeymap, standardKeymap } from '@codemirror/commands';
import { Compartment, EditorState, Prec, type Extension } from '@codemirror/state';
import {
EditorView,
drawSelection,
keymap,
placeholder as placeholderExtension,
type KeyBinding,
} from '@codemirror/view';
import { cn } from '@/lib/utils';
import type { ComposerLanguageContext } from '../language/tokenize';
import { composerLanguage, setLanguageContext } from './composerLanguage';
import type { ComposerEditorViewStore } from './viewStore';
import { composerEditorTheme, composerNativeSelectionExtension } from './theme';
export interface ComposerSelection {
start: number;
end: number;
}
export interface ComposerChange {
value: string;
selection: ComposerSelection;
/** True when the edit came from a paste rather than typing. */
fromPaste: boolean;
/** The text this edit inserted, empty for deletions. */
insertedText: string;
}
export interface ComposerEditorHandle {
focus(options?: { preventScroll?: boolean }): void;
blur(): void;
isFocused(): boolean;
getValue(): string;
getSelection(): ComposerSelection;
setSelection(start: number, end?: number): void;
selectAll(): void;
/** Replace the current selection, leaving the caret after the insertion. */
insertText(text: string): void;
/** Replace an explicit range; the caret lands at `caret` or after the text. */
replaceRange(from: number, to: number, text: string, caret?: number): void;
/** Viewport coordinates of the caret, for positioning popups. */
caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
/** The scrollable element, for measuring and scroll compensation. */
getScrollDOM(): HTMLElement | null;
}
export interface ComposerEditorProps {
value: string;
onChange: (change: ComposerChange) => void;
/** Caret or selection moved without the document changing. */
onSelectionChange?: (selection: ComposerSelection) => void;
/**
* Key press before CodeMirror handles it. Return true to consume the
* event — this is where the composer routes autocomplete navigation,
* message history and send.
*/
onKeyDown?: (event: KeyboardEvent) => boolean;
onFocus?: () => void;
onBlur?: () => void;
onPaste?: (event: ClipboardEvent) => void;
languageContext: ComposerLanguageContext;
placeholder?: string;
editable?: boolean;
spellCheck?: boolean;
/** Mobile keyboards; ignored on desktop. */
autoCorrect?: boolean;
autoCapitalize?: 'none' | 'sentences';
/** Fill the available height instead of growing with the content. */
fillContainer?: boolean;
/** Lines of text shown before the editor starts scrolling. */
maxLines?: number;
/**
* Selector of the ancestor the composer must never outgrow. The cap is
* measured — the ancestor's height minus the chrome around the editor,
* both read from the DOM — and the smaller of it and `maxLines` wins.
*/
boundSelector?: string;
/** Breathing room kept between the grown composer and the bound's edge. */
boundGapPx?: number;
className?: string;
contentClassName?: string;
/**
* Keeps the underlying view alive across unmounts. Supply one from a parent
* that outlives the swap; without it the view is built and destroyed with
* the component, which is correct but expensive on an interaction path.
*/
viewStore?: ComposerEditorViewStore;
'aria-label'?: string;
'data-testid'?: string;
}
/**
* The text inserted by a transaction, used to tell a typed `@` from a pasted
* one. CodeMirror reports the change set directly, so this needs none of the
* prefix/suffix diffing a textarea's `onChange` required.
*/
function insertedTextOf(transaction: { changes: { iterChanges: (fn: (fromA: number, toA: number, fromB: number, toB: number, inserted: { toString(): string }) => void) => void } }): string {
let inserted = '';
transaction.changes.iterChanges((_fromA, _toA, _fromB, _toB, text) => {
inserted += text.toString();
});
return inserted;
}
/**
* Compartments are configuration keys, not per-view state, so one set can serve
* every editor. They live at module scope because a kept-alive view outlives
* the component that created it: per-instance compartments would be unknown to
* the reused view's configuration, and reconfiguring it would throw.
*/
const editableCompartment = new Compartment();
const placeholderCompartment = new Compartment();
export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEditorProps>(
function ComposerEditor(props, ref) {
const {
value,
languageContext,
placeholder,
editable = true,
spellCheck = false,
autoCorrect = false,
autoCapitalize = 'none',
fillContainer = false,
maxLines = 8,
boundSelector,
boundGapPx = 0,
className,
contentClassName,
} = props;
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(null);
// Callbacks reach the CodeMirror extensions through a ref: the view is
// built once and must not be torn down when a handler identity changes,
// which would drop focus mid-typing. When a view store is supplied the
// ref lives there, so a kept-alive view keeps calling into whichever
// component instance is currently mounted rather than a dead one.
const localHandlersRef = React.useRef(props);
const store = props.viewStore ?? null;
if (store && !store.handlers) store.handlers = { current: props };
const handlersRef = store?.handlers ?? localHandlersRef;
handlersRef.current = props;
// A layout effect, not a passive one: the mobile composer expands with
// `flushSync` and focuses the editor on the next line, still inside the
// tap's call stack, because that is the only way a mobile browser
// raises the keyboard. flushSync commits layout effects but makes no
// promise about passive ones, so creating the view there would leave
// nothing to focus — the keyboard would rise later, from some other
// path, and the composer would appear to transform only once it moved.
React.useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
// A kept view is re-attached rather than rebuilt. Its extensions
// already read through the shared handlers ref, so it needs nothing
// from this instance beyond a parent to live in; the effects below
// re-apply editable, placeholder, value and language context.
const keptView = store?.view;
if (keptView) {
host.appendChild(keptView.dom);
// Measurements taken while detached are meaningless; the view
// re-reads its geometry now that it is back in the document.
keptView.requestMeasure();
viewRef.current = keptView;
return () => {
keptView.dom.remove();
viewRef.current = null;
};
}
const interceptKeys: KeyBinding[] = [{
any: (_view, event) => handlersRef.current.onKeyDown?.(event) ?? false,
}];
const view = new EditorView({
state: EditorState.create({
doc: handlersRef.current.value,
extensions: [
history(),
// `drawSelection()` must stay even though the native
// selection is what actually shows (see the theme's
// comment on `composerNativeSelectionExtension`):
// removing it makes CodeMirror enforce cursor
// association on the native selection, which iOS
// answers with severe input lag.
drawSelection(),
composerNativeSelectionExtension,
EditorView.lineWrapping,
// Highest precedence: the composer's own keys must win
// over CodeMirror's defaults (Enter sends, ArrowUp
// walks history, Escape closes a picker).
Prec.highest(keymap.of(interceptKeys)),
keymap.of([...standardKeymap, ...historyKeymap]),
composerLanguage(handlersRef.current.languageContext),
editableCompartment.of(
EditorView.editable.of(handlersRef.current.editable ?? true),
),
placeholderCompartment.of(
placeholderExtension(handlersRef.current.placeholder ?? ''),
),
composerEditorTheme,
EditorView.updateListener.of((update) => {
const handlers = handlersRef.current;
const selection = readSelection(update.state);
if (update.docChanged) {
const fromPaste = update.transactions.some(
(transaction) => transaction.isUserEvent('input.paste'),
);
let insertedText = '';
for (const transaction of update.transactions) {
insertedText += insertedTextOf(transaction);
}
handlers.onChange({
value: update.state.doc.toString(),
selection,
fromPaste,
insertedText,
});
return;
}
if (update.selectionSet) {
handlers.onSelectionChange?.(selection);
}
}),
EditorView.domEventHandlers({
focus: () => { handlersRef.current.onFocus?.(); return false; },
blur: () => { handlersRef.current.onBlur?.(); return false; },
paste: (event) => { handlersRef.current.onPaste?.(event); return false; },
}),
EditorView.contentAttributes.of({
spellcheck: String(handlersRef.current.spellCheck ?? false),
autocorrect: handlersRef.current.autoCorrect ? 'on' : 'off',
autocapitalize: handlersRef.current.autoCapitalize ?? 'none',
...(handlersRef.current['aria-label']
? { 'aria-label': handlersRef.current['aria-label'] }
: {}),
}),
] satisfies Extension[],
}),
parent: host,
});
viewRef.current = view;
if (store) store.view = view;
return () => {
viewRef.current = null;
// A stored view is detached, not destroyed: the store owns its
// lifetime now, and whoever owns the store ends it.
if (store) {
view.dom.remove();
return;
}
view.destroy();
};
// Created once: every changing input is applied through a
// dispatch below rather than by rebuilding the view.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Controlled value: only write back when the prop and the document
// genuinely differ, otherwise every keystroke would round-trip and
// reset the caret.
React.useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
// An external rewrite (draft restore, history navigation,
// "add to chat", dictation insert) lands the caret at the END,
// matching what a plain textarea did when its value was
// replaced. Every rewrite that reaches here appends or
// replaces wholesale; keeping the old caret instead left it
// stranded before the inserted text, and the next insertion
// or keystroke landed inside the previous one.
selection: { anchor: value.length },
});
// A large insert can push the caret below the fold, and a
// transaction-time `scrollIntoView` cannot reach it: wrapped-line
// heights are still estimates during the update, and the
// grow-with-content effect applies the scroller's max-height cap
// through a ResizeObserver a frame later — at scroll time the
// overflow does not exist yet, so the scroller stays at the top.
// The caret is at the end here, so once the layout has settled
// (two frames: one for the cap, one after it) pin the scroller to
// the bottom.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (viewRef.current !== view) return;
view.scrollDOM.scrollTop = view.scrollDOM.scrollHeight;
});
});
}, [value]);
React.useEffect(() => {
viewRef.current?.dispatch({ effects: setLanguageContext.of(languageContext) });
}, [languageContext]);
React.useEffect(() => {
viewRef.current?.dispatch({
effects: editableCompartment.reconfigure(EditorView.editable.of(editable)),
});
}, [editable]);
React.useEffect(() => {
viewRef.current?.dispatch({
effects: placeholderCompartment.reconfigure(placeholderExtension(placeholder ?? '')),
});
}, [placeholder]);
// Grow with the content up to `maxLines`, then scroll. The limit is
// measured from the rendered line height rather than assumed, so it
// tracks the composer's responsive typography.
React.useEffect(() => {
const view = viewRef.current;
const host = hostRef.current;
if (!view || !host) return;
// Filling the container means there is no line limit — and the
// limit from the collapsed composer has to be released, or the
// expanded editor keeps scrolling inside an invisible eight-line
// window while the rest of the surface sits empty.
if (fillContainer) {
view.scrollDOM.style.maxHeight = '';
return;
}
const boundEl = boundSelector ? host.closest(boundSelector) : null;
// The bound's direct child our editor lives in: its height minus
// the scroller's is exactly the chrome around the editor — form
// paddings, model row, footer, attachment chips — measured live,
// so the cap needs no estimate of what surrounds the editor.
let branch: HTMLElement | null = null;
if (boundEl) {
branch = host;
while (branch.parentElement && branch.parentElement !== boundEl) {
branch = branch.parentElement;
}
}
const applyLimit = () => {
const lineHeight = parseFloat(
getComputedStyle(view.contentDOM).lineHeight || '',
);
if (!Number.isFinite(lineHeight) || lineHeight <= 0) return;
let cap = lineHeight * maxLines;
if (boundEl && branch) {
const chrome = branch.offsetHeight - view.scrollDOM.offsetHeight;
const available = boundEl.clientHeight - chrome - boundGapPx;
if (available > 0) cap = Math.min(cap, available);
}
const next = `${cap}px`;
// The scroller growing re-fires the observer with an unchanged
// result; writing only on change keeps that loop silent.
if (view.scrollDOM.style.maxHeight !== next) {
view.scrollDOM.style.maxHeight = next;
}
};
applyLimit();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(applyLimit);
observer.observe(host);
if (branch) observer.observe(branch);
if (boundEl) observer.observe(boundEl);
return () => observer.disconnect();
}, [boundGapPx, boundSelector, fillContainer, maxLines]);
React.useEffect(() => {
const view = viewRef.current;
if (!view) return;
const content = view.contentDOM;
content.setAttribute('spellcheck', String(spellCheck));
content.setAttribute('autocorrect', autoCorrect ? 'on' : 'off');
content.setAttribute('autocapitalize', autoCapitalize);
}, [autoCapitalize, autoCorrect, spellCheck]);
/**
* The composer box is bigger than its text: it carries padding, and in
* focus mode it fills the surface. Clicking that empty space has always
* put the caret in the text — with a textarea the element itself filled
* the box, so the browser did it. CodeMirror's content element does not
* extend into the padding, so the click has to be forwarded.
*/
const handleHostMouseDown = React.useCallback((event: React.MouseEvent) => {
const view = viewRef.current;
if (!view || view.state.readOnly || !view.contentDOM.isContentEditable) return;
// A click that already landed in the text needs no help, and
// forwarding it would break drag-selection.
if (view.contentDOM.contains(event.target as Node)) return;
event.preventDefault();
const position = view.posAtCoords({ x: event.clientX, y: event.clientY })
?? view.state.doc.length;
view.dispatch({ selection: { anchor: position } });
view.focus();
}, []);
React.useImperativeHandle(ref, (): ComposerEditorHandle => ({
focus(options) {
const view = viewRef.current;
if (!view) return;
// preventScroll matters on mobile, where the browser's own
// scroll-into-view fights the keyboard choreography.
view.contentDOM.focus({ preventScroll: options?.preventScroll });
},
blur() {
viewRef.current?.contentDOM.blur();
},
isFocused() {
return viewRef.current?.hasFocus ?? false;
},
getValue() {
return viewRef.current?.state.doc.toString() ?? '';
},
getSelection() {
const view = viewRef.current;
return view ? readSelection(view.state) : { start: 0, end: 0 };
},
setSelection(start, end = start) {
const view = viewRef.current;
if (!view) return;
const max = view.state.doc.length;
view.dispatch({
selection: {
anchor: Math.min(Math.max(start, 0), max),
head: Math.min(Math.max(end, 0), max),
},
});
},
selectAll() {
const view = viewRef.current;
if (!view) return;
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
},
insertText(text) {
const view = viewRef.current;
if (!view || !text) return;
const { from, to } = view.state.selection.main;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: from + text.length },
userEvent: 'input.type',
});
},
replaceRange(from, to, text, caret) {
const view = viewRef.current;
if (!view) return;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: caret ?? from + text.length },
userEvent: 'input.type',
});
},
caretCoords(position) {
const view = viewRef.current;
if (!view) return null;
const pos = position ?? view.state.selection.main.head;
const coords = view.coordsAtPos(pos);
return coords
? { top: coords.top, bottom: coords.bottom, left: coords.left }
: null;
},
getScrollDOM() {
return viewRef.current?.scrollDOM ?? null;
},
}), []);
return (
<div
ref={hostRef}
data-testid={props['data-testid']}
onMouseDown={handleHostMouseDown}
className={cn(
'composer-editor w-full',
// The editor fills the host so its content box can cover
// the whole clickable area rather than just the text.
'[&_.cm-editor]:h-full',
fillContainer && 'flex min-h-0 flex-1 flex-col',
className,
contentClassName,
)}
/>
);
},
);
function readSelection(state: EditorState): ComposerSelection {
const range = state.selection.main;
return { start: range.from, end: range.to };
}
@@ -0,0 +1,107 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import type { ComposerLanguageContext } from '../../language/tokenize';
import { composerLanguage, setLanguageContext } from '../composerLanguage';
const context = (overrides: Partial<ComposerLanguageContext> = {}): ComposerLanguageContext => ({
inputMode: 'normal',
knownAgentNames: new Set(['build']),
confirmedMentions: new Set(),
knownSlashNames: new Set(['review']),
knownSnippetTriggers: new Set(['sig']),
attachmentFilenames: [],
...overrides,
});
const stateWith = (doc: string, ctx = context()) =>
EditorState.create({ doc, extensions: composerLanguage(ctx) });
/** Every decorated stretch as [text, class]. */
const decorations = (state: EditorState) => {
const found: Array<[string, string]> = [];
const set = state.facet(EditorView.decorations)
.map((source) => (typeof source === 'function' ? null : source))
.find(Boolean);
if (!set) return found;
const iterator = set.iter();
while (iterator.value) {
const spec = iterator.value.spec as { class?: string };
found.push([state.doc.sliceString(iterator.from, iterator.to), spec.class ?? '']);
iterator.next();
}
return found;
};
const decoratedText = (state: EditorState) => decorations(state).map(([text]) => text);
describe('composerLanguage — initial decorations', () => {
test('decorates the references it knows about', () => {
expect(decoratedText(stateWith('ask @build to /review'))).toEqual(['@build', '/review']);
});
test('leaves unknown tokens undecorated', () => {
expect(decoratedText(stateWith('ask @stranger to /nothing'))).toEqual([]);
});
test('decorates markdown structure', () => {
expect(decoratedText(stateWith('# Title'))).toEqual(['#', 'Title']);
});
test('plain prose gets no decorations at all', () => {
expect(decoratedText(stateWith('just a sentence'))).toEqual([]);
});
test('an empty document is fine', () => {
expect(decoratedText(stateWith(''))).toEqual([]);
});
test('shell mode disables the language', () => {
expect(decoratedText(stateWith('@build /review', context({ inputMode: 'shell' }))))
.toEqual([]);
});
test('decorated spans carry the shared highlight classes', () => {
const [[, agentClass]] = decorations(stateWith('@build'));
expect(agentClass).toContain('status-success');
});
});
describe('composerLanguage — updates', () => {
test('editing the document retokenizes', () => {
const state = stateWith('hello');
const next = state.update({
changes: { from: 5, insert: ' @build' },
}).state;
expect(decoratedText(next)).toEqual(['@build']);
});
test('deleting a reference removes its decoration', () => {
const state = stateWith('@build hi');
const next = state.update({ changes: { from: 0, to: 7 } }).state;
expect(decoratedText(next)).toEqual([]);
});
test('a new registry repaints without touching the document', () => {
const state = stateWith('ask @deploy');
expect(decoratedText(state)).toEqual([]);
const next = state.update({
effects: setLanguageContext.of(context({ knownAgentNames: new Set(['deploy']) })),
}).state;
expect(decoratedText(next)).toEqual(['@deploy']);
expect(next.doc.toString()).toBe('ask @deploy');
});
test('a transaction that changes neither keeps the same decoration set', () => {
const state = stateWith('@build');
const next = state.update({ selection: { anchor: 0 } }).state;
expect(decoratedText(next)).toEqual(['@build']);
});
test('the document stays the plain string that gets sent', () => {
const state = stateWith('# Title\n@build /review #sig');
expect(state.doc.toString()).toBe('# Title\n@build /review #sig');
});
});
@@ -0,0 +1,189 @@
import { describe, expect, test } from 'bun:test';
import { EditorState } from '@codemirror/state';
import {
COMPOSER_EDITOR_THEME_SPEC,
NATIVE_SELECTION_THEME_SPEC,
composerEditorTheme,
composerNativeSelectionExtension,
} from '../theme';
const selectors = Object.keys(COMPOSER_EDITOR_THEME_SPEC);
const declarations = JSON.stringify(COMPOSER_EDITOR_THEME_SPEC);
describe('composerEditorTheme', () => {
/**
* EditorView.theme compiles its selectors when this module is imported and
* throws RangeError on a scope it was not given — `&light` and `&dark`
* among them, despite both appearing throughout CodeMirror's own base
* theme. A build and a type-check both pass happily on that mistake; it
* surfaces only in the running app, where it takes the composer down.
*/
test('its selectors compile and the theme can be installed', () => {
let failure: unknown = null;
try {
EditorState.create({ extensions: [composerEditorTheme] });
} catch (error) {
failure = error;
}
expect(failure).toBeNull();
});
/**
* The composer runs `drawSelection()`, which hides the native caret with
* `caret-color: transparent !important` and draws a `.cm-cursor` element
* instead. Styling `caret-color` looks correct and does nothing, leaving
* CodeMirror's hard-coded black cursor on dark themes.
*/
test('the caret is coloured where it is drawn, not on the native caret', () => {
expect(selectors.some((selector) => selector.includes('.cm-cursor'))).toBe(true);
expect(declarations.includes('caretColor')).toBe(false);
});
test('the drawn caret follows the theme rather than a fixed colour', () => {
const cursorRule = selectors.find((selector) => selector.includes('.cm-cursor'));
const rule = (COMPOSER_EDITOR_THEME_SPEC as Record<string, Record<string, string>>)[cursorRule!];
expect(rule.borderLeftColor.startsWith('var(--')).toBe(true);
});
/**
* CodeMirror's own `.cm-cursor` rule and its `&dark` override are one and
* two classes deep respectively; a bare `.cm-cursor` selector loses to the
* latter. `&.cm-editor` matches it.
*/
test('the caret rule is specific enough to beat the base theme', () => {
const cursorRule = selectors.find((selector) => selector.includes('.cm-cursor'));
expect(cursorRule!.startsWith('&.cm-editor')).toBe(true);
});
/**
* Same trap as the caret, one layer over: `drawSelection()` paints its own
* selection and CodeMirror styles the focused case through a six-class
* selector. A shorter rule silently loses and the selection renders in
* CodeMirror's stock lavender, which buries the token colours.
*/
test('the focused selection is styled at the depth CodeMirror uses', () => {
const focusedRule = selectors.find((selector) =>
selector.includes('.cm-focused') && selector.includes('.cm-selectionBackground'));
expect(focusedRule).toBeDefined();
expect(focusedRule!.includes('.cm-scroller')).toBe(true);
expect(focusedRule!.includes('.cm-selectionLayer')).toBe(true);
});
/**
* An unknown custom property makes the whole declaration invalid rather
* than falling back to something visible, so a misspelled token reads as
* "this element was never styled". `color` inherits, which is how a
* camelCased `--surface-mutedForeground` left the placeholder at full text
* brightness while looking perfectly correct in the source.
*/
test('every theme token is kebab-case, as the theme emits them', () => {
const tokens = [...declarations.matchAll(/var\((--[A-Za-z-]+)/g)].map((m) => m[1]);
expect(tokens.length > 0).toBe(true);
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
});
test('the selection is translucent so token colours survive it', () => {
const rules = selectors
.filter((selector) => selector.includes('.cm-selectionBackground'))
.map((selector) =>
(COMPOSER_EDITOR_THEME_SPEC as Record<string, Record<string, string>>)[selector]);
expect(rules.length > 0).toBe(true);
for (const rule of rules) {
expect(rule.background.includes('transparent')).toBe(true);
}
});
});
describe('composerNativeSelectionTheme', () => {
const nativeSelectors = Object.keys(NATIVE_SELECTION_THEME_SPEC);
const nativeDeclarations = JSON.stringify(NATIVE_SELECTION_THEME_SPEC);
/**
* Every device layers this over `drawSelection()`: the native selection
* paints over token backgrounds (the painted layer is hidden behind them)
* and iOS attaches its selection handles to it. `drawSelection()` must
* NOT be removed for that: without it CodeMirror starts enforcing cursor
* association on the native selection while typing in wrapped text, and
* iOS answers those programmatic selection moves with severe input lag.
*/
test('it compiles and can be installed', () => {
let failure: unknown = null;
try {
EditorState.create({ extensions: [composerNativeSelectionExtension] });
} catch (error) {
failure = error;
}
expect(failure).toBeNull();
});
/**
* `drawSelection()` hides the native selection through a `Prec.highest`
* theme with `!important` on `.cm-line ::selection`. Winning that back
* needs both `!important` and strictly more specificity, because the
* mount order of two highest-precedence themes is not something to bet
* on.
*/
test('the native selection is re-shown with enough weight to win', () => {
const rule = nativeSelectors.find((selector) => selector.includes('::selection'));
expect(rule).toBeDefined();
expect(rule!.includes('.cm-content')).toBe(true);
expect(rule!.includes('.cm-line')).toBe(true);
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
expect(value.backgroundColor.includes('!important')).toBe(true);
expect(value.backgroundColor.includes('transparent')).toBe(true);
});
test('the painted selection layer is hidden so highlights do not stack', () => {
const rule = nativeSelectors.find((selector) => selector.includes('.cm-selectionLayer'));
expect(rule).toBeDefined();
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
expect(value.display).toBe('none');
});
/**
* iOS colours its selection drag handles from the caret colour. With
* `drawSelection()`'s `caret-color: transparent !important` in effect the
* handles are drawn — invisibly. The native caret must come back with
* enough weight to win, and the drawn cursor layer must go so there are
* not two carets.
*
* BUT a visible native caret makes WebKit re-render its caret UI after
* every keystroke's decoration redraw — severe input lag. Both rules are
* therefore scoped to `.oc-native-range`, which only exists while a range
* is selected (when there is no caret to lag on).
*/
test('the native caret is re-enabled, since the handles take its colour', () => {
const rule = nativeSelectors.find((selector) =>
selector.includes('.cm-content')
&& (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[selector].caretColor);
expect(rule).toBeDefined();
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
expect(value.caretColor.startsWith('var(--')).toBe(true);
expect(value.caretColor.includes('!important')).toBe(true);
expect(rule!.includes('&.cm-editor')).toBe(true);
});
test('the native caret shows only while a range is selected', () => {
for (const selector of nativeSelectors) {
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[selector];
if (value.caretColor) {
expect(selector.includes('.oc-native-range')).toBe(true);
}
}
});
test('the drawn cursor layer is hidden so there are not two carets', () => {
const rule = nativeSelectors.find((selector) => selector.includes('.cm-cursorLayer'));
expect(rule).toBeDefined();
expect(rule!.includes('.oc-native-range')).toBe(true);
const value = (NATIVE_SELECTION_THEME_SPEC as Record<string, Record<string, string>>)[rule!];
expect(value.display).toBe('none');
});
test('every theme token is kebab-case, as the theme emits them', () => {
const tokens = [...nativeDeclarations.matchAll(/var\((--[A-Za-z-]+)/g)].map((m) => m[1]);
expect(tokens.length > 0).toBe(true);
expect(tokens.filter((token) => /[A-Z]/.test(token))).toEqual([]);
});
});
@@ -0,0 +1,97 @@
/**
* The composer's prompt language as a CodeMirror extension.
*
* `tokenizeComposer` already answers "what does this text mean"; this module
* is the thin adapter that turns its ranges into mark decorations and keeps
* them in sync with the document and with the workspace registries.
*
* Why this replaces the mirror overlay: a transparent textarea painted over a
* mirror div can only use styles that do not change glyph advance width, or
* the two layers drift apart and the caret lands in the wrong place. That is
* why bold and italic were never highlighted, and why the overlay had to be
* switched off entirely on mobile. CodeMirror owns the caret and the text, so
* there is no second layer to keep aligned and no metric restriction.
*/
import { RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
import { Decoration, EditorView, type DecorationSet } from '@codemirror/view';
import { resolveHighlightSegments, DEFAULT_HIGHLIGHT_CLASS } from '../../composerHighlight';
import { tokenizeComposer, type ComposerLanguageContext } from '../language/tokenize';
/**
* Replace the workspace knowledge the tokenizer resolves against. Dispatched
* when the agent, command, skill, snippet or attachment registries change —
* not on every keystroke, which only changes the document.
*/
export const setLanguageContext = StateEffect.define<ComposerLanguageContext>();
/**
* The context lives in editor state rather than in a closure so the decoration
* field can recompute from `(document, context)` alone, and so a context change
* repaints without remounting the view.
*/
const languageContextField = StateField.define<ComposerLanguageContext>({
create: () => EMPTY_CONTEXT,
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(setLanguageContext)) return effect.value;
}
return value;
},
});
export const EMPTY_CONTEXT: ComposerLanguageContext = {
inputMode: 'normal',
knownAgentNames: new Set(),
confirmedMentions: new Set(),
knownSlashNames: new Set(),
knownSnippetTriggers: new Set(),
attachmentFilenames: [],
};
/**
* Decorations for the whole document. The composer holds a prompt, not a
* source file: it is short enough that a full retokenize per change is
* cheaper and far simpler than incremental mapping, and it keeps the editor
* and the send path reading the exact same grammar.
*/
function buildDecorations(text: string, context: ComposerLanguageContext): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
for (const segment of resolveHighlightSegments(text, tokenizeComposer(text, context))) {
// Unstyled stretches need no decoration — the editor's own base text
// color already renders them.
if (segment.className === DEFAULT_HIGHLIGHT_CLASS) continue;
builder.add(segment.start, segment.end, Decoration.mark({ class: segment.className }));
}
return builder.finish();
}
const decorationField = StateField.define<DecorationSet>({
create: (state) => buildDecorations(state.doc.toString(), state.field(languageContextField)),
update(value, transaction) {
const contextChanged = transaction.effects.some((effect) => effect.is(setLanguageContext));
if (!transaction.docChanged && !contextChanged) return value;
return buildDecorations(
transaction.state.doc.toString(),
transaction.state.field(languageContextField),
);
},
provide: (field) => EditorView.decorations.from(field),
});
/**
* The composer language extension. Install once; feed it registry updates with
* `setLanguageContext`.
*/
export function composerLanguage(initial: ComposerLanguageContext = EMPTY_CONTEXT) {
return [
languageContextField.init(() => initial),
decorationField,
];
}
/** The context currently in effect, for callers that need to read it back. */
export function readLanguageContext(view: EditorView): ComposerLanguageContext {
return view.state.field(languageContextField);
}
@@ -0,0 +1,162 @@
/**
* The composer editor's layout, typography and caret.
*
* Token colours are not here: they come from the shared highlight classes the
* language layer emits, so the composer and the message list stay in step.
*/
import { EditorView } from '@codemirror/view';
/**
* Exported for the regression test, which asserts the caret is styled where it
* is actually drawn.
*/
export const COMPOSER_EDITOR_THEME_SPEC = {
'&': {
backgroundColor: 'transparent',
color: 'var(--surface-foreground)',
},
'&.cm-focused': { outline: 'none' },
'.cm-content': {
padding: '0',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
// The content box must cover the whole editor, not just the text, so
// clicking the empty space below the last line still lands in it.
minHeight: '100%',
},
// The caret is NOT the native one. `drawSelection()` hides that with
// `caret-color: transparent !important` at the highest precedence and
// draws its own `.cm-cursor` element, whose base style is a hard-coded
// `border-left: 1.2px solid black`. Styling `caret-color` here therefore
// does nothing at all — the border is what has to be coloured.
//
// CodeMirror recolours it for dark editors through `&dark .cm-cursor`,
// which needs the theme to declare itself dark. OpenChamber themes are not
// only light or dark, so the cursor takes the surface foreground directly
// instead. `&.cm-editor` matches the specificity of that `&dark` rule, and
// theme styles mount after the base theme, so this wins in every variant.
//
// The `&light` / `&dark` scopes are NOT usable here: EditorView.theme
// builds its selectors without scopes and throws RangeError on them the
// moment this module is imported.
'&.cm-editor .cm-cursor, &.cm-editor .cm-dropCursor': {
borderLeftColor: 'var(--surface-foreground)',
},
'.cm-line': { padding: '0' },
'.cm-scroller': {
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
overflowX: 'hidden',
},
// Kebab-case: the theme emits `--surface-muted-foreground`. A camelCased
// name here is not a missing colour but an invalid declaration, and since
// `color` inherits, the placeholder silently renders at full text
// brightness instead.
'.cm-placeholder': { color: 'var(--surface-muted-foreground)' },
// `drawSelection()` paints its own selection layer, and CodeMirror styles
// it for the focused editor through
// `&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground`
// — six classes deep, so anything shorter loses and the selection comes out
// in CodeMirror's stock lavender. Both rules below match the shape of the
// ones they replace: unfocused first, then the focused case.
//
// The tint is translucent on purpose. An opaque selection would bury the
// token colours the composer exists to show; the point of selecting text
// here is to move it, not to stop reading it.
'&.cm-editor .cm-selectionBackground, & .cm-selectionBackground': {
background: 'color-mix(in srgb, var(--interactive-selection) 45%, transparent)',
},
'&.cm-editor.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
background: 'color-mix(in srgb, var(--interactive-selection) 55%, transparent)',
},
// The native selection still shows through in places CodeMirror does not
// draw over, such as the placeholder. Same colour as the native-selection
// theme below, for the same reason: the selection token carries its own
// alpha and reads as nearly invisible when mixed down again.
'& ::selection': {
background: 'color-mix(in srgb, var(--primary) 25%, transparent)',
},
};
export const composerEditorTheme = EditorView.theme(COMPOSER_EDITOR_THEME_SPEC);
/**
* Every device keeps `drawSelection()` but shows the NATIVE selection through
* it, for two independent reasons:
*
* - iOS attaches its selection handles (the draggable pins after a
* double-tap) to the *visible* native selection, and `drawSelection()`
* hides it with `.cm-line ::selection { background: transparent
* !important }`, so the handles never appear and range selection is
* undiscoverable.
* - The painted selection layer sits *behind* the content, so any token with
* its own background — inline code, code fences — covers it completely and
* the selection is invisible inside those spans. The native selection
* paints over element backgrounds.
*
* Dropping `drawSelection()` entirely is NOT an option: without it CodeMirror
* clears the `nativeSelectionHidden` facet and starts enforcing cursor
* association on the native selection while typing in wrapped text —
* programmatic selection moves that iOS answers with severe input lag (each
* one also resets the keyboard's autocorrect context). Typing must stay on
* the drawn-selection code path; only the paint changes.
*
* Both rules below fight `drawSelection()`'s own `Prec.highest` theme, so
* they carry `!important` and one class more specificity
* (`.cm-content .cm-line` vs its `.cm-line`) to win regardless of style
* mount order. The painted selection layer is hidden rather than removed —
* two highlights would otherwise stack.
*/
export const NATIVE_SELECTION_THEME_SPEC = {
// Built from `--primary`, not `--interactive-selection`: themes define the
// selection token with its own alpha (often under 10%), so mixing it with
// transparent again leaves the highlight barely perceptible. `--primary`
// is a full-strength colour in every theme; a low mix of it reads as a
// classic editor selection while the token colours stay legible through it.
'& .cm-content .cm-line ::selection, & .cm-content .cm-line::selection': {
backgroundColor:
'color-mix(in srgb, var(--primary) 25%, transparent) !important',
},
// iOS derives the colour of its selection UI — the drag handles included —
// from the caret colour, and `drawSelection()` sets `caret-color:
// transparent !important` on both `.cm-content` and `.cm-line`. A visible
// native selection alone is therefore not enough: the handles get drawn,
// in transparent.
//
// But a visible native caret is not free either: while it shows, WebKit
// re-renders its caret UI after every keystroke's decoration redraw, which
// arrives as severe input lag. The handles only exist while a RANGE is
// selected — exactly when there is no caret — so the native caret (and the
// drawn cursor layer's absence) are scoped to `.oc-native-range`, which
// `composerNativeSelectionExtension` sets on the editor whenever the main
// selection is non-empty. Typing stays on the transparent-native-caret
// fast path.
'&.cm-editor.oc-native-range .cm-content, &.cm-editor.oc-native-range .cm-content .cm-line': {
caretColor: 'var(--surface-foreground) !important',
},
'&.oc-native-range .cm-scroller > .cm-cursorLayer': {
display: 'none',
},
// The layers live beside the content, as children of the scroller.
'& .cm-scroller > .cm-selectionLayer': {
display: 'none',
},
};
export const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
/**
* The native-selection arrangement, installed on every device: the theme
* above plus the `.oc-native-range` marker class that scopes its caret rules
* to the moments a range is actually selected. `editorAttributes`
* re-evaluates on every update, so the class follows the selection with no
* listener of its own.
*/
export const composerNativeSelectionExtension = [
composerNativeSelectionTheme,
EditorView.editorAttributes.of((view) =>
view.state.selection.main.empty ? null : { class: 'oc-native-range' }),
];
@@ -0,0 +1,32 @@
/**
* Somewhere to keep a composer editor alive across an unmount.
*
* The mobile composer swaps between a collapsed pill and the full composer,
* which are different subtrees — so the editor unmounts and mounts again on
* every keyboard toggle. With a textarea that cost nothing. Building a
* CodeMirror view is not nothing: extensions, state, document, decorations and
* a first measure, all inside the tap's `flushSync`, before the browser is
* allowed to paint the swap. That is enough to push the visible transformation
* past the keyboard animation, which is what it looked like from the outside.
*
* Handing the editor a store owned by something longer-lived lets the view be
* detached and re-attached instead of destroyed and rebuilt. Whoever creates
* the store owns the view's lifetime and must destroy it.
*/
import type { EditorView } from '@codemirror/view';
import type { ComposerEditorProps } from './ComposerEditor';
export interface ComposerEditorViewStore {
view: EditorView | null;
/**
* Where the view's extensions read their callbacks from. It lives here
* rather than in the component so a kept-alive view keeps calling into
* whichever instance is currently mounted, never a dead one.
*/
handlers: { current: ComposerEditorProps } | null;
}
export function createComposerEditorViewStore(): ComposerEditorViewStore {
return { view: null, handlers: null };
}