Writer Studio (standalone writing editor) (part 3)
Part 3 of the Writer Studio page: the four AI capabilities the editor ships — selection edits and their review flow, options-mode variants, inline ghost-text autocomplete, and the chat assistant with its suggested edits, persisted history and voice context.
What it is
This is part 3 of the Writer Studio (standalone writing editor) page. It covers the four AI capabilities the editor ships — selection edits and their review flow, options-mode variants, inline ghost-text autocomplete, and the chat assistant with its suggested edits, persisted history and voice context.
Where to find it
Writer Studio opens from the Writer virtual project in the sidebar, and the parent page covers gating, surfaces and first-run setup. The selection bubble menu and the header toggles for Options mode, Autocomplete and the Assistant are all in the writer view itself; the credential cascade and the daily cost cap that sit behind every one of them are described below.
How it behaves
Selection edits (AI — Plan 2)
How it works
Select any text in the editor and a bubble menu appears with these actions:
| Action | What it does |
|---|---|
| Improve | Rewrites the selection for clarity and flow |
| Fix grammar | Corrects grammatical and spelling errors |
| Shorten | Condenses the selection without losing key meaning |
| Expand | Elaborates the selection with more detail |
| More formal | (plugin) A more formal, professional tone — a canned custom instruction |
| Punchier | (plugin) Punchier and more concise — a canned custom instruction |
| Translate | (plugin) Translates the selection — a canned custom instruction |
| Free-text | You type a custom instruction; the model follows it |
The plugin's presets live in lib/selection-presets.ts; the tone presets "More formal",
"Punchier" and "Translate" reuse the custom action with a canned instruction, so they need
no new bridge method. Every preset lands in the review flow below.
Choosing an action calls writer:ai-edit on the main process, which calls the
Anthropic Messages API or OpenRouter (the model is the user-selected Writer Studio model — default Luna) and returns the rewritten text.
The document is not touched at this point.
Review flow
The client word-diffs the original selection against the rewritten version (using
the diff package) and renders a ReviewPanel showing additions and deletions
inline. The panel is positioned at the viewport coordinates of the selection start
(editor.view.coordsAtPos(review.from)) and clamped to the window via
containToViewport so it never renders off-screen. Press Esc to reject without
reaching for the mouse.
- Accept — replaces the captured selection range in the editor with the rewritten
text (
insertContentAt); the editor briefly flashes an accent outline (600 ms, suppressed underprefers-reduced-motion) to confirm the change landed. - Reject — discards the rewritten text; the editor is unchanged.
Nothing is written to the document until you click Accept.
Credentials — three-step cascade
writer:ai-edit resolves credentials through a three-step cascade (runWriterCompletion
in writer-ai-service.ts):
- OpenRouter model (default path) — when the resolved model id contains
/(e.g. Luna, the default for edits/options/autocomplete), the request routes throughllmProviderService.chat()using the bundled company-paid OpenRouter key. No user credentials are needed — this is the zero-setup path that works out of the box. Cost-tracked and daily-capped against the writer budget. - API-key account — for bare Claude model ids (Haiku/Sonnet/Opus), calls the Anthropic Messages API directly; fast, cost-tracked, and daily-capped.
- Subscription login (fallback) — if no API key is configured but you are signed in
to a Claude account, Omniscio runs the request through
claude -p(the same one-shot bridge the Council uses) on your subscription. No API key is needed — it rides your Claude plan. This is the sanctioned path (it is Claude Code); the Messages API still rejects OAuth tokens directly, so the subscription path spawns the CLI instead of calling the API.
Only when you have neither a key nor a signed-in Claude account AND the model is not an OpenRouter model does the handler return a humanized "add an API key" error — it never crashes.
Daily cost cap
Every successful edit is logged with trackApiCost(accountId, 'writer', …).
Before each call, checkWriterCap checks the accumulated cost against the
writerCostCapUsd setting (default $2 / day, range $0.50–$50). Edits are
refused once the cap is reached. The cap resets at midnight local time. The cap
applies to the OpenRouter and API-key paths. On the subscription-login path,
edits ride your Claude plan's own limits instead.
The model is resolveWriterModel('edit') — the user-selected writerModel (default Automatic = Luna); no streaming. Typical edits cost fractions of a
cent; the cap is a safety guard, not a practical limit.
Feature gating
Selection edits are part of the ai-writer gated surface — they are only available
when the Writer Studio feature itself is visible (see "How it is gated" above).
Options mode (AI — Plan 3)
How it works
Enable Options mode via the toolbar toggle. With Options mode ON, selecting
text triggers the SelectionMenu (a Tiptap BubbleMenu) to show N alternative
versions of the selected segment inline. The scope (sentence vs paragraph) is
derived automatically by deriveOptionsScope from the selection — it is never a
user-facing tab. Press Esc to dismiss.
Alternatives are generated on demand when a non-empty selection is made (NOT on a
bare caret click, NOT pre-generated in a background queue). The client caches
results by scope:contentHash(text), so re-selecting the same text instantly
re-shows the same options at no cost. The Refresh button bypasses the cache
and regenerates.
Picking an alternative replaces exactly the selected segment's range in the
editor via insertContentAt — nothing outside the active scope is touched.
Scope detection
Scope is derived from the selection by deriveOptionsScope: if the selection fits
within a single sentence (detected via Intl.Segmenter, granularity 'sentence'),
the scope is 'sentence'; otherwise it is 'paragraph' (the full ProseMirror text
block).
IPC channel
writer:ai-options (WRITER_AI_OPTIONS) receives the selected text and scope, calls
the AI provider (OpenRouter for Luna, Anthropic for Claude models), and parses the
response by splitting on --- separators to produce N distinct alternatives. The
response is plain text (no streaming).
Credential and cost guardrails
writer:ai-options shares all the same guardrails as selection edits:
- Resolves credentials via the same three-step cascade as edits
(
runWriterCompletion): OpenRouter bundled key (default Luna path), else API-key account, else signed-in Claude account (claude -p). No credential on any path → humanized error, no crash. - Checks and logs cost against the same
'writer'daily cap (checkWriterCap/writerCostCapUsd, default $2 / day) on all paths. The cache means cached hits never incur a new call. - Model:
resolveWriterModel('options')— user-selectedwriterModel(default Luna). No streaming.
Offline testing
The E2E spec uses AMC_WRITER_AI_FAKE=1, which makes the service return
deterministic fake alternatives without calling the real API.
Inline autocomplete (AI — Plan 4)
How it works
Enable Autocomplete via the header toggle (or Settings → Writer Studio →
Autocomplete; writerAutocompleteEnabled, default OFF). When enabled, after
a ~400 ms typing pause with the cursor at the end of a paragraph that is at least
15 characters long, Omniscio calls writer:ai-autocomplete and renders the returned
continuation as grey ghost text inline in the editor (a ProseMirror widget
decoration styled .writer-ghost-text / data-writer-ghost).
- Tab — accepts the suggestion, inserting it at the cursor and clearing the ghost text.
- Any document change, selection move, or Escape — immediately clears the ghost text without inserting anything.
The suggestion is intentionally short (model: resolveWriterModel('autocomplete') — user-selected writerAutocompleteModel, default Luna; max_tokens 40) — it is a sentence-completion nudge, not a paragraph generator.
Guardrails
- Off by default — the toggle must be explicitly enabled; no autocomplete fires on a fresh install.
- Trigger conditions — selection must be collapsed (no range selection) and positioned at the very end of the paragraph; the paragraph must be ≥15 characters. Neither the debounce (~400 ms) nor the min-length check can be bypassed.
- Stale-drop — a monotonically increasing sequence counter is incremented on every request; responses that arrive for an earlier counter value are silently discarded, preventing out-of-order flicker.
- No background pre-generation — no speculative calls are made while the user is not typing.
Credential and cost guardrails
writer:ai-autocomplete does NOT use the shared runWriterCompletion helper and does
NOT fall back to a subscription login (a per-keystroke claude -p spawn would feel
laggy for inline ghost text). It has two credential paths:
- OpenRouter model (default) — when the resolved model contains
/(Luna, the default), autocomplete routes throughllmProviderService.chat()using the bundled OpenRouter key. No user credentials are needed — any user with autocomplete enabled gets ghost text out of the box. - Bare Claude model — when the user explicitly selects Haiku/Sonnet/Opus, resolves
an API-key account via
resolveApiKeyForAiFeatures(). No key → empty suggestion (no toast). - Checks cost against the same
'writer'daily cap (checkWriterCap/writerCostCapUsd, default $2 / day) on BOTH paths. Cap hit → empty suggestion (no toast). - Model:
resolveWriterModel('autocomplete')(default Luna),max_tokens40. No streaming.
Silent failure — no toast storm
Unlike writer:ai-edit and writer:ai-options, the autocomplete handler never
surfaces a toast or error message on failure. Any soft error (no key, cap hit, rate
limit, network error) returns an empty string so the ghost text simply does not
appear. This prevents a toast storm on every keystroke when, for example, no API
key is configured.
Offline testing
The E2E spec (tests/e2e/ui/writer-autocomplete.spec.ts) uses
AMC_WRITER_AI_FAKE=1, which makes the service return a deterministic fake
continuation without calling the real API.
Chat assistant (AI — Plan 5)
The panel
A ChatPanel slides in on the right side of the editor when you click the
Assistant toggle in the header. The panel is resizable — drag its left
edge to set the width (300–720px), double-click the edge to reset to the 400px
default, and the chosen width is remembered across sessions (the
writerAssistantWidth setting, driven by the shared useResizeSidebar hook).
Toggling hides/shows the panel without losing the conversation. The chat input
receives autoFocus on mount so you can start typing immediately after opening.
If an AI request fails, an inline error bubble appears in the chat history with a
"Try again" button that replays the last user message (retryChat in the store).
The store's aiStatus field also drives the ambient AI banner (see above).
Protocol
Each user turn is sent to writer:ai-chat (WRITER_AI_CHAT). The handler calls
the Anthropic Messages API (the user-selected Writer Studio model (writerModel); default
Automatic = Sonnet for chat — low-frequency and capped at 2048 tokens, so the
stronger model is used for reliable, applicable edits) and returns a structured
response. The model is instructed to wrap its reply in two XML
sections:
<conversation>
…free-form reply to the user…
</conversation>
<suggested_edits>
<edit>
<search>exact text to find in the document</search>
<replace>replacement text</replace>
</edit>
…additional edits…
</suggested_edits>
The parseChatResponse helper parses this into { conversation: string, edits: SuggestedEdit[] }. If the model omits <suggested_edits>, edits is an empty array. Malformed XML or a dropped <conversation> wrapper is treated as a pure conversation reply. The conversation text is rendered as markdown in the assistant bubble (via the shared sanitized ProseBlock renderer — headings, lists, bold, links, code, tables); user messages stay plain text.
Suggested edits through the review flow
Each SuggestedEdit in the response shows an "Apply suggested edit" button
that previews what the change becomes (→ <replace>, or → (delete)).
Clicking it calls findTextRangeInDoc, which searches the ProseMirror document
for the <search> text within a single text block. It first tries an exact
substring match; if that fails it falls back to a tolerant match (runs of
whitespace, letter case, and straight-vs-smart quotes/dashes are all treated as
equivalent) so a near-miss quote from the model still applies, landing on the real
document characters. When found, the match range is passed to the EXISTING Plan 2
review flow: the store transitions to review state and the same ReviewPanel
renders, showing the diff between the original and replacement text. The user clicks:
- Accept —
insertContentAtreplaces the found range with the<replace>text. - Reject — the store returns to idle; the document is unchanged.
There is no second review UI for chat-suggested edits — they go through the identical ReviewPanel path as bubble-menu selection edits.
If the search text is not found in the document even after the tolerant match
(e.g. the user rewrote that passage since the response arrived),
applySuggestedEdit returns false and a friendly toast is shown instead of
opening the review panel — never a silent no-op.
Persisted history (per document)
Chat messages are persisted per document in the writer_chat_messages table
(a child of writer_documents). Each turn — your message plus the assistant's
reply — is saved by the writer:ai-chat handler on success (a failed turn
saves nothing, so retrying it never creates a duplicate). Switching to another
document loads that document's saved conversation; reopening a document you
chatted in before restores the whole conversation, including the assistant's
"Apply" buttons (the suggested edits are stored alongside each reply). A new,
never-chatted document opens with an empty panel.
Each document keeps up to ~200 messages; older messages beyond that are trimmed
automatically. Documents are global per install — chat history has no
account_id and is the same regardless of which Anthropic account is active.
Deleting a document hides its chat history along with it (and restoring the
document brings the conversation back).
Voice context integration
The chat assistant automatically receives two pieces of context from other Omniscio integrations, when available:
- Voiceprint style guide — if the user has completed a Voiceprint Studio calibration,
the resulting markdown style guide (derived from their actual sent emails) is injected
into the system prompt as a "Your writing voice" section. The assistant uses it to match
the user's natural tone in any suggested edits or drafted text. Resolved via
resolveEmailVoiceGuide(db), capped at 4,000 characters. - Sender engagement signals — if the document's Audience field contains an email
address or name that matches a known sender in SuperMail, the assistant receives a
brief "Relationship context" summary (reply frequency, engagement level, last reply date).
This helps it calibrate formality and familiarity. Resolved via
readSenderScores().
Both are resolved by a shared resolveWriterVoiceContext(db, audience) function
(src/main/services/writer/voice-context.ts) called in both the bridge and native IPC
transports before chatWriterText(). Both are optional: missing voice guide or unknown
sender silently produces no injection, and all existing callers are unaffected.
Credential and cost guardrails
writer:ai-chat shares all the same guardrails as the other AI capabilities:
- Resolves credentials via the same three-step cascade as edits
(
runWriterCompletion): (1) OpenRouter bundled key for models whose id contains/, (2) API-key account for bare Claude model ids, (3) subscription login viaclaude -p. No key AND no login AND no OpenRouter model → humanized error in the chat panel, no crash. - On the API-key path, checks and logs cost against the same
'writer'daily cap (checkWriterCap/writerCostCapUsd, default $2 / day); the subscription path is uncapped. - Model:
resolveWriterModel('chat')— user-selectedwriterModel(default Sonnet). No streaming.
Offline testing
The E2E spec (tests/e2e/ui/writer-chat.spec.ts) uses AMC_WRITER_AI_FAKE=1,
which makes the service return a deterministic fake response (a markdown
conversation plus a <suggested_edits> block) without calling the real API.
Panel resize persistence has its own spec, tests/e2e/ui/writer-assistant-resize.spec.ts.
Related
The editor these capabilities act on is in part 2, the exports and integrations around them in part 4, and the IPC channels they call in part 5; the Writer Studio (standalone writing editor) page is the parent.
Last verified 2026-09-23