slidevim
by jaisharmz
README.md
# slidevim
[](https://github.com/jaisharmz/slidevim/actions/workflows/ci.yml)
**Vim for Google Slides — built so an agent can edit a deck instead of clicking one.**
Computer-use agents are bad at Google Slides for a structural reason: a deck is a
tree of objects, but the only interface is a canvas. So the model takes a
screenshot, guesses at a pixel, clicks, screenshots again to see whether it
worked, and repeats. Ten screenshots and a lot of tokens to delete three slides.
slidevim gives the deck a text interface and a command language instead:
```console
$ slidevim exec $DECK ':g/TODO/d | :%s/2025/2026/g | :w'
deleted 1 slide
deleted 1 slide
7 substitutions on 4 slides
wrote 9 ops to google
```
No screenshots. No pixels. One round trip, and a diff you can inspect before it
is sent.
> Zero runtime dependencies. Node ≥ 22.6. MIT.
---
## The idea
Vim's grammar is `operator + motion`, applied to a buffer of lines. slidevim
keeps the grammar and rebinds the nouns:
| vim | slidevim |
| --- | --- |
| buffer | the deck |
| line | a slide |
| word | an element on the slide (title, body, image, table) |
| `dd` | delete this slide |
| `:%s/a/b/g` | substitute across every slide |
| `:g/pat/d` | delete every slide matching a pattern |
| marks, registers, counts, `u`, `.` | all of them, unchanged |
That mapping is the whole product. An agent that knows vim already knows this,
which means the command language costs roughly zero tokens to teach.
### What this takes from Vimium
[Vimium](https://github.com/philc/vimium) is the direct ancestor, and its best
idea is not the vim keybindings — it is `f`. Press `f` and every link on the page
grows a short label; type the label and you have clicked it. You never aim at a
pixel, never describe a target, never say "the third link in the nav". **The
thing you are looking at is labelled with exactly what you type to act on it.**
That is the property slidevim is trying to give a deck, and it is worth
considerably more to an agent than to a human, because an agent's alternative is
a screenshot and a guess at a coordinate.
So slidevim has hints, and they work the way Vimium's do. Every slide and every
element carries a code, in the buffer and in the preview:
```
[l] @3 #s03 TITLE_AND_BODY
[e] title Revenue
[w] tbl1 [table 3x3]
Quarter | Revenue | Growth
```
Type the code where a label goes and it acts on that exact thing, with no motion
and no cursor:
```
:text e Revenue and margin
:cell w 2,2 $9.9M
:drop w
:hint l | dd # or put the cursor there first, for slide operators
```
Two differences from Vimium, both forced by the reader being a program:
- **Hints are always on.** Vimium hides them behind `f` because they are visual
clutter. For an agent they are four characters that remove a round trip, so
they are cheaper shown than hidden.
- **Hints are assigned in document order**, which makes them a pure function of
the deck — the same deck yields the same codes in the next process, and every
CLI and MCP call *is* a new process. They shift when slides are added or
removed, exactly as Vimium's do when the page changes, so re-read the buffer
after that.
For when you would rather name a thing than look one up, `@3/tbl1` and
`#s03/tbl1` also work anywhere a label goes and also ignore the cursor. A bare
`tbl1` still means "in this command's range", as in vim.
What slidevim deliberately does not take from Vimium: modes and remappable keys.
Insert mode exists to get out of the way of a human typing prose, and `map`
exists because humans have muscle memory. Neither applies here.
## Install
```bash
git clone https://github.com/jaisharmz/slidevim
cd slidevim && npm install && npm run build && npm link
```
Try it against the bundled fixture deck, no credentials needed:
```bash
slidevim demo # print the buffer
slidevim demo ':g/TODO/d | :%s/2025/2026/g' # run a program
slidevim demo ':g/TODO/d | :w' --dry-run # the batchUpdate it would send
slidevim keys # command reference
```
That third line is the quickest way to judge this project: it prints the real
Google Slides API payload for a program, with no credential and no network.
## Just tell Claude to make you a deck
The repo is set up as a Claude Code project. `.mcp.json` registers the MCP
server; `.claude/skills/presentation/` holds the workflow.
```bash
cd slidevim && npm install && npm run build
claude
```
> make me a presentation about why residential streets should be 20mph
Claude creates a deck under `decks/`, opens a live preview, hands you the URL,
and builds the deck while you watch. On the first run it asks once whether to
trust the project's MCP server; say yes.

Those yellow chips are the hints. They are the same codes the agent sees in its
buffer, so what you are looking at and what it is typing are the same addresses.
## Watching it work
A text interface is only convincing if you can see what it produced. `slidevim
serve` opens a page that follows a local deck file, so you can put the preview
on one side of the screen and let an agent edit on the other:
```bash
slidevim new deck.json "Slower Roads"
slidevim serve --file deck.json # http://127.0.0.1:PORT
slidevim exec deck ':$new TITLE | :title Slower Roads | :w' --file deck.json
```
`./examples/build-a-deck.sh` runs the whole thing — five slides, a table, a web
image, a substitution pass over the finished deck — with a pause between steps
so you can watch each one land. No credential needed.
The preview is an approximation, not a renderer: it knows structure and content,
because that is all the model carries. It has no idea what your theme looks like.
For pixels, open the real deck — Slides updates live while the API writes to it,
which is the same demo with better fonts.
**Appending:** `:$new` adds a slide at the end. A plain `:new` inserts after the
cursor, and every new process starts with the cursor on slide 1, so a deck built
with bare `:new` calls comes out backwards. This bit me the first time I built a
deck with it.
## The buffer
`slidevim view $DECK` renders the deck as text. Hints are in brackets, slides
are numbered, elements are labelled, and `>` marks the cursor:
```
deck:1AbC… "Northwind Q3 Review" 6 slides [+]
[ss] @1 #s01 TITLE
[sa] title Northwind Q3 Review
[sd] subtitle Prepared for the board, FY2025
> [sf] @2 #s02 TITLE_AND_BODY
[sj] title Agenda
[sk] body - Revenue
- Pricing changes
- Roadmap
notes Keep this to ninety seconds.
[sl] @3 #s03 TITLE_AND_BODY
[se] title Revenue
[sw] tbl1 [table 3x3]
Quarter | Revenue | Growth
Q1 2025 | $1.2M | 8%
```
Every bracketed code is an address: `:cell sw 2,2 $9.9M` edits that table
wherever the cursor is. So is `@3/tbl1`, for when naming beats looking up. A
bare label means "in this command's range" — `:text body …` edits the body of
the current slide, `:%text body …` edits every slide's. Notes have no hint
because `:notes` addresses the slide, not an element on it.
The projection is one-way by design. Commands mutate the model and the view is
re-rendered — nothing ever parses this format back into a deck, so there is no
round trip to get subtly wrong.
## Commands
```
motions j k gg G {n}G } { w b 0 $ /pat n N
edits dd yy dj dG 3dd x p P cc u <C-r> . "ayy
marks ma `a ranges: % 1,5 . $ .+2 'a,'b /pat/
:new [layout] insert a slide after the cursor (:new! before)
:title TEXT set the title (also :sub, :body, :notes)
:text LABEL TEXT set any element by label
:append LABEL TEXT append a paragraph
:image URL [alt] add an image
:table RxC add a table; :cell tbl1 2,3 TEXT
:drop LABEL delete an element
:s/old/new/g substitute (:%s for the whole deck)
:g/pat/CMD run CMD on every matching slide (:v inverts)
:m ADDR :t ADDR move / copy slides
:sort sort slides in range by title (:sort! reverses)
:skip :unskip toggle "skip in presentation"
:hint CODE put the cursor on what a hint code names (also :f)
:diff show pending ops without writing
:w :e :q :wq write, reload, quit
```
`:new` inserts after the cursor; `:$new` appends at the end. Use `:$new` when
building a deck up from nothing.
Anywhere `LABEL` appears you may write a qualified address instead — `@3/tbl1`
by position, `#s03/tbl1` by object id — which names its own slide and ignores
the cursor and the range. `:3cell tbl1 2,2 X` is the ex-range spelling of the
same thing.
Two deliberate deviations from vim:
- **Whitespace separates commands.** `gg 3dd :w` is three commands. Agents write
programs as JSON strings, and unambiguous separators are worth more than
`<Space>` as a synonym for `l`.
- **`/pattern` and `:ex` run to a `|` or a newline**, as in vim. A pattern may
contain spaces, so terminate it before the next command:
`/q3 revenue | :s/2025/2026/g`.
Nothing is written until `:w`. Until then `:diff` shows exactly what would be
sent.
## Use from an agent (MCP)
```json
{
"mcpServers": {
"slidevim": { "command": "node", "args": ["/path/to/slidevim/dist/mcp.js"] }
}
}
```
Eight tools: `slides_new`, `slides_preview`, `slides_view`, `slides_exec`,
`slides_diff`, `slides_write`, `slides_check_image`, `slides_keys`. Only
`slides_write` touches the deck, so an agent can plan and self-check for free.
A typical exchange:
```
slides_view { deckId } -> the buffer
slides_exec { deckId, program: ":g/TODO/d" } -> the buffer, edited
slides_diff { deckId } -> "2 pending ops: delete slide s02, …"
slides_write { deckId } -> "wrote 2 ops to google"
```
### slidevim does not replace the agent's other tools
It is a text interface to one specific object, not a sandbox. The agent keeps
its browser, its web search, its file system — and it should use them. Pictures
are the obvious case: slidevim has no opinion about which image belongs on a
slide, only about whether a given URL will work.
```
(the agent's own image search) -> some candidate URLs
slides_check_image { url } -> "ok image/jpeg 448 KB — Slides can fetch this"
slides_exec { program: ":image <url> a residential street with speed cushions" }
```
That middle step earns its place. `createImage` is fetched by *Google's*
servers, not by yours, so a URL only works if it is publicly reachable,
PNG/JPEG/GIF, and under 50 MB. Image search results routinely fail all three —
they are pages about an image, or hotlink-protected, or behind a redirect that
wants a cookie — and Slides reports every one of those as a bare `400`.
`slides_check_image` turns that into "content-type is text/html (this looks like
a page about the image, not the image file)" at the moment of choosing, which is
a thing an agent can actually act on. `slidevim check-image <url>` is the same
check from the shell.
## Credentials
slidevim never stores a credential. Point it at a token you already have:
```bash
export SLIDEVIM_ACCESS_TOKEN="$(gcloud auth print-access-token)"
# or, refreshed on demand:
export SLIDEVIM_TOKEN_CMD="gcloud auth print-access-token"
# or a standard OAuth client:
export SLIDEVIM_CLIENT_ID=… SLIDEVIM_CLIENT_SECRET=… SLIDEVIM_REFRESH_TOKEN=…
```
Scopes: `presentations`, `drive.file`.
Before pointing it at a deck you care about, look at the requests:
```bash
slidevim exec $DECK ':%s/2025/2026/g' --dry-run
```
`--dry-run` compiles to Google Slides `batchUpdate` requests and prints them,
and it holds on every backend — the Google client sends nothing, the local
`--file` deck is not rewritten, and the demo fixture is not touched.
## How it works
```
command text ──parse──▶ AST ──execute──▶ deck model (in memory)
│
diff vs last-synced snapshot
│
DeckOp[]
╱ ╲
memory backend Google backend
(batchUpdate)
```
Two decisions carry the design:
**Undo is a snapshot; sync is a diff.** Commands mutate the model freely and
never journal an inverse. Undo restores a clone. `:w` diffs the working deck
against the last synced snapshot and emits the minimal op list. Correctness of
both falls out of the model rather than out of every command remembering to do
the right thing — which is why `:g/pat/normal 3dd` syncs correctly despite
nobody having thought about that combination.
**The Google backend is a pure compiler plus a thin client.** `compileOps` turns
ops into API requests with no I/O, so the entire write path is unit-tested
without a network or a credential.
Three places where the Slides API is sharper than it looks, and what this does
about them:
- `createSlide.placeholderIdMappings` lets the caller choose object ids, so
model ids become the real Slides ids and stay stable across syncs.
- `updateSlidesPosition.insertionIndex` is indexed against the arrangement
*before* the move, so a slide moving later in the deck must aim one past its
destination. Reordering is computed by simulation, not by comparing final
indices — an op list of "final index" moves is wrong the moment there is more
than one of them.
- `deleteText` is a 400 on a shape with no text, so the compiler tracks which
shapes and table cells actually hold text and only clears those.
## Tests
```bash
npm test # 126 tests, on Node 22 and Node 26
npm run typecheck
```
The suite's backbone is a round-trip property: for a corpus of programs, writing
the deck and reading it back must reproduce the working deck exactly. On top of
that, all 720 orderings of a six-slide deck are checked through diff-and-apply,
which is what caught the multi-slide reordering bug that final-index moves
produce.
The Google backend is covered by asserting the generated `batchUpdate` requests
against the documented API shapes, and by mapping a recorded `presentations.get`
payload into the model.
The write path against a live Google deck is the one thing the suite cannot
reach: it needs an OAuth grant, which CI does not have. Everything up to the
HTTP call is tested; the HTTP call itself is not.
## Status
v0.1. Slide and element structure, text, tables, images, notes, ordering and
skip state. Not yet: text *styling* (fonts, colours, sizes), speaker-note
formatting, charts, and relayout of an existing slide — the Slides API has no
request for that last one, so `:layout` only affects slides created in the same
session, and says so.
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues