---
title: Writer Studio (standalone writing editor) (part 3)
---

# Writer Studio (standalone writing editor) (part 3)

## What it is

This is part 3 of the [Writer Studio (standalone writing editor)](ai-writer.md) 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](ai-writer.md) 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 under `prefers-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`):

1. **OpenRouter model (default path)** — when the resolved model id contains `/` (e.g. Luna,
   the default for edits/options/autocomplete), the request routes through
   `llmProviderService.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.
2. **API-key account** — for bare Claude model ids (Haiku/Sonnet/Opus), calls the Anthropic
   Messages API directly; fast, cost-tracked, and daily-capped.
3. **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-selected `writerModel` (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 through `llmProviderService.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_tokens` 40. 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** — `insertContentAt` replaces 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 via `claude -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-selected `writerModel` (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](ai-writer-part-2.md), the exports and integrations around them in [part 4](ai-writer-part-4.md), and the IPC channels they call in [part 5](ai-writer-part-5.md); the [Writer Studio (standalone writing editor)](ai-writer.md) page is the parent.
