diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,3 @@
+# Changelog for structural-json
+
+## Unreleased changes
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright Author name here (c) 2021
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Author name here nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,59 @@
+# structural-json
+
+<!-- toc GFM -->
+
+* [Features](#features)
+* [Keymaps](#keymaps)
+* [Installation](#installation)
+* [Usage](#usage)
+* [Roadmap/Known bugs](#roadmapknown-bugs)
+
+<!-- tocstop -->
+
+A structural editor for JSON.
+
+I.e. an editor which is aware of the *structure* of JSON and allows you to manipulate it directly.
+The document is _always_ in a valid state.
+
+https://user-images.githubusercontent.com/6439644/143655548-3c556ea8-7673-4439-8624-15b4b503001f.mov
+
+
+# Features
+
+* [x] Structurally sound editing, never outputs invalid JSON.
+* [x] Copy/Cut/Paste JSON subtrees
+* [x] Subtree folding so you can focus on what's important.
+* [x] Transpose values around each other in lists.
+* [x] Undo/redo system, everyone makes mistakes
+* [x] Save functionality
+
+
+# Keymaps
+
+Press `?` to see the key map, which should feel familiar to vim users.
+
+# Installation
+
+```shell
+cabal install jet
+```
+
+# Usage
+
+```shell
+# Open a file for editing. Use ctrl-s to save back to the file.
+# The edited file is output to stdout even if unsaved.
+jet myfile.json 
+
+# Using jet in a pipeline for quick in-line edits.
+cat myfile.json | jet > result.json
+```
+
+# Roadmap/Known bugs
+
+- [ ] Figure out why vty needs two keystrokes to quit for some reason.
+- [ ] Allow cut/paste of _keys_ of objects.
+- [ ] Allow inserting when empty key already exists
+- [ ] Add search
+- [ ] Improved visibility around copy/paste with highlighting
+- [ ] Increment/decrement commands for integers.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,6 @@
+module Main where
+
+import Jet
+
+main :: IO ()
+main = run
diff --git a/jet.cabal b/jet.cabal
new file mode 100644
--- /dev/null
+++ b/jet.cabal
@@ -0,0 +1,185 @@
+cabal-version: 1.12
+
+-- This file has been generated from package.yaml by hpack version 0.34.4.
+--
+-- see: https://github.com/sol/hpack
+
+name:           jet
+version:        0.0.0.0
+description:    Please see the README on GitHub at <https://github.com/ChrisPenner/jet#readme>
+homepage:       https://github.com/ChrisPenner/jet#readme
+bug-reports:    https://github.com/ChrisPenner/jet/issues
+author:         Chris Penner
+maintainer:     example@example.com
+copyright:      2021 Chris Penner
+license:        BSD3
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+    ChangeLog.md
+
+source-repository head
+  type: git
+  location: https://github.com/ChrisPenner/jet
+
+library
+  exposed-modules:
+      Jet
+      Jet.Render
+  other-modules:
+      Paths_jet
+  hs-source-dirs:
+      src
+  default-extensions:
+      FlexibleInstances
+      FlexibleContexts
+      ScopedTypeVariables
+      LambdaCase
+      ViewPatterns
+      TypeApplications
+      TypeOperators
+      DeriveFunctor
+      DeriveTraversable
+      DeriveGeneric
+      DerivingStrategies
+      StandaloneDeriving
+      TemplateHaskell
+      RankNTypes
+      GADTs
+      MultiParamTypeClasses
+  ghc-options: -Wall
+  build-depends:
+      Hclip
+    , aeson
+    , aeson-extra
+    , aeson-pretty
+    , ansi-terminal
+    , base >=4.7 && <5
+    , bytestring
+    , comonad
+    , containers
+    , free
+    , hashable
+    , lens
+    , mtl
+    , prettyprinter
+    , prettyprinter-ansi-terminal
+    , recursion-schemes
+    , recursive-zipper ==0.0.0.0
+    , scientific
+    , text
+    , text-zipper
+    , transformers
+    , unix
+    , unordered-containers
+    , vector
+    , vty
+  default-language: Haskell2010
+
+executable jet-exe
+  main-is: Main.hs
+  other-modules:
+      Paths_jet
+  hs-source-dirs:
+      app
+  default-extensions:
+      FlexibleInstances
+      FlexibleContexts
+      ScopedTypeVariables
+      LambdaCase
+      ViewPatterns
+      TypeApplications
+      TypeOperators
+      DeriveFunctor
+      DeriveTraversable
+      DeriveGeneric
+      DerivingStrategies
+      StandaloneDeriving
+      TemplateHaskell
+      RankNTypes
+      GADTs
+      MultiParamTypeClasses
+  ghc-options: -Wall -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      Hclip
+    , aeson
+    , aeson-extra
+    , aeson-pretty
+    , ansi-terminal
+    , base >=4.7 && <5
+    , bytestring
+    , comonad
+    , containers
+    , free
+    , hashable
+    , jet
+    , lens
+    , mtl
+    , prettyprinter
+    , prettyprinter-ansi-terminal
+    , recursion-schemes
+    , recursive-zipper ==0.0.0.0
+    , scientific
+    , text
+    , text-zipper
+    , transformers
+    , unix
+    , unordered-containers
+    , vector
+    , vty
+  default-language: Haskell2010
+
+test-suite jet-test
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      Paths_jet
+  hs-source-dirs:
+      test
+  default-extensions:
+      FlexibleInstances
+      FlexibleContexts
+      ScopedTypeVariables
+      LambdaCase
+      ViewPatterns
+      TypeApplications
+      TypeOperators
+      DeriveFunctor
+      DeriveTraversable
+      DeriveGeneric
+      DerivingStrategies
+      StandaloneDeriving
+      TemplateHaskell
+      RankNTypes
+      GADTs
+      MultiParamTypeClasses
+  ghc-options: -Wall -threaded -rtsopts -with-rtsopts=-N
+  build-depends:
+      Hclip
+    , aeson
+    , aeson-extra
+    , aeson-pretty
+    , ansi-terminal
+    , base >=4.7 && <5
+    , bytestring
+    , comonad
+    , containers
+    , free
+    , hashable
+    , jet
+    , lens
+    , mtl
+    , prettyprinter
+    , prettyprinter-ansi-terminal
+    , recursion-schemes
+    , recursive-zipper ==0.0.0.0
+    , scientific
+    , text
+    , text-zipper
+    , transformers
+    , unix
+    , unordered-containers
+    , vector
+    , vty
+  default-language: Haskell2010
diff --git a/src/Jet.hs b/src/Jet.hs
new file mode 100644
--- /dev/null
+++ b/src/Jet.hs
@@ -0,0 +1,929 @@
+{-# LANGUAGE ApplicativeDo #-}
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE InstanceSigs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+module Jet (run) where
+
+import Control.Category ((>>>))
+import Control.Comonad (extract)
+import qualified Control.Comonad as Comonad
+import Control.Comonad.Cofree
+import qualified Control.Comonad.Trans.Cofree as CofreeF
+import Control.Lens hiding ((:<))
+import qualified Control.Lens.Cons as Cons
+import Control.Monad.State
+import Control.Monad.Trans.Maybe (MaybeT (MaybeT, runMaybeT))
+import Data.Aeson (Value)
+import qualified Data.Aeson as Aeson
+import Data.Aeson.Encode.Pretty (encodePretty)
+import Data.Aeson.Extra
+import qualified Data.ByteString.Lazy.Char8 as BS
+import qualified Data.ByteString.Lazy.Char8 as LBS
+import Data.Functor.Classes (Eq1 (..), Ord1 (liftCompare))
+import qualified Data.Functor.Foldable as FF
+import Data.HashMap.Strict (HashMap)
+import qualified Data.HashMap.Strict as HM
+import qualified Data.HashMap.Strict as HashMap
+import Data.Hashable (Hashable)
+import qualified Data.List as List
+import Data.Maybe
+import Data.Sequence (Seq)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Text.Zipper as TZ
+import Data.Vector (Vector)
+import qualified Data.Vector as Vector
+import qualified Graphics.Vty as Vty
+import Graphics.Vty.Input.Events
+import qualified Jet.Render as Render
+import Prettyprinter as P
+import System.Environment (getArgs)
+import System.Exit (exitFailure)
+import System.Hclip
+import System.IO (IOMode (ReadWriteMode), openFile)
+import qualified System.IO as IO
+import qualified System.Posix as Posix
+import Text.Read (readMaybe)
+import qualified Zipper.Recursive as Z
+
+tabSize :: Int
+tabSize = 2
+
+maxUndoStates :: Int
+maxUndoStates = 100
+
+hoistMaybe :: Maybe a -> MaybeT Editor a
+hoistMaybe = MaybeT . pure
+
+data EditorState = EditorState
+  { _undo :: UndoZipper (Z.Zipper JIndex ValueF FocusState),
+    _mode :: Mode,
+    _register :: ValueF (Cofree ValueF FocusState),
+    _vty :: Vty.Vty,
+    _flash :: Text,
+    _save :: Z.Zipper JIndex ValueF FocusState -> Editor ()
+  }
+
+newtype Editor a = Editor {runEditor :: StateT EditorState IO a}
+  deriving newtype (Functor, Applicative, Monad, MonadState EditorState, MonadIO)
+
+mode_ :: Lens' EditorState Mode
+mode_ = lens _mode (\s m -> s {_mode = m})
+
+register_ :: Lens' EditorState (ValueF (Cofree ValueF FocusState))
+register_ = lens _register (\s m -> s {_register = m})
+
+undo_ :: Lens' EditorState (UndoZipper (Z.Zipper JIndex ValueF FocusState))
+undo_ = lens _undo (\s m -> s {_undo = m})
+
+vty_ :: Lens' EditorState Vty.Vty
+vty_ = lens _vty (\s m -> s {_vty = m})
+
+flash_ :: Lens' EditorState Text
+flash_ = lens _flash (\s m -> s {_flash = m})
+
+save_ :: Lens' EditorState (Z.Zipper JIndex ValueF FocusState -> Editor ())
+save_ = lens _save (\s m -> s {_save = m})
+
+recover :: a -> MaybeT Editor a -> Editor a
+recover def m = do
+  let e = runMaybeT m
+  s <- get
+  r <- liftIO $ flip runStateT s . runEditor $ e
+  case r of
+    (Just a, newS) -> put newS *> pure a
+    (Nothing, _) -> pure def
+
+data Focused = Focused | NotFocused
+  deriving (Eq)
+
+data Folded = Folded | NotFolded
+  deriving (Eq)
+
+type PrettyJSON = Doc (Either Render.Cursor Vty.Attr)
+
+type Buffer = TZ.TextZipper Text
+
+-- | Nodes are annotated with one of these.
+-- This includes information about the node itself, but also
+-- a cached render of the node, which allows us to re-render
+-- the whole tree much faster.
+data FocusState = FocusState
+  { isFocused :: Focused,
+    isFolded :: Folded,
+    rendered :: PrettyJSON
+  }
+
+instance Eq FocusState where
+  a == b =
+    isFocused a == isFocused b
+      && isFolded a == isFolded b
+
+focused_ :: Lens' FocusState Focused
+focused_ = lens isFocused (\fs new -> fs {isFocused = new})
+
+folded_ :: Lens' FocusState Folded
+folded_ = lens isFolded (\fs new -> fs {isFolded = new})
+
+toggleFold :: Folded -> Folded
+toggleFold Folded = NotFolded
+toggleFold NotFolded = Folded
+
+run :: IO ()
+run = do
+  (json, srcFile) <-
+    getArgs >>= \case
+      [] -> do
+        json <-
+          (Aeson.eitherDecode . BS.pack <$> getContents) >>= \case
+            Left err -> do
+              IO.hPutStrLn IO.stderr err
+              exitFailure
+            Right json -> pure json
+        pure (json, Nothing)
+      [f] -> do
+        json <-
+          Aeson.eitherDecodeFileStrict f >>= \case
+            Left err -> do
+              IO.hPutStrLn IO.stderr err
+              exitFailure
+            Right json -> pure json
+        pure (json, Just f)
+      _ -> IO.hPutStrLn IO.stderr "usage: structural-json FILE.json" *> exitFailure
+  result <- edit srcFile $ json
+  BS.putStrLn $ encodePretty result
+
+edit :: Maybe FilePath -> Value -> IO Value
+edit srcFile value = do
+  -- Use tty so we don't interfere with stdin/stdout
+  tty <- openFile "/dev/tty" ReadWriteMode >>= Posix.handleToFd
+  config <- liftIO $ Vty.standardIOConfig
+  vty <- (liftIO $ Vty.mkVty config {Vty.inputFd = Just tty, Vty.outputFd = Just tty})
+  -- load the value into a zipper.
+  let z = Z.zipper . toCofree $ value
+  v <- flip evalStateT (editorState srcFile vty) . runEditor $ loop z
+  Vty.shutdown vty
+  pure (Z.flatten v)
+
+loop ::
+  Z.Zipper JIndex ValueF FocusState ->
+  Editor (Z.Zipper JIndex ValueF FocusState)
+loop z = do
+  vty <- use vty_
+  renderScreen z
+  flash_ .= ""
+  e <- liftIO $ Vty.nextEvent vty
+  nextZ <- handleEvent e z
+  if (shouldExit e)
+    then pure nextZ
+    else (loop nextZ)
+
+renderScreen :: Z.Zipper JIndex ValueF FocusState -> Editor ()
+renderScreen z = do
+  vty <- use vty_
+  (winWidth, winHeight) <- bounds
+  rendered <- uses mode_ (\m -> fullRender m z)
+  footer <- footerImg
+  let screen = Vty.vertCat . Render.renderScreen (winHeight - Vty.imageHeight footer) . layoutPretty defaultLayoutOptions $ rendered
+  let spacerHeight = winHeight - (Vty.imageHeight screen + Vty.imageHeight footer)
+  let spacers = Vty.charFill Vty.defAttr ' ' winWidth spacerHeight
+  liftIO $ Vty.update vty (Vty.picForImage (screen Vty.<-> spacers Vty.<-> footer))
+
+-- | Get the current bounds of the current terminal screen.
+bounds :: Editor (Int, Int)
+bounds = use vty_ >>= liftIO . Vty.displayBounds . Vty.outputIface
+
+-- | Render the footer bar to an image
+footerImg :: Editor Vty.Image
+footerImg = do
+  (w, _) <- bounds
+  flash <- gets _flash
+  let attr = (Vty.defAttr `Vty.withForeColor` Vty.green `Vty.withStyle` Vty.reverseVideo)
+      helpMsg = Vty.text' attr "| Press '?' for help"
+      flashMsg = Vty.text' (attr `Vty.withStyle` Vty.bold) (" " <> flash)
+  pure $
+    Vty.horizCat
+      [ flashMsg,
+        Vty.charFill attr ' ' (w - (Vty.imageWidth helpMsg + Vty.imageWidth flashMsg)) 1,
+        helpMsg
+      ]
+
+-- | Push the given zipper onto history iff it's distinct from the most recent undo state.
+pushUndo :: Z.Zipper JIndex ValueF FocusState -> Editor ()
+pushUndo z =
+  undo_ %= \case
+    (UndoZipper (ls Cons.:> _) _) | length ls >= maxUndoStates -> UndoZipper (z <| ls) Empty
+    (UndoZipper ls _) -> UndoZipper (z <| ls) Empty
+
+editorState :: Maybe FilePath -> Vty.Vty -> EditorState
+editorState srcFile vty =
+  EditorState
+    { _undo = UndoZipper Empty Empty,
+      _mode = Move,
+      _register = NullF,
+      _vty = vty,
+      _flash = "Hello World",
+      _save = saveFile
+    }
+  where
+    saveFile = case srcFile of
+      Nothing -> const (pure ())
+      Just fp -> \z -> do
+        liftIO $ BS.writeFile fp (z & Z.flatten & encodePretty @Value)
+        flash_ .= "Saved to " <> Text.pack fp
+
+shouldExit :: Vty.Event -> Bool
+shouldExit = \case
+  EvKey (KChar 'c') [Vty.MCtrl] -> True
+  EvKey (KChar 'q') [] -> True
+  _ -> False
+
+bufferText :: Buffer -> Text
+bufferText = Text.concat . TZ.getText
+
+-- | Apply the state that's in the current mode's buffer to the selected node if possible.
+applyBuf :: Z.Zipper JIndex ValueF FocusState -> Editor (Z.Zipper JIndex ValueF FocusState)
+applyBuf z = do
+  use mode_ >>= \case
+    Edit buf -> do
+      let txt = buf ^. to bufferText
+      mode_ .= Move
+      pure
+        ( z & Z.unwrapped_ . _unwrap
+            %~ ( \case
+                   StringF _ -> StringF txt
+                   (NumberF n) -> NumberF . fromMaybe n . readMaybe $ Text.unpack txt
+                   x -> x
+               )
+        )
+    KeyEdit key buf -> do
+      let txt = buf ^. to bufferText
+      mode_ .= (KeyMove txt)
+      pure
+        ( z & Z.unwrapped_ . _unwrap
+            %~ ( \case
+                   (ObjectF hm) -> ObjectF $ renameKey key txt hm
+                   x -> x
+               )
+        )
+    _ -> pure z
+
+renameKey :: (Hashable k, Eq k) => k -> k -> HashMap k v -> HashMap k v
+renameKey srcKey destKey hm =
+  hm
+    &~ do
+      v <- use (at srcKey)
+      at srcKey .= Nothing
+      at destKey .= v
+
+-- | Create a buffer using the text from the current value.
+bufferForValueF :: ValueF x -> Maybe Buffer
+bufferForValueF = \case
+  (ObjectF _hm) -> Nothing
+  (ArrayF _vec) -> Nothing
+  StringF txt -> Just $ newBuffer txt
+  (NumberF sci) ->
+    Just $ newBuffer (Text.pack . show $ sci)
+  (BoolF True) -> Just $ newBuffer "true"
+  (BoolF False) -> Just $ newBuffer "true"
+  NullF -> Just $ newBuffer "null"
+
+boolText_ :: Prism' Text Bool
+boolText_ = prism' toText toBool
+  where
+    toText True = "true"
+    toText False = "false"
+    toBool "true" = Just True
+    toBool "false" = Just False
+    toBool _ = Nothing
+
+data Mode
+  = Edit {_buf :: Buffer}
+  | Move
+  | KeyMove {_selectedKey :: Text}
+  | KeyEdit {_selectedKey :: Text, _buf :: Buffer}
+  deriving (Show)
+
+buf_ :: Traversal' Mode Buffer
+buf_ f = \case
+  Edit b -> Edit <$> f b
+  Move -> pure Move
+  KeyMove txt -> pure (KeyMove txt)
+  KeyEdit txt b -> KeyEdit txt <$> f b
+
+-- | Main event handler
+handleEvent :: Vty.Event -> Z.Zipper JIndex ValueF FocusState -> Editor (Z.Zipper JIndex ValueF FocusState)
+handleEvent evt zipper = do
+  use mode_ >>= \case
+    KeyMove {} -> handleMove zipper
+    Move {} -> handleMove zipper
+    KeyEdit {} -> handleEdit zipper
+    Edit {} -> handleEdit zipper
+  where
+    handleEdit ::
+      ( Z.Zipper JIndex ValueF FocusState ->
+        Editor (Z.Zipper JIndex ValueF FocusState)
+      )
+    handleEdit z =
+      case evt of
+        EvKey key [] ->
+          -- Perform buffer updates:
+          case key of
+            KChar c -> do
+              mode_ . buf_ %= TZ.insertChar c
+              pure z
+            KLeft -> do
+              mode_ . buf_ %= TZ.moveLeft
+              pure z
+            KRight -> do
+              mode_ . buf_ %= TZ.moveRight
+              pure z
+            KBS -> do
+              mode_ . buf_ %= TZ.deletePrevChar
+              pure z
+            KEsc -> do
+              newZ <- applyBuf z
+              pure $ newZ
+            _ -> pure z
+        _ -> pure z
+    handleMove ::
+      ( Z.Zipper JIndex ValueF FocusState ->
+        Editor (Z.Zipper JIndex ValueF FocusState)
+      )
+    handleMove z =
+      case evt of
+        EvKey key mods -> case key of
+          -- move up
+          KChar 'h' -> z & outOf
+          -- move down
+          KChar 'l' -> do
+            z & Z.focus_ . folded_ .~ NotFolded
+              & into
+          -- next sibling
+          KChar 'j' -> z & sibling Forward
+          -- move down
+          KChar 'J' -> do
+            pushUndo z
+            pure (z & moveElement Forward)
+          -- prev sibling
+          KChar 'k' -> z & sibling Backward
+          -- move up
+          KChar 'K' -> do
+            pushUndo z
+            pure (z & moveElement Backward)
+          -- add new node
+          KChar 'i' -> do
+            pushUndo z
+            insert z
+          -- replace with boolean
+          KChar 'b' -> do
+            pushUndo z
+            pure (z & setFocus (BoolF True))
+          -- replace with object
+          KChar 'o' -> do
+            pushUndo z
+            pure (z & setFocus (ObjectF mempty))
+          -- replace with array
+          KChar 'a' -> do
+            pushUndo z
+            pure (z & setFocus (ArrayF mempty))
+          -- replace with number
+          KChar 'n' -> do
+            pushUndo z
+            pure (z & setFocus (NumberF 0))
+          -- replace with Null
+          KChar 'N' -> do
+            pushUndo z
+            pure (z & setFocus NullF)
+          -- Save file
+          KChar 's'
+            | [Vty.MCtrl] <- mods -> do
+              saver <- use save_
+              saver z
+              pure z
+          -- replace with string
+          KChar 's' -> do
+            pushUndo z
+            pure (z & setFocus (StringF ""))
+          -- undo
+          KChar 'u' -> do
+            flash_ .= "Undo"
+            undo_ %%= \case
+              (UndoZipper (l Cons.:< ls) rs) ->
+                (l, UndoZipper ls (z Cons.:< rs))
+              lz -> (z, lz)
+          -- redo
+          KChar 'r' | [Vty.MCtrl] <- mods -> do
+            flash_ .= "Redo"
+            undo_ %%= \case
+              (UndoZipper ls (r Cons.:< rs)) -> (r, UndoZipper (z Cons.:< ls) rs)
+              lz -> (z, lz)
+          -- toggle bool
+          KChar ' ' -> do
+            pushUndo z
+            pure (z & tryToggleBool)
+          -- copy
+          KChar 'y' -> do
+            flash_ .= "Copied"
+            copy z
+          -- paste
+          KChar 'p' -> do
+            flash_ .= "Paste"
+            pushUndo z
+            paste z
+          -- cut
+          KChar 'x' -> do
+            flash_ .= "Cut"
+            pushUndo z
+            copy z >>= delete
+          -- help
+          KChar '?' -> do
+            vty <- use vty_
+            liftIO $ Vty.update vty (Vty.picForImage helpImg)
+            void $ liftIO $ Vty.nextEvent vty
+            pure z
+          -- add child
+          KEnter -> do
+            pushUndo z
+            tryAddChild z
+          -- toggle fold
+          KChar '\t' -> do
+            -- Exit KeyMove mode if we're in it.
+            mode_ .= Move
+            pure $ (z & Z.focus_ . folded_ %~ toggleFold)
+          -- Fold all children
+          KChar 'F' -> do
+            -- Fold all child branches
+            pure $ mapChildren (mapped . folded_ .~ Folded) z
+          -- unfold all children
+          KChar 'f' -> do
+            -- Unfold all child branches
+            pure $ mapChildren (mapped . folded_ .~ NotFolded) z
+          -- delete node
+          KBS -> do
+            flash_ .= "Deleted"
+            pushUndo z
+            delete z
+          _ -> pure z
+        _ -> pure z
+    paste z = do
+      reg <- use register_
+      pure (z & setFocus reg)
+    copy z = do
+      let curVal = Z.branches z
+      register_ .= curVal
+      liftIO $ setClipboard (encodeValueFCofree curVal)
+      pure z
+    insert z = do
+      use mode_ >>= \case
+        KeyMove k -> do
+          mode_ .= KeyEdit k (newBuffer k)
+          pure $ z & Z.focus_ . folded_ .~ NotFolded
+        Move
+          | Just editBuf <- bufferForValueF (z ^. Z.branches_) -> do
+            mode_ .= Edit editBuf
+            pure $ z & Z.focus_ . folded_ .~ NotFolded
+        _ -> pure z
+
+encodeValueFCofree :: ValueF (Cofree ValueF FocusState) -> String
+encodeValueFCofree vf = LBS.unpack . encodePretty . FF.embed $ fmap (FF.cata alg) vf
+  where
+    alg :: CofreeF.CofreeF ValueF ann Value -> Value
+    alg (_ CofreeF.:< vf') = FF.embed vf'
+
+-- | Set the value of the focused node.
+setFocus ::
+  ValueF (Cofree ValueF FocusState) ->
+  Z.Zipper JIndex ValueF FocusState ->
+  Z.Zipper JIndex ValueF FocusState
+setFocus f z = z & Z.branches_ .~ f & rerender
+
+data Dir = Forward | Backward
+
+-- | Move the current value within an array
+moveElement :: Dir -> Z.Zipper JIndex ValueF FocusState -> Z.Zipper JIndex ValueF FocusState
+moveElement dir z = fromMaybe z $ do
+  i <- case Z.currentIndex z of
+    Just (Index i) -> pure i
+    _ -> Nothing
+  parent <- z & rerender & Z.up
+  pure $
+    case parent ^. Z.branches_ of
+      ArrayF arr ->
+        let swapI = case dir of
+              Forward -> i + 1
+              Backward -> i - 1
+            moves =
+              [ (i, arr Vector.!? swapI),
+                (swapI, arr Vector.!? i)
+              ]
+                & sequenceOf (traversed . _2)
+                & fromMaybe []
+         in parent
+              & Z.branches_ .~ ArrayF (arr Vector.// moves)
+              & fromMaybe z . Z.down (Index swapI)
+      _ -> z
+
+tryToggleBool :: Z.Zipper JIndex ValueF FocusState -> Z.Zipper JIndex ValueF FocusState
+tryToggleBool z =
+  z & Z.branches_ %~ \case
+    BoolF b -> BoolF (not b)
+    x -> x
+
+tryAddChild :: Z.Zipper JIndex ValueF FocusState -> Editor (Z.Zipper JIndex ValueF FocusState)
+tryAddChild z =
+  z & Z.branches_ %%~ \case
+    ObjectF hm -> do
+      mode_ .= (KeyEdit "" $ newBuffer "")
+      pure $ ObjectF $ HM.insert "" (toCofree Aeson.Null) hm
+    ArrayF arr -> do
+      mode_ .= Move
+      pure $ ArrayF $ arr <> pure (toCofree Aeson.Null)
+    x -> pure x
+
+-- | Delete the current node
+delete :: Z.Zipper JIndex ValueF FocusState -> Editor (Z.Zipper JIndex ValueF FocusState)
+delete z = do
+  curMode <- use mode_
+  mode_ .= Move
+  pure $ case z ^. Z.branches_ of
+    -- If we're in a Key focus, delete that key
+    ObjectF hm
+      | KeyMove k <- curMode ->
+        ( z & Z.branches_ .~ ObjectF (HM.delete k hm)
+        )
+    -- Otherwise move up a layer and delete the key we were in.
+    _ -> case Z.currentIndex z of
+      -- If we don't have a parent, set the current node to null
+      Nothing ->
+        z & Z.branches_ .~ NullF
+      Just i -> fromMaybe z $ do
+        parent <- z & rerender & Z.up
+        pure $
+          parent & Z.branches_ %~ \case
+            ObjectF hm | Key k <- i -> ObjectF (HM.delete k hm)
+            ArrayF arr | Index j <- i -> ArrayF (Vector.ifilter (\i' _ -> i' /= j) arr)
+            x -> x
+
+-- | Move to next/previous sibling.
+sibling :: Dir -> Z.Zipper JIndex ValueF FocusState -> Editor (Z.Zipper JIndex ValueF FocusState)
+sibling dir z = recover z $ do
+  mode <- use mode_
+  case (mode, Z.branches z) of
+    (KeyMove k, ObjectF hm) -> do
+      case findSiblingIndex (== k) $ HashMap.keys hm of
+        Nothing -> pure z
+        Just theKey -> do
+          mode_ .= KeyMove theKey
+          pure z
+    _ -> do
+      curI <- hoistMaybe $ Z.currentIndex z
+      parent <- hoistMaybe $ (z & rerender & Z.up)
+      let newI = case Z.branches parent of
+            ObjectF hm -> do
+              let keys = HM.keys hm
+              newKey <- findSiblingIndex (\k -> Key k == curI) keys
+              pure $ Key newKey
+            ArrayF xs -> case curI of
+              (Index i) -> alterIndex xs i
+              _ -> Nothing
+            StringF {} -> Nothing
+            NumberF {} -> Nothing
+            BoolF {} -> Nothing
+            NullF -> Nothing
+      case newI of
+        Just i -> hoistMaybe $ Z.down i parent
+        Nothing -> hoistMaybe Nothing
+  where
+    (findSiblingIndex, alterIndex) = case dir of
+      Forward ->
+        ( findAfter,
+          \xs i -> if i < length xs - 1 then Just (Index (i + 1)) else Nothing
+        )
+      Backward ->
+        ( findBefore,
+          \_xs i -> if i > 0 then Just (Index (i -1)) else Nothing
+        )
+
+findAfter :: (a -> Bool) -> [a] -> Maybe a
+findAfter p xs = fmap snd . List.find (p . fst) $ zip xs (drop 1 xs)
+
+findBefore :: (a -> Bool) -> [a] -> Maybe a
+findBefore p xs = fmap snd . List.find (p . fst) $ zip (drop 1 xs) xs
+
+newBuffer :: Text -> Buffer
+newBuffer txt = TZ.gotoEOF $ TZ.textZipper (Text.lines txt) Nothing
+
+-- | Move into the current node
+into :: Z.Zipper JIndex ValueF FocusState -> Editor (Z.Zipper JIndex ValueF FocusState)
+into z = do
+  mode <- use mode_
+  case (Z.branches z, mode) of
+    (ObjectF _, KeyMove key) -> do
+      mode_ .= Move
+      pure (Z.tug (Z.down (Key key)) z)
+    (ObjectF hm, Move) -> do
+      case (HM.keys hm) ^? _head of
+        Just fstKey -> do
+          mode_ .= KeyMove fstKey
+          pure z
+        _ -> pure z
+    (ArrayF {}, _) -> do
+      pure $ Z.tug (Z.down (Index 0)) z
+    _ -> pure z
+
+-- | Move out of the current node
+outOf :: Z.Zipper JIndex ValueF FocusState -> Editor (Z.Zipper JIndex ValueF FocusState)
+outOf z = do
+  mode <- use mode_
+  maybeParentKey <- case (Z.currentIndex z) of
+    Just (Key k) -> pure $ Just k
+    _ -> pure Nothing
+
+  case (Z.branches z, mode) of
+    (ObjectF _, KeyMove {}) -> do
+      mode_ .= Move
+      pure z
+    _ -> do
+      maybe (pure ()) (\k -> mode_ .= KeyMove k) maybeParentKey
+      pure (Z.tug (rerender >>> Z.up) z)
+
+-- | Render the full zipper using render caches stored in each node.
+fullRender :: Mode -> Z.Zipper JIndex ValueF FocusState -> PrettyJSON
+fullRender mode z = do
+  let focusedRender =
+        z & Z.focus_ . focused_ .~ Focused
+          & Z.unwrapped_ %~ \(fs :< vf) ->
+            let rerendered = renderSubtree fs mode (rendered . extract <$> vf)
+             in (fs {rendered = rerendered} :< vf)
+  rendered . foldSpine alg $ focusedRender
+  where
+    alg fs vf =
+      fs {rendered = rerenderCached fs (rendered <$> vf)}
+    rerenderCached fs = \case
+      ObjectF o -> prettyObj (isFocused fs) mode o
+      ArrayF a -> prettyArray (isFocused fs) a
+      -- Nodes without children are never part of the spine, but just to have something
+      -- we can render the cache.
+      _ -> rendered fs
+
+-- | Updates the cached render of the current focus, using cached renders for subtrees.
+rerender :: Z.Zipper JIndex ValueF FocusState -> Z.Zipper JIndex ValueF FocusState
+rerender = Z.unwrapped_ %~ rerenderCofree
+
+-- Rerenders a layer of a cofree structure. Doesn't re-render the children.
+rerenderCofree :: Cofree ValueF FocusState -> Cofree ValueF FocusState
+rerenderCofree (fs :< vf) =
+  let rerendered = (renderSubtree fs mode (rendered . extract <$> vf))
+   in fs {rendered = rerendered} :< vf
+  where
+    -- Currently the mode is required by renderSubtree, but for the rerender cache it's
+    -- irrelevant, because it only matters if we're 'focused', and if we're focused, we'll be
+    -- manually rerendered later anyways.
+    mode = Move
+
+-- | Renders a subtree
+renderSubtree :: FocusState -> Mode -> ValueF PrettyJSON -> PrettyJSON
+renderSubtree (FocusState {isFolded = Folded, isFocused}) _ vf = case vf of
+  ObjectF {} -> colored' Vty.white "{...}"
+  ArrayF {} -> colored' Vty.white "[...]"
+  StringF {} -> colored' Vty.green "\"...\""
+  NumberF {} -> colored' Vty.blue "..."
+  NullF {} -> colored' Vty.yellow "..."
+  BoolF {} -> colored' Vty.magenta "..."
+  where
+    colored' :: Vty.Color -> String -> PrettyJSON
+    colored' col txt =
+      P.annotate (Right $ if isFocused == Focused then reverseCol col else Vty.defAttr `Vty.withForeColor` col) (pretty txt)
+renderSubtree (FocusState {isFocused}) mode vf = case vf of
+  (StringF txt) -> cursor isFocused $ case (isFocused, mode) of
+    (Focused, Edit buf) ->
+      colored' Vty.green "\"" <> renderBuffer Vty.green buf <> colored' Vty.green "\""
+    _ -> colored' Vty.green "\"" <> colored' Vty.green (Text.unpack txt) <> colored' Vty.green "\""
+  (NullF) -> cursor isFocused $ colored' Vty.yellow "null"
+  (NumberF n) -> cursor isFocused $ case (isFocused, mode) of
+    (Focused, Edit buf) -> renderBuffer Vty.blue buf
+    _ -> colored' Vty.blue (show n)
+  (BoolF b) -> cursor isFocused $ colored' Vty.magenta (Text.unpack $ boolText_ # b)
+  (ArrayF xs) -> prettyArray isFocused xs
+  (ObjectF xs) -> prettyObj isFocused mode xs
+  where
+    colored' :: Vty.Color -> String -> PrettyJSON
+    colored' col txt =
+      P.annotate (Right $ if isFocused == Focused then reverseCol col else Vty.defAttr `Vty.withForeColor` col) (pretty txt)
+
+-- | Attr in reverse-video
+reverseCol :: Vty.Color -> Vty.Attr
+reverseCol col = Vty.defAttr `Vty.withForeColor` col `Vty.withStyle` Vty.reverseVideo
+
+-- | Map over all children of the current node, re-rendering after changes.
+mapChildren ::
+  (Cofree ValueF FocusState -> Cofree ValueF FocusState) ->
+  Z.Zipper JIndex ValueF FocusState ->
+  Z.Zipper JIndex ValueF FocusState
+mapChildren f = Z.branches_ . mapped %~ FF.cata alg
+  where
+    alg :: CofreeF.CofreeF ValueF FocusState (Cofree ValueF FocusState) -> Cofree ValueF FocusState
+    alg (cf CofreeF.:< vf) = rerenderCofree $ f (cf :< vf)
+
+prettyWith :: Pretty a => Vty.Attr -> a -> PrettyJSON
+prettyWith ann a = annotate (Right ann) $ pretty a
+
+colored :: Pretty a => Vty.Color -> a -> PrettyJSON
+colored col a = annotate (Right $ Vty.defAttr `Vty.withForeColor` col) $ pretty a
+
+renderBuffer :: Vty.Color -> Buffer -> PrettyJSON
+renderBuffer col buf =
+  let (prefix, suffix) = Text.splitAt (snd $ TZ.cursorPosition buf) (bufferText buf)
+      suffixImg = case Text.uncons suffix of
+        Nothing -> prettyWith (reverseCol col) ' '
+        Just (c, rest) -> prettyWith (reverseCol col) c <> colored col rest
+   in colored col prefix <> suffixImg
+
+cursor :: Focused -> PrettyJSON -> PrettyJSON
+cursor Focused = P.annotate (Left Render.Cursor)
+cursor _ = id
+
+prettyArray :: Focused -> Vector PrettyJSON -> PrettyJSON
+prettyArray foc vs =
+  let inner :: [PrettyJSON] =
+        Vector.toList vs
+          & imap (\i v -> v <> commaKey i)
+   in cursor foc $ vsep $ [img "[", indent tabSize (vsep inner), img "]"]
+  where
+    img :: Text -> PrettyJSON
+    img t = case foc of
+      Focused -> prettyWith (reverseCol Vty.white) t
+      NotFocused -> pretty t
+    commaKey i
+      | i == Vector.length vs - 1 = mempty
+      | otherwise = ","
+
+prettyObj :: Focused -> Mode -> HashMap Text PrettyJSON -> PrettyJSON
+prettyObj focused mode vs =
+  let inner :: PrettyJSON
+      inner =
+        vsep
+          ( HM.toList vs
+              & imap
+                ( \i (k, v) ->
+                    vsep [imgForKey k <> pretty @Text ": ", indent tabSize (v <> commaKey i)]
+                )
+          )
+      rendered = vsep [img "{", indent tabSize inner, img "}"]
+   in case mode of
+        Move -> cursor focused rendered
+        _ -> rendered
+  where
+    hmSize = HM.size vs
+    commaKey i
+      | i == hmSize - 1 = mempty
+      | otherwise = ","
+    imgForKey k = case focused of
+      NotFocused -> colored Vty.cyan (show k)
+      Focused -> case mode of
+        KeyMove focKey | focKey == k -> cursor Focused $ prettyWith (reverseCol Vty.cyan) (show focKey)
+        KeyEdit focKey buf | focKey == k -> cursor Focused $ colored Vty.cyan '"' <> renderBuffer Vty.cyan buf <> colored Vty.cyan '"'
+        _ -> colored Vty.cyan (show k)
+    img :: Text -> PrettyJSON
+    img t = case (focused, mode) of
+      (Focused, Move) -> prettyWith (reverseCol Vty.white) t
+      _ -> pretty t
+
+-- Orphan instances
+instance Eq1 ValueF where
+  liftEq f vf1 vf2 = case (vf1, vf2) of
+    (ObjectF l, ObjectF r) -> liftEq f l r
+    (ArrayF l, ArrayF r) -> liftEq f l r
+    (NullF, NullF) -> True
+    (StringF l, StringF r) -> l == r
+    (NumberF l, NumberF r) -> l == r
+    (BoolF l, BoolF r) -> l == r
+    _ -> False
+
+instance Ord1 ValueF where
+  liftCompare f vf1 vf2 = case (vf1, vf2) of
+    (ObjectF l, ObjectF r) -> liftCompare f l r
+    (ArrayF l, ArrayF r) -> liftCompare f l r
+    (NullF, NullF) -> EQ
+    (StringF l, StringF r) -> compare l r
+    (NumberF l, NumberF r) -> compare l r
+    (BoolF l, BoolF r) -> compare l r
+    (NullF, _) -> LT
+    (_, NullF) -> GT
+    (BoolF _, _) -> LT
+    (_, BoolF _) -> GT
+    (NumberF _, _) -> LT
+    (_, NumberF _) -> GT
+    (StringF _, _) -> LT
+    (_, StringF _) -> GT
+    (ArrayF _, _) -> LT
+    (_, ArrayF _) -> GT
+
+data JIndex
+  = Index Int
+  | Key Text
+  deriving (Show, Eq, Ord)
+
+instance FunctorWithIndex JIndex ValueF
+
+instance FoldableWithIndex JIndex ValueF
+
+instance TraversableWithIndex JIndex ValueF where
+  itraverse f = \case
+    NullF -> pure NullF
+    StringF txt -> pure (StringF txt)
+    NumberF sci -> pure (NumberF sci)
+    BoolF b -> pure (BoolF b)
+    ObjectF hm -> ObjectF <$> itraverse (\k a -> f (Key k) a) hm
+    ArrayF arr -> ArrayF <$> itraverse (\k a -> f (Index k) a) arr
+
+type instance Index (ValueF a) = JIndex
+
+type instance IxValue (ValueF a) = a
+
+instance Ixed (ValueF a) where
+  ix (Index i) f (ArrayF xs) = ArrayF <$> ix i f xs
+  ix (Key k) f (ObjectF xs) = ObjectF <$> ix k f xs
+  ix _ _ x = pure x
+
+toCofree :: (Value -> Cofree ValueF FocusState)
+toCofree t = FF.hylo alg FF.project $ t
+  where
+    defaultFs = FocusState NotFocused NotFolded mempty
+    mode = Move
+    alg :: ValueF (Cofree ValueF FocusState) -> Cofree ValueF FocusState
+    alg vf = defaultFs {rendered = renderSubtree defaultFs mode (rendered . extract <$> vf)} :< vf
+
+helpImg :: Vty.Image
+helpImg =
+  let helps =
+        [ ("h", "ascend"),
+          ("l", "descend"),
+          ("j", "next sibling"),
+          ("k", "previous sibling"),
+          ("J", "move down (in array)"),
+          ("K", "move up (in array)"),
+          ("i", "enter edit mode (string/number)"),
+          ("<C-s>", "save file"),
+          ("<SPACE>", "toggle boolean"),
+          ("<ESC>", "exit edit mode"),
+          ("<BS>", "delete key/element"),
+          ("<ENTER>", "add new key/element (object/array)"),
+          ("<TAB>", "toggle fold"),
+          ("f", "unfold all children"),
+          ("F", "fold all children"),
+          ("s", "replace element with string"),
+          ("b", "replace element with bool"),
+          ("n", "replace element with number"),
+          ("N", "replace element with null"),
+          ("a", "replace element with array"),
+          ("o", "replace element with object"),
+          ("u", "undo last change (undo buffer keeps 100 states)"),
+          ("<C-r>", "redo from undo states"),
+          ("y", "copy current value into buffer (and clipboard)"),
+          ("p", "paste value from buffer over current value"),
+          ("x", "cut a value, equivalent to a copy -> delete"),
+          ("q | ctrl-c", "quit without saving. Due to a bug, tap twice")
+        ]
+
+      (keys, descs) =
+        unzip
+          ( helps <&> \(key, desc) ->
+              ( Vty.text' (Vty.defAttr `Vty.withForeColor` Vty.green) (key <> ": "),
+                Vty.text' Vty.defAttr desc
+              )
+          )
+   in (Vty.vertCat keys Vty.<|> Vty.vertCat descs)
+
+-- | Recomputes the spine at the current position, then at every position from that point
+-- upwards until the zipper is closed, returning the result.
+foldSpine :: (Functor f, Z.Idx i f a) => (a -> f a -> a) -> Z.Zipper i f a -> a
+foldSpine f z =
+  case Z.up z of
+    Nothing -> z ^. Z.focus_
+    Just parent ->
+      let next = f (parent ^. Z.focus_) (fmap Comonad.extract . Z.branches $ parent)
+       in foldSpine f (parent & Z.focus_ .~ next)
+
+data UndoZipper a
+  = UndoZipper
+      (Seq a)
+      -- ^ undo states
+      (Seq a)
+      -- ^ redo states
diff --git a/src/Jet/Render.hs b/src/Jet/Render.hs
new file mode 100644
--- /dev/null
+++ b/src/Jet/Render.hs
@@ -0,0 +1,84 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Jet.Render where
+
+import Control.Lens
+import Control.Monad.State
+import qualified Data.List as List
+import Data.Maybe
+import qualified Graphics.Vty as Vty
+import Prettyprinter
+import Prettyprinter.Render.Util.StackMachine (renderSimplyDecoratedA)
+
+data Cursor = Cursor
+  deriving (Show, Eq)
+
+data Output = LineBreak | ImgChunk Vty.Image
+
+renderScreen :: Int -> SimpleDocStream (Either Cursor Vty.Attr) -> [Vty.Image]
+renderScreen winHeight doc = do
+  let (outputs, (_, _, mcursor)) =
+        doc
+          & toLineStream
+          & flip runState ([], 0, Nothing)
+
+  let allLines =
+        outputs & List.foldr collapse ([], [])
+          -- clean up anything remaining in the buffer
+          & ( \(buf, rest) ->
+                if null buf
+                  then rest
+                  else Vty.horizCat buf : rest
+            )
+
+  let cropped = case mcursor of
+        Nothing -> allLines
+        Just cursorPos -> takeSurroundingCursor winHeight cursorPos allLines
+  cropped
+  where
+    collapse out (buf, rest) =
+      case out of
+        LineBreak -> ([], Vty.horizCat buf : rest)
+        ImgChunk img -> (img : buf, rest)
+
+-- Take only enough lines to fill the screen, with the cursor centered don't print any more.
+takeSurroundingCursor :: Int -> Int -> [a] -> [a]
+takeSurroundingCursor height cursorPos xs
+  | cursorPos - half > 0 =
+    xs
+      & drop (cursorPos - half)
+      & take height
+  | otherwise = take height xs
+  where
+    half = height `div` 2
+
+toLineStream ::
+  SimpleDocStream (Either Cursor Vty.Attr) ->
+  State ([Vty.Attr], Int, Maybe Int) [Output]
+toLineStream doc =
+  renderSimplyDecoratedA
+    renderText
+    pushAnn
+    popAnn
+    doc
+  where
+    popAnn _ = do
+      _1 %= drop 1
+      -- Need to clear existing colors or they bleed to the right.
+      pure [ImgChunk (Vty.text' Vty.defAttr "")]
+    pushAnn = \case
+      Left Cursor -> do
+        cursorLine <- use _2
+        _3 ?= cursorLine
+        pure []
+      Right ann -> do
+        _1 %= (ann :)
+        pure mempty
+    -- prettyprinter always renders lines as a single text fragment
+    renderText "\n" = do
+      _2 += 1
+      pure [LineBreak]
+    -- prettyprinter never passes text with newlines here
+    renderText txt = do
+      attr <- uses _1 (fromMaybe Vty.defAttr . listToMaybe)
+      pure [ImgChunk (Vty.text' attr txt)]
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,2 @@
+main :: IO ()
+main = putStrLn "Test suite not yet implemented"
