diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for heap-console
+
+## 0.1.0.0 -- 2020-11-20
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2020, TheMatten
+
+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 TheMatten 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,62 @@
+# `heap-console` 🔎
+
+> Debugging - the classic mystery game where you are the detective, the victim,
+> and the murderer!
+>
+> *Some poor developer*
+
+Quite a common approach in Haskell for debugging programs is to use
+`Debug.Trace` - problem is that what we often want to do is not to print random
+strings, but to inspect the state of the program. Debuggers are meant for this,
+but they can be tricky to set up, may require speicific graphical tools to be
+somewhat convenient, and it's easy to forget to run your program through one
+while iterating through changes in code.
+
+`heap-console` let's you inspect your values at runtime using simple
+combinators, resuming execution once you close it:
+
+```txt
+> import Heap.Console
+> inspect (42, 'a')
+[Entering heap-view - use `:help` for more information]
+heap-console> it
+(_, 'a')
+heap-console> !it
+(42, 'a')
+heap-console> it.1
+'a'
+heap-console> :exit
+[Exiting heap-view]
+(42, 'a')
+```
+
+If you give it `Data` instance, it can recognize records too:
+
+```txt
+> :set -XDeriveDataTypeable
+> data Foo = Foo{ bar :: Int, baz :: String } deriving Data
+> inspectAD $ Foo 65 "Hello World!"
+[Entering heap-view - use `:help` for more information]
+heap-console> !it
+Foo {bar = 65, baz = "Hello World!"}
+heap-console> it.baz
+"Hello World!"
+heap-console> ^D
+[Exiting heap-view]
+```
+
+And you can bind values for inspection in random places, getting to them once
+it makes sense:
+
+```txt
+> evidenceD "foo" [1,2,3]
+> inspection
+[Entering heap-view - use `:help` for more information]
+heap-console> foo
+[1, 2, 3]
+heap-console> ^D
+[Exiting heap-view]
+```
+
+To see more features, take a look at documentation, or simply try `:help`
+command.
diff --git a/heap-console.cabal b/heap-console.cabal
new file mode 100644
--- /dev/null
+++ b/heap-console.cabal
@@ -0,0 +1,64 @@
+cabal-version:      2.4
+name:               heap-console
+version:            0.1.0.0
+synopsis:           interactively inspect Haskell values at runtime
+description:        See the README.md file in the project repository.
+homepage:           https://gitlab.com/thematten/heap-console
+bug-reports:        https://gitlab.com/thematten/heap-console/issues
+license:            BSD-3-Clause
+license-file:       LICENSE
+author:             TheMatten
+maintainer:         matten@tuta.io
+
+copyright:          2020 TheMatten
+category:           Language
+extra-source-files: CHANGELOG.md README.md
+
+source-repository head
+  type:                 git
+  location:             git://gitlab.com/thematten/heap-console.git
+
+common common
+    build-depends:      base ^>=4.14.1.0
+    default-extensions: BangPatterns
+                        BlockArguments
+                        DeriveAnyClass
+                        DerivingStrategies
+                        FlexibleContexts
+                        GADTs
+                        GeneralizedNewtypeDeriving
+                        KindSignatures
+                        LambdaCase
+                        NamedFieldPuns
+                        NegativeLiterals
+                        PartialTypeSignatures
+                        PatternSynonyms
+                        RankNTypes
+                        RecordWildCards
+                        ScopedTypeVariables
+                        TupleSections
+                        TypeApplications
+                        TypeOperators
+                        ViewPatterns
+    default-language:   Haskell2010
+    ghc-options:        -Wall -Wno-partial-type-signatures
+
+library
+    import: common
+    build-depends:      exceptions ^>=0.10.4
+                      , containers ^>=0.6.2.1
+                      , haskeline ^>=0.8.1
+                      , ghc-heap ^>=8.10.2
+                      , ghc-prim ^>=0.6.1
+                      , mtl ^>=2.2.2
+                      , show-combinators ^>=0.2
+    exposed-modules:    Heap.Console Heap.Console.Value
+    hs-source-dirs:     src
+
+test-suite heap-console-test
+    import: common
+    build-depends:      heap-console, hspec ^>=2.7.4
+    build-tool-depends: hspec-discover:hspec-discover ^>=2.7.4
+    type:               exitcode-stdio-1.0
+    hs-source-dirs:     test
+    main-is:            Spec.hs
diff --git a/src/Heap/Console.hs b/src/Heap/Console.hs
new file mode 100644
--- /dev/null
+++ b/src/Heap/Console.hs
@@ -0,0 +1,496 @@
+{-# language AllowAmbiguousTypes #-}
+
+-- | This module provides combinators for spawning heap console in convenient
+-- way.
+--
+-- = Console usage
+--
+-- Console startup is indicated by a message:
+--
+-- @
+-- [Entering heap-view - use `:help` for more information]
+-- @
+--
+-- followed by console's prompt:
+--
+-- @
+-- heap-console>
+-- @
+--
+-- here you can probe for values of bindings or use provided commands - e.g.
+-- when opening console with:
+--
+-- @
+-- inspect (42, \'a\')
+-- @
+--
+-- you can inspect given value under name @it@:
+--
+-- @
+-- heap-console> it
+-- (_, \'a\')
+-- @
+--
+-- or see the value strictly evaluated (up to the configured depth):
+--
+-- @
+-- heap-console> !it
+-- (42, \'a\')
+-- @
+--
+-- or you can access it's parts by using selection:
+--
+-- @
+-- heap-console> it.1
+-- \'a\'
+-- @
+--
+-- __Bindings__ can be automatically created with functions like 'inspectD',
+-- added in arbitrary places in program using e.g. 'evidenceD' or added in
+-- console directly by assigning results of selections:
+--
+-- @
+-- heap-console> foo = bar.0.baz
+-- @
+--
+-- __Selections__ consist of sequence of dot-separated indexes, optionally
+-- prefixed with @!@ to force thunks along the way. Valid indexes are:
+--
+-- * positive integer (e.g. @3@) - position of element in list, tuple or other
+--   data constructor
+--
+-- * record field name (e.g. @foo@) - name of field in record (only works when
+--   given enough information - that is, when value has 'Data' instance
+--   available)
+--
+-- In general, it's recommended to prefer combinators suffixed with @D@ when
+-- possible - they require 'Data' instance for bindings being added, but
+-- provide ability to recover record syntax and information about unpacked
+-- fields - in case of combinators without @D@, unpacked fields appear as plain
+-- @Word#@s without any information about their origin and are not indexable.
+-- 'Data' instances can be easily derived using @-XDeriveDataTypeable@.
+module Heap.Console
+  {-# warning
+    "\"heap-console\" is meant for debugging only\
+    \ - make sure you remove it in production."
+  #-}
+  ( inspect
+  , inspectD
+  , inspecting
+  , inspectingD
+  , inspectA
+  , inspectAD
+  , investigate
+  , investigateD
+  , inspection
+  , withInspection
+  , investigation
+  , withEvidence
+  , withEvidenceD
+  , evidence
+  , evidenceD
+  ) where
+
+import Control.Arrow hiding (first, second)
+import Control.Monad.Catch
+import Control.Monad.Except
+import Control.Monad.State.Strict
+import Data.Bifunctor
+import Data.Char
+import Data.Data
+import Data.Function
+import Data.Functor
+import Data.IORef
+import Data.List
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as M
+import Data.Foldable
+import GHC.Exts.Heap
+import GHC.Stack
+import Heap.Console.Value
+import System.Console.Haskeline
+import System.IO.Unsafe
+import Text.Read (readMaybe)
+
+-- TODO: some persistent configuration?
+-- TODO: implement auto-completion.
+
+-------------------------------------------------------------------------------
+-- | Opens console for inspecting argument before returning it. Argument is
+-- provided in console under name @it@.
+--
+-- >>> inspect 42
+-- [Entering heap-view - use `:help` for more information]
+-- ...
+-- [Exiting heap-view]
+-- 42
+inspect :: a -> a
+inspect = join inspecting
+
+-- | Version of 'inspect' providing more precise inspection using 'Data' -
+-- prefer this one where possible.
+--
+-- >>> inspectD 42
+-- [Entering heap-view - use `:help` for more information]
+-- ...
+-- [Exiting heap-view]
+-- 42
+inspectD :: Data a => a -> a
+inspectD = join inspectingD
+
+-- | Opens console for inspecting @a@ before returning @b@. Argument @a@ is
+-- provided in console under name @it@.
+--
+-- >>> inspecting 42 'a'
+-- [Entering heap-view - use `:help` for more information]
+-- ...
+-- [Exiting heap-view]
+-- 'a'
+inspecting :: a -> b -> b
+inspecting = inspectingSome . Left . asBox
+
+-- | Version of 'inspecting' providing more precise inspection using 'Data' -
+-- prefer this one where possible.
+--
+-- >>> inspectingD 42 'a'
+-- [Entering heap-view - use `:help` for more information]
+-- ...
+-- [Exiting heap-view]
+-- 'a'
+inspectingD :: Data a => a -> b -> b
+inspectingD = inspectingSome . Right . Value
+
+inspectingSome :: Either Box Value -> b -> b
+inspectingSome v = seq $ unsafePerformIO do
+  c <- consoleWithEvidence
+  heapConsole c{ consoleBinds = M.insert "it" v $ consoleBinds c }
+{-# noinline inspectingSome #-}
+
+-- | Opens console for inspecting argument. Argument is provided in console
+-- under name @it@.
+--
+-- >>> inspectA 42
+-- [Entering heap-view - use `:help` for more information]
+-- ...
+-- [Exiting heap-view]
+inspectA :: Applicative f => a -> f ()
+inspectA a = inspecting a $ pure ()
+
+-- | Version of 'inspectA' providing more precise inspection using 'Data' -
+-- prefer this one where possible.
+--
+-- >>> inspectAD 42
+-- [Entering heap-view - use `:help` for more information]
+-- ...
+-- [Exiting heap-view]
+inspectAD :: (Data a, Applicative f) => a -> f ()
+inspectAD a = inspectingD a $ pure ()
+
+-- | Opens console for inspecting argument before failing with error. Argument
+-- is provided in console under name @it@.
+--
+-- >>> investigate 42
+-- [Entering heap-view - use `:help` for more information]
+-- ...
+-- [Exiting heap-view]
+-- *** Exception: Heap.Console.investigate: closed investigation
+-- CallStack (from HasCallStack):
+--   investigate, called at <interactive>:1:1 in interactive:Ghci
+investigate :: HasCallStack => a -> b
+investigate a = withFrozenCallStack $ inspecting a $
+  error "Heap.Console.investigate: closed investigation"
+
+-- | Version of 'investigate' providing more precise inspection using 'Data' -
+-- prefer this one where possible.
+--
+-- >>> investigateD 42
+-- [Entering heap-view - use `:help` for more information]
+-- ...
+-- [Exiting heap-view]
+-- *** Exception: Heap.Console.investigateD: closed investigation
+-- CallStack (from HasCallStack):
+--   investigateD, called at <interactive>:1:1 in interactive:Ghci
+investigateD :: (HasCallStack, Data a) => a -> b
+investigateD a = withFrozenCallStack $ inspectingD a $
+  error "Heap.Console.investigateD: closed investigation"
+
+-- | Opens console with recorded "evidence" in scope.
+--
+-- >>> inspection
+-- [Entering heap-view - use `:help` for more information]
+-- ...
+-- [Exiting heap-view]
+inspection :: Applicative f => f ()
+inspection = withInspection $ pure ()
+
+-- | Opens console with recorded "evidence" in scope, before returning given
+-- argument.
+--
+-- >>> withInspection 42
+-- [Entering heap-view - use `:help` for more information]
+-- ...
+-- [Exiting heap-view]
+-- 42
+withInspection :: a -> a
+withInspection a =
+  -- NOTE: do not eta reduce - GHC seems to memoize it as CAF in that case.
+  unsafePerformIO (heapConsole =<< consoleWithEvidence) `seq` a
+{-# noinline withInspection #-}
+
+-- | Opens console with recorded "evidence" in scope before failing with error.
+--
+-- >>> investigation
+-- [Entering heap-view - use `:help` for more information]
+-- heap-console>
+-- [Exiting heap-view]
+-- *** Exception: Heap.Console.investigation: closed investigation
+-- CallStack (from HasCallStack):
+--   investigation, called at <interactive>:1:1 in interactive:Ghci
+investigation :: HasCallStack => a
+investigation = withFrozenCallStack $ withInspection $
+  error "Heap.Console.investigation: closed investigation"
+
+-- | Records @a@ as "evidence" to be later provided in console under given
+-- name, before returning @b@.
+--
+-- >>> withEvidence "foo" 'a' inspection
+-- [Entering heap-view - use `:help` for more information]
+-- heap-console> foo
+-- 'a'
+-- ...
+-- [Exiting heap-view]
+withEvidence :: String -> a -> b -> b
+withEvidence n = withSomeEvidence n . Left . asBox
+
+-- | Version of 'withEvidence' providing more precise inspection using 'Data' -
+-- prefer this one where possible.
+--
+-- >>> withEvidenceD "foo" 'a' inspection
+-- [Entering heap-view - use `:help` for more information]
+-- heap-console> foo
+-- 'a'
+-- ...
+-- [Exiting heap-view]
+withEvidenceD :: Data a => String -> a -> b -> b
+withEvidenceD n = withSomeEvidence n . Right . Value
+
+-- | Records @a@ as "evidence" to be later provided in console under given
+-- name.
+--
+-- >>> evidence "foo" 42
+-- >>> inspection
+-- [Entering heap-view - use `:help` for more information]
+-- heap-console> foo
+-- 42
+-- ...
+-- [Exiting heap-view]
+evidence :: Applicative f => String -> a -> f ()
+evidence n v = withEvidence n v $ pure ()
+
+-- | Version of 'evidence' providing more precise inspection using 'Data' -
+-- prefer this one where possible.
+--
+-- >>> evidenceD "foo" 42
+-- >>> inspection
+-- [Entering heap-view - use `:help` for more information]
+-- heap-console> foo
+-- 42
+-- ...
+-- [Exiting heap-view]
+evidenceD :: (Data a, Applicative f) => String -> a -> f ()
+evidenceD n v = withEvidenceD n v $ pure ()
+
+-------------------------------------------------------------------------------
+withSomeEvidence :: String -> Either Box Value -> a -> a
+withSomeEvidence n v = seq $ unsafePerformIO $ addEvidence n v
+
+consoleWithEvidence :: IO Console
+consoleWithEvidence = readIORef unsafeCollectedEvidence <&> \consoleBinds ->
+  defaultConsole{ consoleBinds }
+
+addEvidence :: String -> Either Box Value -> IO ()
+addEvidence n v =
+  atomicModifyIORef' unsafeCollectedEvidence $ (,()) . M.insert n v
+
+unsafeCollectedEvidence :: IORef (Map String (Either Box Value))
+unsafeCollectedEvidence = unsafePerformIO $ newIORef M.empty
+{-# noinline unsafeCollectedEvidence #-}
+
+-------------------------------------------------------------------------------
+newtype ConsoleM a = ConsoleM{ unConsoleM :: StateT Console (InputT IO) a }
+  deriving newtype
+    ( Applicative, Functor, Monad, MonadCatch, MonadIO, MonadMask
+    , MonadState Console, MonadThrow
+    )
+
+data Console = Console{
+    consoleRepOptions :: RepOptions
+  , consolePrompt     :: String
+  , consoleBinds      :: Map String (Either Box Value)
+  } deriving stock Show
+
+defaultConsole :: Console
+defaultConsole = Console (RepOptions 16 False False) "heap-console> " M.empty
+
+data ConsoleExit = ConsoleExit
+  deriving stock Show
+  deriving anyclass Exception
+
+runConsoleM :: Console -> ConsoleM a -> IO (Maybe a)
+runConsoleM c = handle (\ConsoleExit -> pure Nothing) . fmap Just
+              . runInputT defaultSettings . flip evalStateT c . unConsoleM
+
+exitConsole :: ConsoleM a
+exitConsole = throwM ConsoleExit
+
+liftRepM :: RepM a -> ConsoleM (Either String a)
+liftRepM ma = ConsoleM $ liftIO . runRepM ma =<< gets consoleRepOptions
+
+withRepM :: RepM a -> (a -> ConsoleM ()) -> ConsoleM ()
+withRepM ma f = liftRepM ma >>= either errorC f
+
+putStrLnC :: String -> ConsoleM ()
+putStrLnC = ConsoleM . lift . outputStrLn
+
+getLnC :: ConsoleM String
+getLnC = maybe exitConsole pure =<< ConsoleM do
+  lift . getInputLine =<< gets consolePrompt
+
+errorC :: String -> ConsoleM ()
+errorC = ConsoleM . lift . outputStrLn . ("error: " ++)
+
+catchInterrupt :: ConsoleM () -> ConsoleM ()
+catchInterrupt (ConsoleM ma) =
+  handleInterrupt (pure ()) $ ConsoleM $ mapStateT withInterrupt ma
+
+heapConsole :: Console -> IO ()
+heapConsole c = do
+  putStrLn "[Entering heap-view - use `:help` for more information]"
+  _ <- runConsoleM c $ forever $ catchInterrupt $
+    either errorC commands . parseCommand =<< getLnC
+  putStrLn "[Exiting heap-view]"
+
+commands :: [String] -> ConsoleM ()
+commands = \case
+  [] -> pure ()
+
+  ":help":_ -> putStrLnC
+    -- TODO: move descriptions of options to 'Option'?
+    "Usage:\n\
+    \  :help                    - shows this text.\n\
+    \  :exit | :quit | <ctrl-D> - returns back to program.\n\
+    \  :show [OPTION]           - shows value of a selected option, or values of all\n\
+    \                             options if not given any. Available options:\n\
+    \    depth :: Natural\n\
+    \      depth of printed representation\n\
+    \    showTypes :: Bool\n\
+    \      whether to show types in printed representation\n\
+    \    prompt :: String\n\
+    \      console prompt\n\
+    \    strict :: Bool\n\
+    \      whether inspection should always force values along the way\n\
+    \\n\
+    \  :set OPTION VALUE        - changes option to given value.\n\
+    \  NAME = SELECTION         - binds result of SELECTION to NAME.\n\
+    \  [!]SELECTION             - prints selection [strictly].\n\
+    \  :info SELECTION          - prints info about selected value.\n\
+    \  :binds                   - lists bindings in scope."
+
+  ":exit":_ -> exitConsole
+  ":quit":_ -> exitConsole
+
+  ":show":o:_ -> withOption o \l -> putStrLnC . view l =<< get
+  ":show":[]  -> for_ (M.toList options) \(o, l) ->
+    putStrLnC . (++) (o ++ " = ") . view l =<< get
+  ":show":_   -> errorC "expecting option name or no argument in `:show`"
+
+  ":set":o:v:_ -> withOption o \l -> case set l v of
+    Nothing -> errorC $ "invalid value for option `" ++ o ++ "`"
+    Just f  -> modify f
+  ":set":_     -> errorC "expecting option name and value in `:set`"
+
+  ":info":s:_ -> withSelected s $
+    putStrLnC . show <=< liftIO .
+      either getBoxedClosureData (\(Value v) -> getClosureData v)
+  ":info":_   -> errorC "expecting selection in `:info`"
+
+  ":binds":[] -> gets consoleBinds >>= traverse_ putStrLnC . M.keys
+  ":binds":_  -> errorC "expecting no arguments in `:binds`"
+
+  c@(':':_):_ -> errorC $ "unknown command `" ++ c ++ "`"
+
+  n:"=":s:_
+    | isIdentifier n -> withSelected s \v -> modify \c ->
+      c{ consoleBinds = M.insert n v $ consoleBinds c }
+    | otherwise -> errorC $ "`" ++ n ++ "` isn't valid binding name"
+
+  s:[] -> withSelected s \v -> withRepM (prettyRep v) putStrLnC
+
+  _ -> errorC "couldn't parse input"
+
+parseCommand :: String -> Either String [String]
+parseCommand = goSpaced where
+  goSpaced = \case
+    [] -> Right []
+    cs -> do
+      (x, cs') <- goLexeme False (dropWhile isSpace cs)
+      (x :) <$> goSpaced cs'
+  goLexeme q = \case
+    "" | not q     -> Right ([], [])
+       | otherwise -> Left "unexpected end of line, expected quote '\"'"
+    '\\':'"':cs    -> first ('"':) <$> goLexeme q cs
+    '"':cs         -> goLexeme (not q) cs
+    ' ':cs | not q -> Right ([], cs)
+    c:cs           -> first (c:) <$> goLexeme q cs
+
+parseSelection :: String -> Either String (Bool, String, [String])
+parseSelection = \case
+  '!':cs -> uncurry (True,,)  <$> go cs
+  cs     -> uncurry (False,,) <$> go cs
+ where
+  go = groupBy (\_ c -> c /= '.') >>> \case
+    []   -> Left "missing selection"
+    n:is -> Right (n, tail <$> is)
+
+isIdentifier :: String -> Bool
+isIdentifier = \case
+  [] -> False
+  c:cs -> (isAlpha c || c == '_') && all ((||) <$> isAlphaNum <*> (== '_')) cs
+
+withBind :: String -> (Either Box Value -> ConsoleM ()) -> ConsoleM ()
+withBind n f = maybe (errorC $ "binding `" ++ n ++ "` not in scope") f .
+  M.lookup n =<< gets consoleBinds
+
+withSelected :: String -> (Either Box Value -> ConsoleM ()) -> ConsoleM ()
+withSelected s f = parseSelection s & either errorC \(strict, n, is) ->
+  withBind n \v -> withRepM (index v strict is) f
+
+withOption :: String -> (Option Console -> ConsoleM ()) -> ConsoleM ()
+withOption o f =
+  maybe (errorC $ "there's no option `" ++ o ++ "`") f $ M.lookup o options
+
+options :: Map String (Option Console)
+options = M.fromList
+  [ ( "depth"
+    , option (repDepth . consoleRepOptions) \repDepth c ->
+        c{ consoleRepOptions = (consoleRepOptions c){ repDepth } }
+    )
+  , ( "showTypes"
+    , option (repTypes . consoleRepOptions) \repTypes c ->
+        c{ consoleRepOptions = (consoleRepOptions c){ repTypes } }
+    )
+  , ( "prompt"
+    , Option (show . consolePrompt) \consolePrompt ->
+        Just \c -> c{ consolePrompt }
+    )
+  , ( "strict"
+    , option (repStrict . consoleRepOptions) \repStrict c ->
+        c{ consoleRepOptions = (consoleRepOptions c){ repStrict } }
+    )
+  ]
+
+-------------------------------------------------------------------------------
+data Option a = Option{ view :: a -> String, set :: String -> Maybe (a -> a) }
+
+option :: (Show a, Read a) => (x -> a) -> (a -> x -> x) -> Option x
+option f t = Option (show . f) $ fmap t . readMaybe
diff --git a/src/Heap/Console/Value.hs b/src/Heap/Console/Value.hs
new file mode 100644
--- /dev/null
+++ b/src/Heap/Console/Value.hs
@@ -0,0 +1,552 @@
+{-# language AllowAmbiguousTypes, MagicHash #-}
+
+-- | Utilities for inspection of Haskell values.
+module Heap.Console.Value
+  ( FromValue (..)
+  , Name (..)
+  , conName
+  , PrettyType
+  , prettyType
+  , RepM
+  , RepOptions (..)
+  , runRepM
+  , Value (..)
+  , valueFromData
+  , Box (..)
+  , asBox
+  , boxFromAny
+  , index
+  , prettyRep
+  ) where
+
+import Control.Applicative
+import Control.Arrow hiding (first, second)
+import Control.Exception
+import Control.Monad.Except
+import Control.Monad.Reader
+import Data.Bifunctor
+import Data.Bitraversable
+import Data.Bool
+import Data.Data
+  ( constrFields, constrFixity, constrRep, ConstrRep (..), Data (..)
+  , Fixity (..), showConstr
+  )
+import Data.Traversable
+import Data.Function
+import Data.Functor
+import Data.Int
+import Data.List
+import Data.Maybe
+import Data.Word
+import GHC.Exts
+import GHC.Exts.Heap
+import GHC.Float
+import GHC.Pack
+import GHC.Stack
+import Numeric.Natural
+import System.IO.Unsafe
+import System.Mem
+import Text.Read (readMaybe)
+import Text.Show.Combinators
+import Type.Reflection
+
+-------------------------------------------------------------------------------
+-- | Interpretation of Haskell value into representation @r@. Allows user to
+-- interpret inspection done by 'valueFromData' or 'boxFromAny' as needed.
+data FromValue box rep = forall info. FromValue{
+    -- | Embeds information created about value together with it's @box@
+    -- (wrapped original value itself) into final representation. It allows one
+    -- to e.g. discard @box@ if not used.
+    box          :: box -> info -> rep
+
+  , list         :: [rep] -> Maybe box -> info
+  , string       :: [Either box Char] -> Maybe box -> info
+  , char         :: Char -> info
+  , tuple        :: [rep] -> info
+  , con          :: Name -> [Word] -> [rep] -> info
+  , rec          :: Name -> [(String, rep)] -> info
+  , fun          :: info
+  , thunk        :: info
+  , bytecode     :: info
+  , byteArray    :: Word -> [Word] -> info
+  , mutByteArray :: info -- TODO: more precise
+  , mVar         :: info -- TODO: more precise?
+  , mutVar       :: rep -> info
+  , stmQueue     :: info -- TODO: more precise?
+  , integral     :: Integer -> PrettyType -> info
+  , floating     :: Double -> PrettyType -> info
+  , int#         :: Int -> info
+  , word#        :: Word -> info
+  , int64#       :: Int64 -> info
+  , word64#      :: Word64 -> info
+  , addr#        :: Int -> info
+  , float#       :: Float -> info
+  , double#      :: Double -> info
+  , other        :: info
+  , depthLimit   :: info
+  }
+
+-- | Runtime representation of Haskell identifier - can be both of type or
+-- value.
+data Name = Name{
+    namePkg    :: String
+  , nameMod    :: String
+  , nameId     :: String
+  , nameFixity :: Fixity
+  } deriving stock Show
+
+-- | 'Name' of given data constructor.
+conName :: forall a. Data a => a -> Name
+conName a =
+  Name (tyConPackage tc) (tyConModule tc) (showConstr vc) (constrFixity vc)
+ where
+  tc = typeRepTyCon $ typeRep @a
+  vc = toConstr a
+
+-- | Pretty representation of type at runtime - currently just
+-- 'Prelude.String'.
+type PrettyType = String
+
+-- | Shows type @a@ as 'PrettyType'.
+prettyType :: forall a. Typeable a => PrettyType
+prettyType = show $ typeRep @a
+
+-------------------------------------------------------------------------------
+-- | Monad for inspecting representation of Haskell values - see 'runRepM'.
+newtype RepM a
+  = RepM{ unRepM :: ReaderT RepOptions (ExceptT String IO) a }
+  deriving newtype
+    ( Alternative, Applicative, Functor, Monad, MonadIO, MonadError String
+    , MonadReader RepOptions
+    )
+
+-- | Options for representation inspection.
+data RepOptions = RepOptions{
+    -- | Depth of inspection - guards against getting stuck in infinite
+    -- structures.
+    repDepth  :: Natural
+    -- | Whether inspection should force thunks along the way.
+  , repStrict :: Bool
+    -- | Whether printed representations should contain type signatures in
+    -- ambiguous places - used by 'prettyRep'.
+  , repTypes  :: Bool
+  } deriving stock Show
+
+-- | Runs action that may make use of inspection of representation of Haskell
+-- values (e.g. using 'valueFromData' or 'boxFromAny').
+runRepM :: RepM a -> RepOptions -> IO (Either String a)
+runRepM = fmap runExceptT . runReaderT . unRepM
+
+-------------------------------------------------------------------------------
+-- | Lifted Haskell value together with it's 'Data' instance.
+data Value = forall a. Data a => Value a
+
+instance Show Value where
+  show = either error id . unsafePerformIO .
+    flip runRepM (RepOptions 100 True False) . prettyRep . Right
+
+-- | Inspects any value with 'Data' instance using given interpretation. Prefer
+--- over 'boxFromAny' where possible.
+valueFromData :: forall a r. Data a => FromValue Value r -> a -> RepM r
+valueFromData FromValue{..} a = flip go a =<< asks repDepth where
+  go :: forall x. Data x => Natural -> x -> RepM r
+  go n = fmap <$> box . Value <*> case n of
+    0 -> \_ -> pure depthLimit
+    _ -> thunked (\_ -> pure thunk) \x -> case constrRep $ toConstr x of
+      IntConstr   i -> pure $ integral i $ prettyType @x
+      FloatConstr r -> pure $
+        floating
+          case typeRep @x of
+            Float  -> float2Double x
+            Double -> x
+            _      -> fromRational r
+          (prettyType @x)
+      CharConstr  c -> pure $ char c
+      AlgConstr{}   -> case typeOf x of
+        String -> uncurry string <$> stringRep n x
+        List   -> uncurry list   <$> listRep   n x
+        _ -> conOf a . reverse <$>
+          confoldl (\ys y -> (:) <$> go (n - 1) y <*> ys) (pure []) a
+
+  conOf :: forall x. Data x => x -> [r] -> _
+  conOf x
+    | '(':_ <- nameId c                     = tuple
+    | fs@(_:_) <- constrFields $ toConstr x = rec c . zip fs
+    | otherwise                             = con c []
+    where c = conName x
+
+  stringRep :: Natural -> String -> RepM ([Either Value Char], Maybe Value)
+  stringRep 0 = tailThunk
+  stringRep n = thunked tailThunk \case
+    []   -> pure ([], Nothing)
+    c:cs -> first . (:) <$> thunked' (Left . Value) Right c
+                        <*> stringRep (n - 1) cs
+
+  listRep :: forall x. Data [x] => Natural -> [x] -> RepM ([r], Maybe Value)
+  listRep 0 = tailThunk
+  listRep n = thunked tailThunk \case
+    []   -> pure ([], Nothing)
+    x:xs -> do
+      -- We don't have 'Data a', so instead we capture first field of (':')
+      -- with it's instance and ignore the rest
+      x' <- fromJust $
+        confoldl (\r y -> r <|> Just (go n y)) Nothing [x]
+      first (x':) <$> listRep (n - 1) xs
+
+  tailThunk :: forall x y. Data x => x -> RepM ([y], Maybe Value)
+  tailThunk = pure . ([],) . Just . Value
+
+-------------------------------------------------------------------------------
+-- | Inspects any lifted value using given interpretation. This function can't
+-- recover some information compared to 'valueFromData' - specifically, it
+-- never recovers record syntax and unpacked fields are only provided by their
+-- representation using 'Word's.
+boxFromAny :: forall r a. FromValue Box r -> a -> RepM r
+-- TODO: levity polymorphism
+boxFromAny FromValue{..} a = flip go a =<< asks repDepth where
+  go :: forall x. Natural -> x -> RepM r
+  go = \case
+    0 -> boxWith depthLimit
+    d -> thunked (boxWith thunk) \x ->
+      liftIO (getClosureData x) >>= \case
+        ConstrClosure{ ptrArgs, dataArgs, pkg, modl, name } ->
+          box (asBox x) <$> case (pkg, modl, name) of
+            ("ghc-prim", "GHC.Types", n)
+              | "I#" <- n -> unsafeIntegral @Int x "Int"
+              | "W#" <- n -> unsafeIntegral @Word x "Word"
+              | "F#" <- n -> unsafeFloating float2Double x "Float"
+              | "D#" <- n -> unsafeFloating id x "Double"
+              | "C#" <- n -> pure $ char (unsafeCoerce# x :: Char)
+              | n `elem` [":", "[]"] -> boxListRep d ptrArgs
+            -- TODO: integer-simple
+            ("ghc-prim", "GHC.Tuple", _) ->
+              tuple <$> for ptrArgs \(Box y) -> go (d - 1) y
+            ("integer-wired-in", "GHC.Integer.Type", n)
+              | n `elem` ["S#", "Jp#", "Jn#"] ->
+                unsafeIntegral @Integer x "Integer"
+            ("base", "GHC.Natural", n)
+              | n `elem` ["NatS#", "NatJ#"] ->
+                unsafeIntegral @Natural x "Natural"
+            ("base", "GHC.Int", n)
+              | "I8#"  <- n -> unsafeIntegral @Int8 x "Int8"
+              | "I16#" <- n -> unsafeIntegral @Int16 x "Int16"
+              | "I32#" <- n -> unsafeIntegral @Int32 x "Int32"
+              | "I64#" <- n -> unsafeIntegral @Int64 x "Int64"
+            ("base", "GHC.Word", n)
+              | "W8#"  <- n -> unsafeIntegral @Word8 x "Word8"
+              | "W16#" <- n -> unsafeIntegral @Word16 x "Word16"
+              | "W32#" <- n -> unsafeIntegral @Word32 x "Word32"
+              | "W64#" <- n -> unsafeIntegral @Word64 x "Word64"
+            (_, _, n)
+              | let fixity  = case n of ':':_ -> Infix; _ -> Prefix
+                    boxName = Name pkg modl name fixity ->
+                con boxName dataArgs <$> for ptrArgs \(Box y) -> go (d - 1) y
+        IndClosure _ (Box i) -> go (d - 1) i
+        WeakClosure{ value = Box i } -> go (d - 1) i
+        MutVarClosure _ (Box i) -> box (asBox x) . mutVar <$> go (d - 1) i
+        v -> pure $ box (asBox x) case v of
+          FunClosure{}           -> fun
+          PAPClosure{}           -> fun
+          BCOClosure{}           -> bytecode
+          ArrWordsClosure _ s ws -> byteArray s ws
+          MutArrClosure{}        -> mutByteArray
+          MVarClosure{}          -> mVar
+          BlockingQueueClosure{} -> stmQueue
+          IntClosure _ i         -> int# i
+          WordClosure _ w        -> word# w
+          Int64Closure _ i       -> int64# i
+          Word64Closure _ w      -> word64# w
+          AddrClosure _ p        -> addr# p
+          FloatClosure _ f       -> float# f
+          DoubleClosure _ f      -> double# f
+          _ -> other
+
+  boxWith :: forall x. _ -> x -> RepM r
+  boxWith r b = pure $ box (asBox b) r
+
+  unsafeIntegral :: forall x b. Integral x => b -> PrettyType -> RepM _
+  unsafeIntegral i = pure . integral (toInteger (unsafeCoerce# i :: x))
+
+  unsafeFloating :: forall y x. (x -> Double) -> y -> PrettyType -> RepM _
+  unsafeFloating f d = pure . floating (f (unsafeCoerce# @_ @_ @y @x d))
+
+  boxListRep :: Natural -> [Box] -> RepM _
+  boxListRep d = goList d False id where
+    goList _ isStr acc [] =
+      mkList isStr (acc []) Nothing
+
+    goList 0 isStr acc [x, xs] = mkList isStr (acc [x]) $ Just xs
+
+    goList n isStr acc [Box x, Box xs] = do
+      isChar <- thunked (\_ -> pure False)
+        do liftIO . getClosureData >>> fmap \case
+             CharClosure -> True
+             _           -> False
+        x
+      thunked
+        do mkList (isStr || isChar) (acc [asBox x]) . Just . asBox
+        do liftIO . getClosureData >=> \case
+             ConstrClosure{ ptrArgs } ->
+               goList (n - 1) (isStr || isChar) (acc . (asBox x :)) ptrArgs
+             _ -> invalidList
+        xs
+
+    goList _ _ _ _ = invalidList
+
+    invalidList :: HasCallStack => z
+    invalidList = withFrozenCallStack $
+      error "Heap.Console.Inspector.boxListRep: invalid list"
+
+    mkList False xs t = flip list t <$>
+      for (zip [d, d - 1 .. 0] xs) \(n, Box x) -> go n x
+    mkList True  xs t = flip string t <$> for xs \(Box c) ->
+      thunked' (Left . Box) (Right . id @Char . unsafeCoerce#) c
+
+-------------------------------------------------------------------------------
+-- | Indexes Haskell value using given "selection" - that is, 'Bool'
+-- determining whether indexing should be always strict and list of indexes to
+-- walk through along the way. Valid indexes are:
+--
+-- * positive integer (e.g. @3@) - position of element in list, tuple or other
+--   data constructor
+--
+-- * record field name (e.g. @foo@) - name of field in record (only works when
+--   given enough information - that is, with 'Value' as input)
+--
+-- In case of 'Box', unpacked values are ignored while indexing.
+index :: Either Box Value -> Bool -> [String] -> RepM (Either Box Value)
+index a strict fs' = local (\o -> o{ repStrict = strict || repStrict o } ) $
+  bitraverse
+    (\(Box x)   -> ($ fs') =<< boxFromAny    FromValue{..} x)
+    (\(Value v) -> ($ fs') =<< valueFromData FromValue{..} v)
+    a
+ where
+  withIndexes
+    :: ([String] -> String -> RepM b) -> b -> [String] -> RepM b
+  withIndexes _ b []     = pure b
+  withIndexes g _ (f:fs) = g fs f
+
+  withList
+    :: [[String] -> RepM b]
+    -> Maybe b
+    -> b -> [String] -> RepM b
+  withList xs t = withIndexes \fs -> \case
+    s | Just i <- readMaybe @Natural s ->
+        case (drop (fromIntegral i) xs, t) of
+          (x:_, _) -> x fs
+          ([], Nothing) -> throwError $ "index '" ++ s ++ "' out of range"
+          ([], Just{}) -> throwError $
+            "index '" ++ s ++ "' not yet evaluated, try using '!'"
+      | otherwise -> throwError $
+        "expected positive integer as index, found '" ++ s ++ "'"
+
+  notIndexable :: String -> b -> [String] -> RepM b
+  notIndexable _   b [] = pure b
+  notIndexable msg _ fs = throwError $
+    "unexpected indexing '." ++ intercalate "." fs ++ "' of " ++ msg
+
+  box = (&)
+
+  list = withList
+
+  -- TODO: make indexable
+  string _ _ = notIndexable "'String'"
+  char _ = notIndexable "'Char'"
+
+  tuple = flip withList Nothing
+
+  con _ _ = flip withList Nothing
+
+  rec _ xs b is =
+    withList (snd <$> xs) Nothing b is `catchError` \_ ->
+      withIndexes goRec b is
+   where
+    goRec fs f = case lookup f xs of
+      Nothing -> throwError $ "no index or field '" ++ f ++ "' found"
+      Just x  -> x fs
+
+  fun = notIndexable "a function"
+  thunk = notIndexable "a thunk - try using '!'"
+  bytecode = notIndexable "bytecode"
+  byteArray _ _ = notIndexable "'ByteArray#' - currently not supported"
+  mutByteArray = notIndexable "'MutableByteArray#' - currently not supported"
+  mVar = notIndexable "'MVar#' - currently not supported"
+
+  mutVar r _ fs = r fs
+
+  stmQueue = notIndexable "a STM queue"
+  integral _ _ = notIndexable "an integral number"
+  floating _ _ = notIndexable "an floating number"
+  int# _ = notIndexable "'Int#'"
+  word# _ = notIndexable "'Word#'"
+  int64# _ = notIndexable "'Int64#'"
+  word64# _ = notIndexable "'Word64#'"
+  addr# _ = notIndexable "an address"
+  float# _ = notIndexable "'Float#'"
+  double# _ = notIndexable "'Double#'"
+  other = notIndexable "an unknown value"
+  depthLimit = notIndexable "a value after depth limit - try using '!'"
+
+-------------------------------------------------------------------------------
+-- | Pretty-print given value. In case of 'Box', record syntax is never shown
+-- and (unpacked) fields may be shown as 'Word#'s out of order.
+prettyRep :: Either Box Value -> RepM String
+prettyRep a = ask >>= go where
+  go RepOptions{..} = do
+    pretty <- case a of
+      Left (Box x)    -> boxFromAny    FromValue{..} x
+      Right (Value v) -> valueFromData FromValue{..} v
+    pure $ pretty -1 ""
+   where
+    -- NOTE: 'dataToHsRep' does forcing, so don't bother with forcing of thunks
+    -- during printing.
+
+    signature :: Show b => b -> String -> PrecShowS
+    signature b t = if repTypes
+      then showInfix "::" -1 (precShow b) \_ -> (t ++)
+      else precShow b
+
+    postfix :: Show b => String -> b -> PrecShowS
+    postfix p b _ = shows b . (p ++)
+
+    precShow :: Show b => b -> PrecShowS
+    precShow = flip showsPrec
+
+    intercalateS :: String -> [ShowS] -> ShowS
+    intercalateS _ []     = id
+    intercalateS _ [x]    = x
+    intercalateS s (x:xs) = x . (s ++) . intercalateS s xs
+
+    box _ = id
+
+    list xs t _ = ('[':)
+                . intercalateS ", " (xs <&> ($ -1))
+                . ((++) if isJust t then ", .." else "")
+                . (']':)
+
+    string cs t _ = ('"':)
+                  . foldl' (.) id (either (\_ -> ("\"_\"" ++)) (:) <$> cs)
+                  . ('"':)
+                  . (++) if isJust t then ".." else ""
+
+    char = precShow
+
+    tuple xs _ = ('(':)
+               . intercalateS ", " (xs <&> ($ -1))
+               . (')':)
+
+    con n@Name{..} ws xs = case nameFixity of
+      Prefix -> foldl'
+        showApp
+        (foldl' showApp (showCon nameId) $ postfix "##" <$> ws)
+        xs
+      Infix -> case (postfix "##" <$> ws) ++ xs of
+        []     -> const (showParen True (nameId ++))
+        [x]    -> const (showParen True (nameId ++)) `showApp` x
+        [x, y] -> showInfix nameId 9 x y
+        x:y:ys -> foldl' showApp (con n [] [x, y]) ys
+
+    rec Name{..} = showRecord n . \case
+      [] -> noFields
+      xs -> foldl1' (&|) $ uncurry showField <$> xs
+     where
+      n = case nameFixity of
+        Infix  -> '(' : nameId ++ ")"
+        Prefix -> nameId
+
+    fun p = showParen (p > -1) ("\\_ -> _" ++)
+    thunk = showCon "_"
+    bytecode = showCon "_bytecode"
+    byteArray _ ws p = list (postfix "#" <$> ws) Nothing p . ('#':)
+    mutByteArray = showCon "_mutByteArray"
+    mVar = showCon "_mVar"
+    mutVar = showApp $ showCon "MutVar#"
+    stmQueue = showCon "_stmQueue"
+    integral = signature
+    floating = signature
+    int# = postfix "#"
+    word# = postfix "##"
+    int64# = postfix "L#"
+    word64# = postfix "L##"
+    addr# (I# i) = precShow $ unpackCString# (unsafeCoerce# i)
+    float# = postfix "#"
+    double# = postfix "##"
+    other = showCon "_unknown"
+    depthLimit = showCon ".."
+
+-------------------------------------------------------------------------------
+-- | Branches on presence of thunk - in @thunked tf ntf@, @tf@ runs when value
+-- is a thunk and @ntf@ when it isn't. When 'repStrict' is set, 'thunked' will
+-- instead always force the value and pass it to @ntf@.
+thunked :: (a -> RepM b) -> (a -> RepM b) -> a -> RepM b
+thunked tf ntf a = asks repStrict >>= \case
+  False -> isThunk a >>= bool (ntf a) (tf a)
+  True  -> a `seq'` ntf a
+
+-- | Version of 'thunked' taking pure functions.
+thunked' :: (a -> b) -> (a -> b) -> a -> RepM b
+thunked' tf ntf = thunked (pure . tf) (pure . ntf)
+
+-- | Tests whether value is a thunk - that is, any type of closure that can be
+-- considered one.
+isThunk :: MonadIO m => a -> m Bool
+isThunk = liftIO . getClosureData >>> fmap \case
+  ThunkClosure{} -> True
+  SelectorClosure{} -> True
+  APClosure{} -> True
+  APStackClosure{} -> True
+  BlackholeClosure{} -> True
+  _ -> False
+
+-- | Version of 'seq' that blocks until it's first argument is forced.
+seq' :: a -> b -> b
+-- TODO: 'performGC' is used to speed up forcing in GHCi, where it seems to
+-- otherwise get stuck for dozens of seconds under blackhole - investigate
+-- ways of resolving the issue without visibly slowing down whole operation.
+seq' a = seq $ unsafePerformIO $ evaluate a *> performGC *> whileM (isThunk a)
+{-# noinline seq' #-}
+
+-------------------------------------------------------------------------------
+-- | Equivalent of 'foldl' for data constructors, providing opaque fields
+-- with their 'Data' instance along the way.
+confoldl :: Data a => (forall b. Data b => r -> b -> r) -> r -> a -> r
+confoldl c n = getConst . gfoldl
+  do \(Const acc) -> Const . c acc
+  do \_ -> Const n
+
+-------------------------------------------------------------------------------
+-- | Repeatedly evaluates provided action until it yields 'False'.
+whileM :: Monad m => m Bool -> m ()
+whileM mb = bool (pure ()) (whileM mb) =<< mb
+
+-------------------------------------------------------------------------------
+-- | Tests equality of @a@ to @b@ described by given 'TypeRep'.
+isType :: forall a b. Typeable a => TypeRep b -> Maybe (a :~~: b)
+isType = eqTypeRep typeRep
+
+-- | Proof of @a@ being equal to @f b@ for some @b@.
+data IsCon f a = forall b. a ~ f b => IsCon
+
+-- | Tests whether @a@ described by given 'TypeRep' is equal to @f b@ for some
+-- @b@.
+isCon :: forall f a. Typeable f => TypeRep a -> Maybe (IsCon f a)
+isCon = \case
+  App l _ | Just HRefl <- eqTypeRep l $ typeRep @f -> Just IsCon
+  _ -> Nothing
+
+pattern Float :: () => a ~ Float => TypeRep a
+pattern Float <- (isType @Float -> Just HRefl)
+
+pattern Double :: () => a ~ Double => TypeRep a
+pattern Double <- (isType @Double -> Just HRefl)
+
+pattern String :: () => a ~ String => TypeRep a
+pattern String <- (isType @String -> Just HRefl)
+
+pattern List :: () => a ~ [b] => TypeRep a
+pattern List <- (isCon @[] -> Just IsCon)
+
+-------------------------------------------------------------------------------
+pattern CharClosure :: GenClosure a
+pattern CharClosure <-
+  ConstrClosure{ pkg = "ghc-prim", modl = "GHC.Types", name = "C#" }
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
