---
title: Scratchpads — part 2 (images, formatting, deletion, the CLI and the contracts)
---

# Scratchpads — part 2 (images, formatting, deletion, the CLI and the contracts)

## What it is

The second half of the Scratchpads feature: how rich content behaves inside a pad, what deletion does, the compatibility rules with the original plain-text pads, the command-line surface, and the architectural contracts the code has to respect. Part 1, [scratchpads.md](scratchpads.md), covers the pad itself, the quick-capture overlay and finding a note.

## Where to find it

All of it lives inside the Scratchpads virtual project in the sidebar, or in the quick-capture overlay you open with **Ctrl+Shift+S** — this part describes what happens once you are there, so it has no separate screen, menu or setting of its own. The one exception is the CLI control surface, which is agent-only and has no button at all.

## How it behaves

### Images

- **Paste** — paste an image (e.g. `Ctrl+V` from a screenshot tool) directly into the body. It inserts inline at the caret as a `<img src="data:…" />` element.
- **Click-to-copy** — single-click an image inside a pad to surface a small floating **Copy image** chip anchored to the image's top-right. Click the chip to write the image to the OS clipboard. Scrolling or typing dismisses the chip. Both the chip and the resize handle (below) use the `bg-surface-900/90` + `text-surface-50` token pair — both tokens flip together with the active theme so the chip stays visible against both light and dark editor backgrounds. (Before 2026-05-21 the chip used a literal `text-white` and was invisible in dark mode against the flipped near-white surface-900 background.)
- **Resize** — the same single-click that surfaces the Copy chip also surfaces a small **resize handle** (16 × 16 px, `nwse-resize` cursor) anchored to the image's bottom-right corner. Click-drag the handle to scale the image; width is clamped between **40 px** and the editor column width minus padding, height is left to `auto` so the natural aspect ratio is preserved off the X delta alone. On release, the new `style="width: …px"` is mirrored to the store, written to SQLite on the next 500 ms autosave, and snapshotted into the undo stack (Ctrl+Z reverts the resize). Scrolling or typing dismisses the handle the same way it dismisses the Copy chip.
- **Right-click** — right-click an image to open a small context menu with **Copy Image** and **Remove**. (This reuses the chat-side `PendingImageContextMenu` primitive so behavior matches what you see for pending image uploads in the chat composer.)
- **Storage** — images are inlined as base64 `data:` URLs in the pad body HTML. There is no separate image table, no file mirror, and no migration to disk paths yet. A resized image's width persists as an inline `style="width: Xpx"` attribute on the `<img>` element — no schema change, no separate metadata row. The token estimate ignores image data so a single pasted screenshot doesn't inflate the count by tens of thousands of "tokens".

### Markdown styling (toggle)

Both the two-pane pad editor **and** the Quick Capture overlay can render markdown **in place** without converting your text — the styled view layers heading sizes / bold / italic / code / blockquote borders / link colour on top of your raw markdown, with the syntax characters kept on screen so you always see exactly what you typed. Flipping the toggle off returns you to flat plain-text rendering. Toggle it with the **Type** icon in either header (two-pane pad header, or Quick Capture header to the left of **Copy all**); both buttons flip the same global `scratchpadRenderMarkdown` setting (default off), so turning it on in one place turns it on everywhere immediately.

What it renders when on:

- **Block-level** — `# H1` through `###### H6` headings (proper sizing + spacing, applied to the whole line **including** the `## ` prefix — the prefix simply renders in a dimmed colour at the same heading size), `> ` blockquotes (left border + italic; the `> ` prefix stays visible, dimmed), `- ` / `* ` unordered list items (the `- ` / `* ` prefix stays visible, dimmed — no synthetic bullet), `1. ` ordered list items (the `1. ` prefix stays visible, dimmed), fenced code blocks delimited by ` ``` ` (the fence lines stay on screen as dimmed monospace; the interior gets a monospace background panel).
- **Inline** — `**bold**` (the `**` markers stay visible, dimmed), `*italic*` / `_italic_` (the `*` / `_` markers stay visible, dimmed), `` `code` `` (monospace pill with surface background; the backticks stay visible, dimmed), and `[label](url)` links (label is the accent-coloured underlined text; the `[`, `]`, `(`, `)`, and URL stay visible, dimmed).

What's preserved:

- Syntax characters (`##`, `**`, `` ` ``, `>`, `- `, `1. `, fence lines, link URLs) are kept in the live DOM **and** on screen — only their visual weight changes (lighter colour, normal weight/style) so they read as scaffolding next to the rendered text. The caret walks past them normally, and you can edit them directly — deleting a heading's `## ` prefix reverts the line to a plain paragraph the instant the marker is gone.
- The plain-text body saved to SQLite is **clean** — the rendering only mutates the live DOM, and the auto-save path strips the styling spans via `getCleanHtml` before writing. Toggling on / off does not modify what is stored.
- Caret position is preserved across the strip-then-apply pass via a linear text-offset round-trip, so re-styling on every keystroke does not jump the cursor.

### List hotkeys (bullets & numbering)

Two keyboard shortcuts toggle list markers on the line(s) your cursor is on, mirroring the **KMS editor's** combos so the two surfaces feel the same. They work in **every** scratchpad surface (two-pane editor, Quick Capture overlay, pop-out window) and **regardless of the markdown-styling toggle** — they just insert / remove the markdown text `- ` / `1. `, which the styler then dims if the toggle is on.

| Action        | Shortcut | Alias (Word / Google-Docs) |
| ------------- | -------- | -------------------------- |
| Bullet list   | `Ctrl+.` | `Ctrl+Shift+8`             |
| Numbered list | `Ctrl+/` | `Ctrl+Shift+7`             |

(The `Ctrl+Shift+8` / `Ctrl+Shift+7` aliases match via the layout-stable `event.code` `Digit8` / `Digit7`, since a US keyboard reports the shifted key as `*` / `&` — the same trick the KMS matcher uses.)

Behavior:

- **Toggle.** Press once to add the marker to the current line; press the same combo again to remove it. With several lines selected, if every line already has that marker they are all cleared, otherwise they all get it.
- **Sequential numbering.** Numbering a multi-line selection writes `1. `, `2. `, `3. ` down the lines (numbering restarts at 1 within the selection — the numbers are literal text).
- **One marker per line.** Adding a list marker first strips any existing leading marker on that line, so a heading (`## todo`) or the other list type becomes the new list cleanly (`- todo`) instead of stacking (`- ## todo`).
- **Cursor stays put.** The caret holds its place relative to the text as the marker is inserted / removed; a multi-line selection stays selected so you can re-toggle.
- **One undo step.** A toggle is a single `Ctrl+Z`, and the live char / line / token counters update immediately — it routes through the editor's normal per-keystroke path (no extra serialization, so image-heavy pads stay fast).

The combos are deliberately **not** in the central keybindings registry (editor-local, not rebindable today — matching how KMS owns its list keys), and never collide with the overlay's own keys (`Ctrl+W` close, `Ctrl+L` / `Tab` toggle the note list, `Ctrl+Enter` save). Locked by [scratchpad-list-hotkeys-contract.md](../../.claude/memory/contracts/scratchpad-list-hotkeys-contract.md).

### Trash (soft delete + 30-day purge)

Deleting a scratchpad **moves it to Trash** (soft delete: `is_deleted = 1`, `deleted_at` timestamp). The pad stays recoverable until it is either restored or permanently deleted. The Trash surface is accessible from the **Trash** button in the `ScratchpadsView` header bar — a count badge shows how many pads are trashed, and clicking opens the `ScratchpadTrashPopover` (a `MenuShell`-based popover, mirroring the Meetings Trash pattern).

- **Trash popover** — lists trashed pads sorted by `deleted_at` DESC. Each row shows the pad title + relative deletion time. Two actions per row: **Restore** (moves the pad back to the active list) and **Delete forever** (permanent hard delete, no recovery). An empty state reads "No items in trash."
- **Undo toast** — immediately after a delete, a toast appears: "Moved to Trash" with an **Undo** button that restores the pad instantly (same toast pattern as the prior hard-delete flow, but now calls `scratchpads:restore` instead of re-creating).
- **30-day auto-purge (always on).** Trashed pads older than 30 days are **hard-deleted** at startup and every 24h by `purgeTrashedScratchpads()` in `data-retention.ts`. This is always on — there is no toggle. The 30-day window gives the user time to recover an accidental delete.
- **Two-sweep retention model.** (a) The opt-in auto-delete (`purgeExpiredScratchpads`) soft-deletes old active pads **to Trash**, so they can be restored within 30 days. (b) The always-on trash purge (`purgeTrashedScratchpads`) hard-deletes trashed pads older than 30 days. Both run at startup + every 24h in `data-retention.ts`.
- **Schema.** Migration `20260725221834-add-soft-delete-to-scratchpads` adds `is_deleted INTEGER NOT NULL DEFAULT 0` and `deleted_at TEXT` columns to the `scratchpads` table, plus a composite index on `(is_deleted, updated_at)`.
- **Query contract.** All active-pad queries (`listScratchpads`, `getScratchpad`, `updateScratchpad`) filter `WHERE is_deleted = 0`. The `deleteScratchpad` function sets `is_deleted = 1` + `deleted_at = now`. `restoreScratchpad` clears both fields. `hardDeleteScratchpad` guards `WHERE is_deleted = 1` so an active pad cannot be permanently deleted without going through Trash first.

### CLI control

Scratchpads has a headless half on Omniscio's local control server (`127.0.0.1:19519`) so an agent or external script can manage pads without the GUI. It reuses the **exact same** DB queries and Zod schemas the in-app IPC handlers use, so the CLI and the UI can't drift.

| Route                             | Purpose                                                                                                           |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `GET /scratchpads`                | List every active pad (id, title, body, timestamps, isPinned), pinned first then newest-updated.                  |
| `GET /scratchpads/trash`          | List trashed pads (id, title, deletedAt), newest-deleted first.                                                   |
| `GET /scratchpads/:id`            | Fetch one active pad (`404` if absent or trashed).                                                                |
| `POST /scratchpads`               | Create a pad `{ title?, body? }` — both optional; empty body = blank pad (`201`).                                 |
| `POST /scratchpads/:id/pin`       | Pin or unpin `{ pinned: boolean }` — pinned pads sort to the top (`404` if absent).                               |
| `PATCH /scratchpads/:id`          | Partial update `{ title?, body? }` (`404` if absent or trashed).                                                  |
| `DELETE /scratchpads/:id`         | Soft-delete (move to Trash); returns `{ deleted, trashed, scratchpad }` with the pad's content (`404` if absent). |
| `POST /scratchpads/:id/restore`   | Restore a trashed pad (`404` if not in Trash).                                                                    |
| `DELETE /scratchpads/:id/forever` | Permanently delete a trashed pad — only works on `is_deleted = 1` pads (`404` if not in Trash).                   |

- **Auth + gating.** Bearer-token required — reads self-authenticate (pad content is private) and draw the 60/min read budget; mutations use the 10/min mutation bucket. Mutations **apply immediately** (no inbox approval — the bookmarks/tags posture) and emit `SCRATCHPADS_CHANGED` so an open Omniscio window refreshes.
- **DELETE is a soft delete (Trash).** The pad moves to Trash and can be restored via `POST /:id/restore`. The response includes `trashed: true` + the pad's content for caller convenience. Trashed pads older than 30 days are automatically hard-deleted by the retention sweep. For immediate permanent deletion, use `DELETE /:id/forever` (only works on trashed pads).
- **Always live** — scratchpads is a shipped feature, so the routes are never feature-gated off.
- Full route reference: the omniscio-control [scratchpads spoke](../../.claude/skills/omniscio-control/scratchpads.md); invariants: [scratchpad-cli-contract.md](../../.claude/memory/contracts/scratchpad-cli-contract.md); per-route gating: [cli-server-gating.md](../../.claude/memory/cli-server-gating.md). Source: `src/main/services/cli/cli-server-scratchpad-routes.ts`, wired in `register-cli-routes.ts`.

### Backward compatibility with v1 plain-text pads

Pads created before the contenteditable switch were stored as plain text. When you open one of those pads in the new editor, Omniscio wraps each line in a `<div>` (matching what the browser produces while typing) so existing content renders unchanged. The save-back format is HTML from that point on. The `htmlToPlainText` helper handles the reverse mapping for title derivation, list snippets, and the **Copy all** button.

### Architectural contracts (do not violate)

- **Uncontrolled editor.** The contenteditable DOM is the source of truth for what the user sees. The `PadEditor` and `QuickCaptureOverlay` components mirror `innerHTML` to the Zustand store on every input event, but never re-set `innerHTML` from the store mid-edit (that would wipe the caret). The parent `ScratchpadsView` **must NOT subscribe to `draftBody` / `draftTitle`** — pinned by `tests/unit/features/scratchpads/ScratchpadsView.test.tsx`. The pre-refactor controlled-textarea wiring caused a per-keystroke parent re-render through every `<PadListItem>`.
- **Synchronous mirror, coalesced heavy commit.** That store mirror (`onChange` → `setDraftBody`) is the ONE pass kept synchronous on every input, so autosave / pad-switch / `flushDraft` never read a stale body. The expensive per-edit bookkeeping — markdown re-style, undo snapshot, footer-stats DOM walk — is **coalesced** onto a trailing animation frame via `useCoalescedCommit`, so holding Backspace through a big block runs that work once after the burst settles instead of once per character (O(n²)→~O(n) — the editor no longer freezes deleting a large note). Flushed synchronously at boundaries: blur, before undo/redo, and discrete actions (list-hotkeys / paste / image resize via `commitDiscreteNow`). Full rules + invariants: [scratchpad-editor-perf-contract.md](../../.claude/memory/contracts/scratchpad-editor-perf-contract.md).
- **Reset key.** The editor only re-seeds `innerHTML` when its `resetKey` prop changes. `PadEditor` uses `pad.id` so switching pads remounts cleanly. `QuickCaptureOverlay` bumps a `resetTick` counter on every open so reopening the overlay never inherits stale DOM.
- **Plain-text threshold.** The `saveQuickCapture` ≥ 3-char gate measures the **visible plain text** (`htmlToPlainText(body).trim().length`), not the raw HTML length. An image-only pad bypasses the gate via the `countImages` check. Pinned by `tests/unit/stores/scratchpad-store.test.ts`.
- **Forwarded ref shape.** `ScratchpadBodyEditor` `forwardRef`s the underlying contenteditable `HTMLDivElement` directly (no imperative handle). DialogShell's `initialFocusRef` requires a `RefObject<HTMLElement | null>`, so the editor must be the focus target itself.
- **Plain-text paste maps newlines 1:1.** When you paste plain text into a pad, each `\n` becomes exactly one `<br>`, so a blank line in the source (`\n\n`) stays a single blank line on screen — never doubled or tripled. The `plainTextToInlineHtml` helper (in `editor-stats.ts`) does this for caret insertion and deliberately differs from `plainTextToEditorHtml`, which wraps whole lines in block `<div>`s for _seeding_ a fresh editor: block elements inserted mid-line at the caret split the surrounding line and double the spacing, which is exactly the spacing-inflation bug this guards against. Pinned by `tests/unit/lib/editor-stats.test.ts`.
- **The markdown styler treats `<br>` as a line boundary.** Because a multi-line paste lands as ONE block with `<br>` separators (and a re-seeded pasted pad still holds them), markdown styling first canonicalises such a block into one `<div>` per visual line (`splitBrDelimitedBlocks` in `scratchpad-markdown-style.ts`), so a leading `# ` styles only its own line instead of stamping the entire block as one heading. The split is surgical — only DIV/P blocks with a _content-separating_ `<br>` are restructured; a trailing-placeholder `<br>` (`hello<br>`), a lone empty-line `<br>` (`<div><br></div>`), and a `<br>` nested inside inline tags (import/sync only) are left untouched, so normal typing and the clean-HTML round-trip never churn. Nodes are moved (not reparsed) so pasted images survive. Pinned by `tests/unit/lib/scratchpad-markdown-style.test.ts`.

### Still deferred

- A settings flag to disable the feature.
- Migrating from inline `data:` URLs to a `scratchpad-attachment://` scheme backed by disk files (acceptable today for typical screenshot sizes — revisit if pads start carrying many or large images).

## For agents

### Where things live

- Virtual project sentinel: `__scratchpads__` (`SCRATCHPADS_PROJECT_ID` in `src/shared/virtual-project-ids.ts`).
- SQLite table: `scratchpads` (migration v192).
- IPC channels: `SCRATCHPAD_LIST`, `SCRATCHPAD_GET`, `SCRATCHPAD_CREATE`, `SCRATCHPAD_UPDATE`, `SCRATCHPAD_DELETE`, `SCRATCHPAD_SET_PINNED`, `SCRATCHPAD_TRASH_LIST`, `SCRATCHPAD_RESTORE`, `SCRATCHPAD_DELETE_FOREVER`, `SCRATCHPADS_CHANGED` (push), `SCRATCHPAD_WINDOW_OPEN` (open/focus the standalone window; optional `{ padId }`), and the existing `IMAGE_COPY_BASE64` for the right-click / floating-button copy paths.
- Renderer entries: `ScratchpadsView` (two-pane main view), `ScratchpadBodyEditor` (shared contenteditable, accepts the `renderMarkdown` prop), `QuickCaptureOverlay` (Ctrl+Shift+S dialog), `editor-stats.ts` (pure HTML ↔ plain text + counter helpers), `scratchpad-markdown-style.ts` (the in-place styling engine — `applyMarkdownStyling` / `stripMarkdownStyling` / `getCleanHtml` / `normalizeBlocks` / linear caret round-trip incl. `linearOffsetOf` / `setLinearSelection`), `scratchpad-list-hotkeys.ts` (the bullet / numbered list-hotkey matcher + toggle, reusing the styler's block model + caret helpers; wired as the editor's `onKeyDown`).
- Keybinding action: `openScratchpadQuickCapture` (default `Ctrl+Shift+S`).
- Standalone window: `scratchpad-window.ts` (singleton manager, mirrors `kms-window.ts`) + `scratchpad-window-handlers.ts` (the `SCRATCHPAD_WINDOW_OPEN` handler, auto-discovered) in main; `ScratchpadWindowApp` + `scratchpad-window.tsx` / `scratchpad-window.html` in the renderer; the shared `ScratchpadPopOutButton` (both surfaces); registry entry `scratchpadWindow` in `renderer-entry-points.ts`; push routing via the `'scratchpad'` role + `SCRATCHPAD_WINDOW_ALLOWLIST` in `detached-push-filter.ts`. See [scratchpad-window-contract.md](../../.claude/memory/contracts/scratchpad-window-contract.md).
- Integration registry: `INTEGRATION_REGISTRY` entry id `scratchpads` (kind: `amc-builtin`).

## Related

Part 1, [scratchpads.md](scratchpads.md), is the page to read first for what a scratchpad is and how the overlay works. For everything else this library holds, [INDEX.md](INDEX.md) is the index.
